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.
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.
output: ‘export’. GitHub Pages serves static files only, so the build emits plain HTML to out/. React is the right tool here because the page is genuinely stateful: two controls drive three visualizations through shared context.basePath: ‘/meridian-advisor’ + assetPrefix + trailingSlash: true in next.config.mjs, then copy out/ to docs/ and point Pages at main /docs. A .nojekyll file stops Jekyll from eating the _next/ directory.next/font — self-hosted at build time, so the static export has zero runtime font dependencies.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.
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.
input[type=range]; the risk selector is a fieldset of real radios styled as a segmented control — arrow keys, focus rings and screen readers work for free..num sets JetBrains Mono + font-variant-numeric: tabular-nums, so rolling readouts never jitter horizontally.+ marks pin the section rules to the grid..js to <html>; reveals and the hero intro only exist under that class, so content is never hidden without JavaScript. SMIL particles in the pots diagram render only when motion is allowed.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.