A light-theme precision fintech site where hand-built, self-drawing SVG charts are the design signature. Plain HTML/CSS/JS, GSAP + ScrollTrigger from a CDN, zero chart libraries, no build step.
Meridian is a consumer money-management app: spending insights, auto-save, high-yield vaults. The design direction is precision SaaS — paper-white ground (#fcfcfa), ink-black type, exactly one brand color (deep emerald #0e7a4f) with fine gradient touches, and data visualization treated as the hero visual rather than decoration.
Two things carry the page: a coded app mockup (real HTML/CSS in a device frame — balance header, categorized transaction list, insight card — no fake screenshot), and a chart system where every mark is an SVG element we position ourselves.
Chart colors were not hand-picked. The four categorical slots were run through a palette validator that checks OKLCH lightness bands, a chroma floor, color-vision-deficiency separation (Machado 2009 ΔE), and contrast against the actual chart surface:
Result: worst all-pairs CVD ΔE of 14.9 (target ≥ 12 — segments in a donut wrap, so every pair can be adjacent). One warning: amber sits below 3:1 contrast on white. The rule for that is relief — the amber donut segment carries a direct percentage label and every value also appears in the legend and the table view, so color never carries meaning alone.
| Role | Face | Why |
|---|---|---|
| Display / headlines | Space Grotesk 600 | A geometric grotesque with just enough character; tight -.02em tracking. |
| Body / UI | Inter 400–600 | Neutral, reads small, disappears behind content. |
| Every figure | IBM Plex Mono 500–600 + tabular-nums | Fintech numbers should feel machined. Tabular figures also stop animated counters from jittering horizontally as digits change. |
All charts are built by ~300 lines of vanilla JS that write inline SVG. Each chart is data-first: arrays of real, internally-consistent numbers (October's donut segments sum to $2,986, which is October's point on the area chart; the $486 weekly total is the sum of the seven bars).
The stroke-dash trick: set stroke-dasharray to the path's own length, offset it fully, then animate the offset to zero. The big chart scrubs this against scroll position, so the line literally draws as you scroll into it:
const len = path.getTotalLength();
path.style.strokeDasharray = len;
path.style.strokeDashoffset = len;
gsap.timeline({ scrollTrigger:{
trigger:"#bigChartWrap", start:"top 78%", end:"top 30%", scrub:1 }})
.to(path, { strokeDashoffset:0, ease:"none" }, 0)
.to(clipRect, { attr:{ width: plotWidth }, ease:"none" }, 0) // area wash
.to(dots, { opacity:1, stagger:.055 }, .12);
The area fill can't be dash-animated (it's a closed shape), so it hides behind a <clipPath> rectangle whose width tweens in lockstep with the line — the wash appears to be poured in behind the stroke.
Month points are joined with a gentle Catmull-Rom → cubic Bézier conversion (tension ÷ 6). It curves without overshooting — important, because an interpolation that swings above a real data point is a small lie.
Each segment is a full <circle> whose stroke-dasharray is [segmentLength, circumference − segmentLength], rotated −90° so twelve o'clock is zero. A 2.5px slice of each arc is surrendered to the background — segments are separated by the surface showing through, never by drawn borders. The reveal animates each dasharray from 0 C to its final value, staggered clockwise.
const segLen = frac * circumference - GAP;
el("circle", {
"stroke-dasharray": `${segLen} ${circumference - segLen}`,
"stroke-dashoffset": -(accumulated * circumference + GAP/2),
transform: `rotate(-90 ${cx} ${cy})`
});
GSAP tweens a plain object, and an onUpdate formats it — power3.out gives the "rush then settle" feel, and the APY counter keeps two live decimals the whole way (4.60% arrives as 4.60, not 5):
gsap.to(state, { v: 4.6, duration: 1.8, ease: "power3.out",
onUpdate: () => el.textContent = state.v.toFixed(2) });
scaleY from the baseline.H/V commands (deposits are discrete events; a smooth curve would misreport them), drawn on with the same dash technique.<table> twin behind "View the data" (the WCAG-clean equivalent).prefers-reduced-motion (or ?static in the URL) renders every chart in its finished state with no animation.The device is three nested divs (body → screen → notch) with real app UI inside: a tabular-mono balance, an insight card on the emerald-ink gradient, and a transaction list whose category chips reuse the four categorical slot colors — a small colored dot plus a text label, so the chip system matches the donut exactly. Costs a few KB, stays razor-sharp on every display, and can be art-directed in CSS forever.
One AI-generated asset is used — the dark topographic texture behind the security strip and download CTA (assets/texture-emerald.jpg, compressed to ~250KB), generated via Higgsfield (Nano Banana Pro) from an art-directed brief specifying the palette, line quality, composition and mood, iterated until it matched the brand's precision-engraved register. The full brief is proprietary — available to clients on request.
Everything else on the page — every chart, the phone, the icons, the logo — is code.
power3.out.translateX(-130%) → 130%) that idles most of its 5s loop — confident, not busy.index.html, guide/, assets/. No build step.meridian-finance folder. Done.npm i -g netlify-cli, then from the folder run netlify deploy --prod --dir .