← MeridianTHE GUIDE · HOW IT WAS BUILT
Design showcase · build notes

A paycheck with percentiles.

Meridian is a robo-advisor concept for freelancers, built as a Next.js static export. Everything you see is React-rendered SVG and CSS — no images, no chart library. The whole pitch rides on one interactive chart and two supporting diagrams.

The idea

Freelancers don’t have a payroll department, so three jobs go undone: tax gets set aside after it’s spent, income arrives in the wrong order, and investing waits for a “stable month” that never comes. Meridian’s pitch is that software can be the payroll department. The site had to make that feel mechanical and trustworthy, so the identity is Swiss: a visible 12-column hairline grid, one accent (chartreuse on navy), and every numeral in tabular JetBrains Mono. The voice stays anti-jargon — “a steady paycheck, invented from an unsteady one.”

The interaction research came from shipped products on Mobbin: Quicken’s retirement fan with High/Expected/Low bands, Acorns’ risk donut with drill rows, Nutmeg’s plain-language risk levels, and Monzo’s salary sorter for the pots diagram.

The stack

Signature technique #1 — the projection fan

The centerpiece is a contribution slider + risk radiogroup driving a percentile fan. Real robo-advisors run Monte Carlo; a static site shouldn’t pretend to. Instead Meridian uses the closed-form lognormal approximation: if portfolio returns have expected drift m and volatility s, the p-th percentile of the annualized return over t years is

// percentile of CAGR after t years, z = ±1.2816 for p10/p90
const cagr = Math.exp(m - (s * s) / 2 + (z * s) / Math.sqrt(t)) - 1;

// future value of the monthly contribution stream at that rate
const r = Math.pow(1 + cagr, 1 / 12) - 1;
const value = monthly * ((Math.pow(1 + r, 12 * t) - 1) / r);

Evaluate that for z = −1.28, 0, +1.28 across 35 years and you get three series: the 10th percentile, the median, and the 90th — a fan that’s honest about uncertainty (wide in relative terms early, compounding outward in dollars). The band is one SVG path: the p90 series drawn forward, the p10 series drawn back, closed with Z.

The animation is a single hook. Every input change recomputes target arrays; the hook tweens the previous values toward them with rAF and the site’s signature settle, and the readout, axis labels and paths all re-render from the same tweened array — so the “at 65 you’d have” number rolls like an odometer in sync with the chart:

export const settle = (t) => 1 - Math.pow(1 - t, 4.2); // matches --ease

function useTweenedValues(targets, duration = 700) {
  // on change: from = current, to = targets
  const step = (now) => {
    const k = settle(Math.min(1, (now - t0) / duration));
    for (let i = 0; i < n; i++) next[i] = from[i] + (to[i] - from[i]) * k;
    setValues(next);
    if (k < 1) raf = requestAnimationFrame(step);
  };
  // prefers-reduced-motion? snap: setValues(targets)
}

The y-axis maximum is part of the same tweened array, so the scale glides instead of jumping when you flip from Conservative to Aggressive. Reduced motion snaps everything to final state — the chart still works, it just doesn’t perform.

Signature technique #2 — the re-weighting donut

The allocation donut is six <circle> strokes sharing one radius. Each segment is a dash pattern: length weight × circumference, offset by the cumulative weight before it. Because the weights come from the same useTweenedValues hook, switching risk levels morphs every arc — and the drill rows’ percentages count along with them.

const C = 2 * Math.PI * R;
let acc = 0;
const segs = weights.map((w) => {
  const seg = { len: w * C - GAP, offset: -(acc * C + GAP / 2) };
  acc += w;
  return seg;
});
// <circle strokeDasharray={`${len} ${C}`} strokeDashoffset={offset} />

Both the explorer and the donut read from one PlanContext, so changing risk in the portfolio section also re-fans the projection at the top of the page. The site behaves like one instrument, not a page of widgets.

Details that matter

Ship it on GitHub Pages

The whole deploy is four commands once the export is configured:

npm run build                 # next build → out/ (static)
rm -rf docs && cp -r out docs # Pages serves /docs on main
touch docs/.nojekyll          # keep _next/ out of Jekyll's mouth
gh api --method POST /repos/bswxyz/meridian-advisor/pages \
  -f 'source[branch]=main' -f 'source[path]=/docs'

With trailingSlash: true, this page exports as /guide/index.html, so the URL works without any server-side routing. Local dev is just npm run dev — note the basePath means the site serves at localhost:3000/meridian-advisor.

← Back to Meridian