← Back to library

Fintech

Meridian

On-chain banking styled as an old ledger — editorial serif, tabular figures and a self-drawing SVG chart.

2 tools★ 91
#editorial#ledger#light
ClaudeLovable

The prompt

Prompt
# Meridian — On-Chain Banking as an Editorial Ledger

## What you're building

An editorial landing page for **Meridian**, a fictional on-chain account product presented as a printed prospectus rather than a fintech dashboard: warm off-white paper, a serif display face, mono tabular figures, and hairline rules that wipe in from the left as you scroll. The distinctive technique is a hand-built SVG chart — a cardinal-spline curve with a deterministic waver, hatched under-fill, and a `pathLength={1}` `stroke-dashoffset` draw-in that inks itself when the figure enters the viewport.

Meridian is an invented brand. Every rate, count and chart value is placeholder sample data, and the page says so seven times — see the honesty clause under Motion & accessibility.

## Art direction

Mood: a well-set annual report on good paper — warm neutrals, one deep navy, generous rules, no gradients and no shadows anywhere.

| Token | Hex | Use |
|---|---|---|
| `--paper` | `#f7f5f1` | Page ground, type on the navy band, chart node fill |
| `--paper-2` | `#f1eee8` | Chart frame fill (bottom of its gradient) |
| `--paper-3` | `#ebe7df` | Deepest paper tone |
| `--ink` | `#14171b` | Headings, chart values, primary type, the one thick rule |
| `--ink-2` | `#3d444c` | Body copy, standfirst, step bodies, nav links, FAQ answers |
| `--ink-3` | `#6d747c` | Cell keys, captions, footer meta, axis labels, the `<small>` unit |
| `--rule` | `#d7d2c8` | Standard hairlines, cell dividers, FAQ rules, chart frame border |
| `--rule-2` | `#c4beb1` | Wipe-in rules, chart gridlines |
| `--accent` | `#173a5e` | Navy: emphasis italic, buttons, step numbers, closing band, pull-quote rule, chart line and hatch |
| `--accent-soft` | `rgba(23,58,94,.09)` | Ghost-button hover |
| Fine print | `#8b9198` | Footer disclaimer only |

Three families, each with one job, declared as custom properties on the root:

- **Serif** (`Georgia, "Iowan Old Style", "Times New Roman", Times, serif`) — display only: H1, H2, standfirst, chart title, step headings, FAQ questions, pull quote, closing heading, wordmark. Always weight **400**; never bold a serif here.
- **Sans** (`ui-sans-serif, "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif`) — body, ledger descriptions, step bodies, FAQ answers, footer.
- **Mono** (`ui-monospace, "SFMono-Regular", "Cascadia Mono", Menlo, Consolas, monospace`) — every slug, key, caption, step number and **every numeral**.

Sizes:

- **H1** — serif 400, `clamp(40px, 7.4vw, 82px)`, `line-height: 1.03`, `letter-spacing: -.024em`, `max-width: 15ch`, `margin: 22px 0 0`. The emphasised word is a real `<em>` — genuine italic, coloured `--accent`.
- **H2** — serif 400, `clamp(28px, 4vw, 44px)`, `line-height: 1.1`, `letter-spacing: -.02em`, `max-width: 20ch`, `margin: 20px 0 0`. The closing band's H2 is its own size: `clamp(30px, 4.6vw, 52px)`, `line-height: 1.08`, `letter-spacing: -.022em`, `max-width: 17ch`.
- **Standfirst** — serif, `clamp(17px, 2vw, 21px)`, `line-height: 1.58`, `--ink-2`, `max-width: 54ch`, `margin: 24px 0 0`.
- **Lede** — sans `15.5px`, `line-height: 1.72`, `--ink-2`, `max-width: 58ch`, `margin: 18px 0 0`.
- **Slug** — mono `11px`, `letter-spacing: .24em`, uppercase, `--accent`.
- **Ledger key** — mono `10.5px`, `.2em`, uppercase, `--ink-3`. **Ledger value** — `clamp(34px, 4.6vw, 50px)`, `line-height: 1`, mono/tabular, `margin: 16px 0 0`; its unit is a `<small>` at `0.44em` in `--ink-3` with `margin-left: 6px` and `letter-spacing: .04em`. **Ledger description** — sans `13.5px / 1.6`, `--ink-2`, `max-width: 32ch`.
- **Buttons** — `13.5px`, weight 600, `letter-spacing: .07em`, uppercase, height 44px, `padding: 0 22px`, radius **2px**, `1px solid --accent`. The nav's ghost button overrides to `height: 36px; padding: 0 16px`.
- **Steps** — number `12px` mono/tabular `.14em` navy with `padding-top: 5px`; heading serif `21px`, `letter-spacing: -.012em`; body sans `14.5px / 1.68`, `--ink-2`.
- **Pull quote** — serif `clamp(19px, 2.4vw, 25px)`, `line-height: 1.42`, `--ink`, `border-left: 2px solid --accent`, `padding: 6px 0 6px 22px`; its `cite` is `display: block`, mono `10.5px`, `.18em`, uppercase, non-italic, `--ink-3`, `margin-top: 14px`.
- **FAQ** — summary serif `clamp(17px, 2vw, 20px)`, `line-height: 1.4`, `padding: 22px 40px 22px 0`; answer sans `14.5px / 1.72`, `max-width: 68ch`, `margin: 0 0 24px`, `padding-right: 40px`.
- **Footer** — line and links `12.5px`; disclaimer `11.5px / 1.65`, `max-width: 74ch`, `#8b9198`.

**Non-negotiable: `font-variant-numeric: tabular-nums` on every figure on the page.** Proportional figures in a ledger layout make columns wobble and destroy the entire conceit. The rule to follow is mechanical: **every selector that sets the mono family also sets `font-variant-numeric: tabular-nums`.** There are five, and all five carry digits somewhere on this page — the `.num` utility on ledger values (which also sets `letter-spacing: -.01em`), the ledger cell key (`Line 01 — settled`), the step counter's `::before`, the chart's axis `<text>` (set inline, since SVG text does not inherit it from a CSS class reliably across the stack), and the chart figcaption (`Period 01`, `Indexed to 100`, `Period 17`). Miss the figcaption and the caption's three items sit in proportional figures under a chart whose axis is tabular — the one place on the page where the mismatch is side by side.

Radius is `2px` everywhere it exists at all. No `box-shadow` on any element.

**Copy against the type scale.** The H1's `max-width: 15ch` is what makes its break stable: 15ch and the font size scale together, so the measure is always 7.5em wide and the headline breaks in the same place at every width the page supports.

`A bank account that keeps its own books.` sets to **three lines** — `A bank account` / `that keeps its own` / `books.` — at 1440px, 1080px, 768px and 375px alike, with the italic navy `books` alone on the last line. At 1440px the clamp caps at 82px (`7.4vw` = 106.6px, so the cap binds from 1109px up) and the measure is ≈615px inside a 1032px column; at 375px the clamp floor of 40px binds and the measure is ≈300px inside a 339px column. Do not insert a `<br>`: the break is doing its own work and an authored one would fight the measure at intermediate widths.

The standfirst is capped at `54ch` (≈567px at 1440px) and sets to four lines there. `Four conventions, borrowed from paper` is capped by its column rather than by `20ch` in the explainer's left track — at 900px the column is 353px against a 360px `20ch`, so it wraps at about 19ch and takes two lines. The closing H2 at `17ch` takes two lines. Every other H2 fits one line from 900px up.

## Copy

Every string the page renders, verbatim. Meridian and every rate, count, period and quotation are invented — they stay exactly as written, no `{{placeholder}}` substitution. The `—` are real em dashes (U+2014), the `·` real middle dots (U+00B7), and the pull quote's marks are real curly quotes (U+201C / U+201D).

### Nav

| Slot | String |
|---|---|
| Wordmark | `Meridian` + `<span>.</span>` — the trailing period is its own span, coloured `--accent` |
| `<nav>` accessible name | `Primary` (via `aria-label="Primary"`) |
| Nav link 1 | `The ledger` |
| Nav link 2 | `Record` |
| Nav link 3 | `How it works` |
| Nav link 4 | `Questions` |
| Ghost CTA | `Open an account` |

### Hero

| Slot | String |
|---|---|
| Slug | `Prospectus — demonstration edition` |
| H1 | `A bank account that keeps its own ` + `<em>books</em>` + `.` |
| Standfirst | `Meridian is an on-chain account with the manners of an old ledger: dated entries, plain statements, and a balance anyone holding the reference can verify without asking us.` |
| Primary CTA | `Open an account` |
| Ghost CTA | `Read the record` |
| Note | `Fictional brand. Sample figures throughout — nothing here is an offer or a description of a real service.` |

### Ledger row

| Slot | String |
|---|---|
| Slug | `Balance sheet, abridged` |

Three cells, left to right. The unit is a `<small>` inside the value; cell 2 has none and must omit the element entirely rather than render an empty one.

| | Key | Value | Unit | Description |
|---|---|---|---|---|
| 1 | `Line 01 — settled` | `4.12` | `%` | `Illustrative sample rate on the demonstration ledger. Not an offer, not a forecast.` |
| 2 | `Line 02 — accounts` | `18,402` | — | `Sample account count used to size the layout. Every figure on this page is invented.` |
| 3 | `Line 03 — settlement` | `1.8` | `s` | `Median sample settlement time across the fictional test network.` |

### The record

| Slot | String |
|---|---|
| Slug | `The record` |
| H2 | `Drawn line by line, the way a clerk would` |
| Lede | `The series below is a fixed sample array held in this page — it exists to demonstrate a hand-drawn SVG curve that inks itself in as you scroll. It measures nothing and predicts nothing.` |
| Chart title | `Ledger balance, indexed` |
| Chart tag | `Sample data · illustrative only` |
| SVG `aria-label` | `Illustrative line chart of a sample indexed ledger balance rising across seventeen sample periods.` |
| Axis labels, bottom to top | `0` · `25` · `50` · `75` · `100` |
| Figcaption, left | `Period 01` |
| Figcaption, centre | `Indexed to 100 · invented values` |
| Figcaption, right | `Period 17` |

### Explainer

| Slot | String |
|---|---|
| Slug | `How it works` |
| H2 | `Four conventions, borrowed from paper` |
| Pull quote | `“The double-entry ledger survived six centuries because it is legible. We saw no reason to replace the part that worked.”` |
| Cite | `House note — fictional` |

Four steps. The numerals `01`–`04` are **not** in the markup — they come from `counter(mstep, decimal-leading-zero)`.

| | Heading | Body |
|---|---|---|
| 1 | `An account, not a wallet` | `You get a named account with statements, references and a running balance — the ledger conventions people already read fluently.` |
| 2 | `Every entry is verifiable` | `Each line carries a reference you can check against the public record yourself. Nothing is asserted that cannot be recomputed.` |
| 3 | `Withdrawals are unilateral` | `The exit path does not require our cooperation. If we disappeared tomorrow, the account would still open.` |
| 4 | `Statements in plain type` | `Monthly statements arrive as ordinary documents. No dashboard required to understand what happened.` |

Four steps, matching the H2's count.

### FAQ

| Slot | String |
|---|---|
| Slug | `Questions` |
| H2 | `Asked plainly, answered plainly` |

Five `<details>`, in order. The **first is `open`**; the other four are closed.

| | Summary | Answer |
|---|---|---|
| 1 | `Is Meridian a real financial institution?` | `No. Meridian is an invented brand created for a front-end design demonstration. It does not offer accounts, hold funds, or provide any financial service, and none of the figures on this page describe a real product.` |
| 2 | `Where do the numbers on this page come from?` | `They are placeholder sample values chosen to exercise the typography — specifically the tabular figures in the ledger row and the chart. They are not measurements of anything and should not be read as performance.` |
| 3 | `What is the chart showing?` | `An illustrative series drawn from a fixed sample array in the page source. It animates on scroll to demonstrate an SVG draw-in technique, and it is not a projection of any kind.` |
| 4 | `Does the page work without JavaScript?` | `The content, the hairline rules and this FAQ do. The FAQ uses native disclosure elements, so it opens and closes with no script at all, and it remains keyboard operable.` |
| 5 | `What happens with reduced motion enabled?` | `All entrance and continuous animation is switched off in both CSS and script. The chart renders fully drawn, rules render at full width, and sections render at rest.` |

### Closing band and footer

| Slot | String |
|---|---|
| H2 | `Open the book on your own money.` |
| Paragraph | `A demonstration page for a fictional account product. No sign-up exists behind these buttons — they are here to show the composition.` |
| Primary CTA | `Open an account` |
| Ghost CTA | `Read the questions` |
| Footer line | `Meridian — an invented brand, built for a design demonstration.` |
| Footer `<nav>` accessible name | `Footer` (via `aria-label="Footer"`) |
| Footer links | `The ledger` · `Record` · `How it works` · `Questions` |
| Fine print | `Nothing on this page is financial advice, an offer, a solicitation, or a description of a real product or institution. All names, rates, counts and chart values are placeholder sample data written for layout purposes.` |

### Anchors

Every link is a same-page anchor; there is no external navigation. `#top` (wordmark, closing primary CTA), `#ledger`, `#record` (nav, hero ghost CTA, footer), `#how`, `#questions` (nav, closing ghost CTA, footer), `#open` (nav CTA, hero primary CTA, and the id on the closing band).

## Assets and illustrations

**No raster images, no video, no webfont, no icon font, no external requests, and no `box-shadow` anywhere.** There is one illustration on the page — the chart — plus two drawn marks. Everything else is type and rules. Do not substitute a charting library's output, a stock photograph or a logo file; the whole conceit is that a printed prospectus needs nothing but type, rules and one engraved figure.

1. **The chart** — one inline `<svg>`, `viewBox="0 0 960 300"`, `role="img"` with the `aria-label` from Copy, `display: block; width: 100%; height: auto`. It depicts a single navy curve rising left to right across seventeen sample periods, stumbling downward five times on the way; the region under the curve is filled with 35°-rotated engraved hatching; five gridlines run behind it with numeric labels down the left; five paper-filled circles sit on the curve at every fourth point. Geometry, construction and animation are specified in full under The technique. Every internal `<g>`, `<path>`, `<line>`, `<text>` and `<circle>` is `aria-hidden="true"` so assistive tech reads one description rather than eighty coordinates.
2. **The wordmark period** — the character `.` in its own `<span>`, coloured `--accent`, after `Meridian` in the nav. Not a glyph, not a dot element; a real full stop, so it inherits the serif and the tracking.
3. **The FAQ disclosure mark** — 13 × 13, built from two CSS gradient bars, no SVG. Construction under The technique.

## Layout

Content column `max-width: 1120px`, `padding-inline: clamp(18px, 5vw, 44px)` — 1032px of content from 1120px up (the padding reaches its 44px cap at 880px, the wrap reaches its 1120px cap at 1120px). Sections are `padding: clamp(48px, 7vw, 88px) 0`, with every section after the hero overriding `padding-top: 0`. The page is the longest in this set, running to roughly four viewport heights on a 1080px-tall screen — a sanity figure, not a target; do not pad whitespace to reach it.

1. **Nav** — sticky, `top: 0`, `z-index: 30`, height 60px, `background: rgba(247,245,241,.9)` + `backdrop-filter: blur(10px)` (ship the `-webkit-` prefix first), `border-bottom: 1px solid --rule`. The row is `display: flex; align-items: baseline; gap: 26px` — baseline, not centre, so the serif wordmark and the sans links sit on one ruled line. Wordmark takes `margin-right: auto`. The four links are `display: none` below 840px and `display: flex` with `gap: 26px` above it; each gains a navy `border-bottom` on hover, with a `1px solid transparent` bottom border and `padding-bottom: 2px` reserved at rest so nothing shifts.
2. **Hero** — `padding: clamp(52px,9vw,108px) 0 clamp(40px,6vw,72px)`. Slug → H1 → standfirst → two CTAs (`gap: 12px`, `margin-top: 32px`) → the note.
3. **Ledger row** — opens on the **2px `--ink`** rule, the only thick rule on the page, then a slug block at `padding-top: 26px`, then the three cells, then a standard hairline. Cells are `display: grid` with `repeat(3,1fr)` at ≥760px, `margin-top: 30px`. At ≥760px each cell is `padding: 30px 30px 32px` with a `1px solid --rule` **left** border; the first cell drops its border and its left padding, the last drops its right padding. Below 760px they stack, the left border becomes a top border, and the padding becomes `26px 0 28px`.
4. **The record** — slug → H2 → lede → the chart figure. This section opens on **no rule of its own**: it follows the ledger's closing hairline directly.
5. **Chart figure** — `<figure>`, `margin-top: 34px`, `1px solid --rule` border on `linear-gradient(180deg,#fbfaf7,var(--paper-2))`, `padding: clamp(18px,3vw,30px)`. A head row (serif title left, mono tag right) at `display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: baseline; justify-content: space-between`, `padding-bottom: 16px`, `border-bottom: 1px solid --rule`; the SVG at `margin-top: 18px`; a `figcaption` of three mono items at `margin-top: 14px`, `display: flex; flex-wrap: wrap; gap: 6px 22px; justify-content: space-between`.
6. **Explainer** — hairline → a two-column grid at `padding-top: 40px`, `gap: clamp(30px,5vw,62px)`, `grid-template-columns: 0.92fr 1.08fr` at ≥900px and one column below. Left: slug, H2, pull quote (`margin: 26px 0 0`). Right: an `<ol>` with `counter-reset: mstep`, each `<li>` a `46px 1fr` sub-grid at `gap: 4px 18px`, `padding: 24px 0`, `border-top: 1px solid --rule` with the first row's border and top padding removed. The `::before` counter lands in column 1 and the `<h3>` auto-places into column 2; the `<p>` is forced to `grid-column: 2` so the body aligns under the heading, not under the number.
7. **FAQ** — hairline → slug and H2 at `padding-top: 40px` → the list at `margin-top: 30px` with `border-top: 1px solid --rule-2`, each `<details>` carrying `border-bottom: 1px solid --rule`.
8. **Closing band** — full-bleed `--accent` with `--paper` type, `margin-top: clamp(40px,6vw,70px)`, inner `padding: clamp(48px,8vw,96px) 0`. Serif H2, paragraph in `rgba(247,245,241,.82)` at `max-width: 52ch`, two CTAs with their fills inverted: the solid becomes `--paper` on `--accent` type (hover `#ffffff`), the ghost becomes paper type on transparent (hover `rgba(247,245,241,.12)`), and both borders become `--paper`.
9. **Footer** — `border-top: 1px solid --rule`, `padding: 40px 0 56px`: the brand line and a four-link `<nav>` on one `align-items: baseline` row at `justify-content: space-between`, `gap: 14px 30px`, plus the disclaimer below at `margin-top: 22px`.

**Column arithmetic at the four widths that matter.** Every track here is fractional against one column, so no fixed-width object can outgrow its neighbour:

| Viewport | Padding | Content column | Explainer gap | Ledger cell | Explainer left / right | Step text column |
|---|---|---|---|---|---|---|
| 1440px | 44px | 1032px | 62px | **344px** | 446.2 / 523.8px | 459.8px |
| 1280px | 44px | 1032px | 62px | **344px** | 446.2 / 523.8px | 459.8px |
| 1080px | 44px | 992px | 54px | **330.7px** | 431.5 / 506.5px | 442.5px |
| 900px | 44px | 812px | 45px | **270.7px** | 352.8 / 414.2px | 350.2px |
| 768px | 38.4px | 691.2px | 38.4px | one column | one column | 627.2px |

The explainer gap is `clamp(30px, 5vw, 62px)`, so it hits its cap at 1240px and tracks the viewport below that; the step text column is the right track minus the 46px counter and the 18px sub-grid gap.

The ledger's tightest case is 900px, where a middle cell is 270.7px with 30px of padding each side, leaving 210.7px of text against a `32ch` description cap of ≈216px — so the description wraps to the cell rather than overflowing it, because `32ch` is a `max-width` and not a `width`. The largest thing in a cell is `18,402` at `4.6vw` = 41.4px in mono, which sets to ≈149px and clears the 210.7px inner width with room. At 768px the cells stack full-width and the question disappears. Below 760px the ledger is one column; below 900px the explainer is one column; below 840px the nav links are gone.

**There are exactly four self-drawing rules on the page**, all `.mrd-hair` elements: the 2px ink rule opening the ledger, the hairline closing the ledger, the hairline opening the explainer, and the hairline opening the FAQ. Everything else that looks like a rule — the nav's bottom border, the ledger cell dividers, the chart frame border and its head rule, the step row dividers, the FAQ's top and per-item borders, the footer's top border — is an ordinary static `border` and does not animate. Do not convert them; a page where every line crawls in reads as a loading screen.

## The technique

**The series and the frame.** Everything is computed once at module scope — deterministic, so server and client render byte-identical markup and there is no hydration mismatch.

```js
const SERIES = [8,14,11,20,26,22,31,39,35,46,55,51,63,74,71,86,94]; // 17 points
const CW = 960, CH = 300;
const CPAD_L = 46, CPAD_R = 12, CPAD_T = 18, CPAD_B = 30;
```

The 17 values rise but stumble **five** times — `14→11`, `26→22`, `39→35`, `55→51`, `74→71`. A monotonic series reads as a sales chart; a series that dips reads as a record. Count them before you write an acceptance check about them.

**Point placement, with a deterministic waver.**

```js
const innerW = CW - CPAD_L - CPAD_R;   // 902
const innerH = CH - CPAD_T - CPAD_B;   // 252
x = CPAD_L + (i / (SERIES.length - 1)) * innerW;
const waver = Math.sin(i * 2.399) * 2.1 + Math.sin(i * 0.77) * 1.3;
y = CPAD_T + innerH - (v / 100) * innerH + waver;
```

Two incommensurable sine terms (periods 2.399 and 0.77) sum to a wobble that never repeats across 17 samples. Its theoretical bound is ±3.4px and its **realised range over `i = 0…16` is −3.19px (at `i = 15`) to +3.06px (at `i = 11`)** — enough that the curve reads as inked by hand rather than plotted by a machine, small enough that it never contradicts the data. `Math.sin` and not `Math.random`: randomness would break SSR determinism and change on every render. Because `y` is measured downward in SVG, a positive waver pushes a point *down*; that is why `i = 15`'s −3.19 lifts the second-to-last node slightly and reads as a nib skipping.

**Smoothing.** A cardinal spline written as cubic Béziers, tension `1/6.4`. The `M` command is part of the path and is easy to lose when transcribing the loop:

```js
let d = `M ${pts[0].x.toFixed(2)} ${pts[0].y.toFixed(2)}`;
for (let i = 0; i < pts.length - 1; i++) {
  const p0 = pts[i-1] ?? pts[i], p1 = pts[i], p2 = pts[i+1], p3 = pts[i+2] ?? p2;
  const c1x = p1.x + (p2.x - p0.x) / 6.4;
  const c1y = p1.y + (p2.y - p0.y) / 6.4;
  const c2x = p2.x - (p3.x - p1.x) / 6.4;
  const c2y = p2.y - (p3.y - p1.y) / 6.4;
  d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
}
```

Each control point is derived from the *neighbours* of the segment ends, which is what keeps the joins C¹-continuous. `pts[-1]` is `undefined` in JavaScript, so `?? pts[i]` and `?? p2` clamp the endpoints and stop the first and last segments flying off. `6.4` is deliberately loose — at `3` the curve overshoots into visible loops on the five dips. Coordinates are `toFixed(2)`; more precision only bloats the path string. The result is 16 `C` segments after the `M`.

**The draw-in.** The line path carries `pathLength={1}`, which normalises its length to 1 regardless of its real geometry — so the dash values are literals and never need `getTotalLength()`:

```css
.mrd-line { stroke-dasharray: 1; stroke-dashoffset: 1; }
.mrd-chart-frame.mrd-in .mrd-line { animation: mrd-draw 2.2s cubic-bezier(0.4, 0, 0.18, 1) forwards; }
@keyframes mrd-draw { to { stroke-dashoffset: 0; } }
```

The line itself is `fill: none`, `stroke: #173a5e`, `stroke-width: 2.4`, round caps and joins. `cubic-bezier(0.4, 0, 0.18, 1)` starts slowly and finishes long — a nib accelerating out of the first stroke and then trailing. `forwards` is required or the line snaps back to hidden at the end.

**That easing is not linear, and the node timings depend on knowing where the tip is.** Under `cubic-bezier(0.4, 0, 0.18, 1)` over 2.2s, the drawing tip reaches these fractions of the path at these times:

| Path fraction | 0 | 0.25 | 0.50 | 0.75 | 1.00 |
|---|---|---|---|---|---|
| Time | 0s | 0.55s | 0.75s | 1.04s | 2.20s |

Three quarters of the curve is inked in the first half-second; the last quarter takes over a second on its own. Any timing keyed to "roughly linear" will run ahead of the tip.

**Under-fill and nodes.** The area path reuses the line's `d` and closes it to the baseline:

```js
AREA_D = `${LINE_D} L ${last.x} ${CH - CPAD_B} L ${first.x} ${CH - CPAD_B} Z`;
```

It is filled with an SVG `<pattern>`, not a gradient — `id="mrd-hatch"`, `width`/`height` `7`, `patternUnits="userSpaceOnUse"`, `patternTransform="rotate(35)"`, containing one `<line x1="0" y1="0" x2="0" y2="7" stroke="#173a5e" strokeWidth="1" strokeOpacity="0.17" />`. Engraved hatching, the way a printed report would shade. It starts at `opacity: 0` and fades in at `1.4s ease` with a `0.6s` delay, landing at 2.0s — while the line is still drawing its last stretch.

Nodes are every 4th point plus the last (`i % 4 === 0 || i === PTS.length - 1`). With 17 points that is `i = 0, 4, 8, 12, 16`, and 16 satisfies both clauses, so `filter` yields it once: **exactly five nodes**, at path fractions 0, 0.25, 0.50, 0.75 and 1.00. Each is `r="3.4"`, `fill="#f7f5f1"`, `stroke="#173a5e"`, `strokeWidth="1.8"` — paper-filled so the curve appears to pass behind them — starting at `opacity: 0` and fading in over `0.6s ease`.

**Their delays are read off the tip table above, not from an arithmetic stagger**, so each node lands just after the line reaches it:

| Node | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| `animation-delay` | `0.15s` | `0.65s` | `0.90s` | `1.20s` | `2.30s` |

Every value is the tip's arrival time plus about 0.1s. A uniform stagger such as `1.1 + i × 0.16` puts the last node on screen at 1.74s, when the tip is still a fifth of the way from it — the node arrives before the line does, which is the opposite of the intended reading. The long wait before node 5 is correct: it is the same wait the tip takes.

**Gridlines.** Five at `[0,25,50,75,100]`, positioned `y = CPAD_T + (CH - CPAD_T - CPAD_B) * (1 - g/100)`, running `x1={CPAD_L}` to `x2={CW - CPAD_R}`, `stroke="#c4beb1"`, `strokeWidth="1"`. The baseline (`g === 0`) is solid at `strokeOpacity 0.95`; the other four are `strokeDasharray="2 5"` at `0.45`. Axis labels sit at `x = CPAD_L - 12`, `y = y + 4`, `textAnchor="end"`, `fontSize="12"`, `fill="#6d747c"`, mono family, with `fontVariantNumeric: "tabular-nums"` set inline. Gridlines and labels precede the area and line in document order, so the curve paints over them.

**The axis labels have a floor, and it is 900px.** SVG text inside a `viewBox` scales with the box, so a `fontSize` of 12 renders at `12 × (columnWidth / 960)` CSS pixels:

| Viewport | Column | Rendered label |
|---|---|---|
| 1440px | 1032px | 12.9px |
| 1080px | 992px | 12.4px |
| 900px | 812px | 10.2px |
| 768px | 691px | 8.6px |
| 375px | 339px | **4.2px** |

Below 900px they are unreadable, and raising `fontSize` cannot fix it — `100` at any size large enough to read would run left past `x = 34` and clip outside the 46-unit left pad. So **hide the labels below 900px** with one rule on their class and leave the gridlines in place; the figcaption already carries `Indexed to 100 · invented values`, which is the information the labels were giving. Do not instead widen the `viewBox` or add a horizontal scroller — the chart scaling cleanly is the reason it is SVG and not canvas.

**Hairline wipe-ins.** The four structural rules are not `border-top` — they are 1px elements that draw themselves:

```css
.mrd-hair { height: 1px; background: var(--rule-2);
            transform: scaleX(0); transform-origin: left center;
            transition: transform 0.95s cubic-bezier(0.22, 0.7, 0.24, 1); }
.mrd-hair.mrd-in { transform: scaleX(1); }
.mrd-hair-thick { height: 2px; background: var(--ink); }
```

`transform-origin: left center` so every rule is drawn left to right like a ruled line, and `0.95s` — slower than the 0.75s content reveal — so the rule is still travelling as the copy under it settles. `transform` only: no layout, no paint, one composited property. The thick ink rule is a `.mrd-hair` too and wipes in with the rest; it differs only in height and colour.

**The FAQ disclosure mark.** The summary's native marker is suppressed with `list-style: none` plus `::-webkit-details-marker { display: none }`, and replaced by two 13 × 13 pseudo-elements, both at `position: absolute; right: 6px; top: 50%; margin-top: -7px; width: 13px; height: 13px`:

- `::after` — the **horizontal** bar, static: `background: linear-gradient(var(--accent), var(--accent)) center / 13px 1px no-repeat`.
- `::before` — the **vertical** bar: `background: linear-gradient(var(--accent), var(--accent)) center / 1px 13px no-repeat`, `transition: transform 0.25s ease`.
- `details[open] summary::before { transform: rotate(90deg); }`

Closed, the two bars cross into a `+`. Open, the vertical bar spins a quarter turn onto the horizontal one and the mark reads as a `−`. **Do not build this as one pseudo-element that drops the vertical bar and rotates the horizontal one 90°** — that is the obvious compression of the same idea and it ends on a single *vertical* stroke, which reads as a stray tick rather than a minus. Two elements, one rotating.

## Motion & accessibility

- **Timings** — chart line `2.2s cubic-bezier(0.4, 0, 0.18, 1) forwards`; area `1.4s ease 0.6s forwards`; nodes `0.6s ease forwards` at the five delays tabulated above; hairlines `0.95s cubic-bezier(0.22,0.7,0.24,1)`; content reveal `0.75s` on the same curve from `opacity: 0; transform: translateY(20px)` to `opacity: 1; transform: none`; FAQ marker rotate `0.25s ease`; buttons and nav links `0.2s ease` on background and colour.
- **One observer, three classes.** A single `IntersectionObserver` watches every `.mrd-rise`, `.mrd-hair` and `.mrd-chart-frame` in the root and adds `.mrd-in` to each on first intersection, then `io.unobserve`s that element; `io.disconnect()` on unmount. Use `{ rootMargin: "0px 0px -10% 0px", threshold: 0.15 }` — a root with real height — and handle each entry independently inside the callback. Nothing here depends on the order entries arrive in, which matters because entry order is not sorted by position: a fast scroll delivers several entries in one callback, and resolving "which one is furthest down" from the array order would fire the wrong element's reveal. The chart's trigger is the same one: `.mrd-chart-frame` is in the observed set, so the draw-in starts when the *figure* enters the viewport, not on mount.
- **`prefers-reduced-motion: reduce` resolves to exactly one behaviour.** The effect checks the query first and adds `.mrd-in` to every target synchronously without constructing an observer; the same branch covers `typeof IntersectionObserver === "undefined"`. CSS then forces the end state so nothing depends on script order: `.mrd-rise { opacity: 1; transform: none; transition: none }`, `.mrd-hair { transform: scaleX(1); transition: none }`, `.mrd-line { stroke-dashoffset: 0; animation: none }`, `.mrd-area, .mrd-node { opacity: 1; animation: none }`, `.mrd-faq summary::before { transition: none }`. All with `!important`, because they are overriding animation fill values. The chart therefore renders **fully drawn** on arrival, hatched, with all five nodes — not blank, not half-inked.
- **Honour a mid-session change.** Put the whole effect body in a function, call it once, and subscribe to the media query's `change` event: on change, run the existing cleanup (`io.disconnect()`) and re-run the function. The `useEffect` has an empty dependency array and will not re-run on its own, so a visitor who flips the OS setting mid-session is otherwise stranded in whichever mode the page loaded in. Remove the `change` listener on unmount.
- **Performance: what is static and what is live.** All chart geometry — `PTS`, `LINE_D`, `AREA_D`, `NODES`, the gridline positions — is computed once at module scope, outside the component, and never recomputes on render, on resize or on scroll. There is no `requestAnimationFrame` on this page, no scroll listener and no `useState`. **No resize handling is needed anywhere**: the SVG scales by `viewBox` and every other dimension is a `clamp()` or a fraction. That is the main reason to build the chart in SVG rather than canvas.

  The one thing here that is *not* free is the draw-in: `stroke-dashoffset` is not a compositor-animatable property, so the browser repaints the path on every frame of those 2.2 seconds. The budget it has to fit is one 16-segment path, 2.4px wide, inside a box that is at most 1032 × 322 CSS pixels — a few hundred microseconds a frame, and it happens exactly once per page load. The hairlines and reveals, by contrast, animate `transform` and `opacity` only and stay off the main thread. Do not add a second `stroke-dashoffset` animation (on the gridlines, say) on the same tick.
- **Contrast on paper** — `--ink` 14.6:1, `--ink-2` 9.0:1, `--ink-3` 4.8:1 (use it only at the `10.5–13.5px` labels, captions and cell keys where it is specified, never for reading copy). On the navy closing band, `--paper` on `--accent` clears 4.5:1 and the `.82` alpha paragraph is the floor — do not drop it further. The chart's navy line and hatch sit on paper and are decoration, not text.
- **Accessibility** — headings run H1 → H2 → H3 with no skips. The FAQ is native `<details>`/`<summary>`, so it opens, closes and is keyboard operable with **zero JavaScript**; the page's content, rules and disclosure all work with JS disabled, because the only thing script does is add a class that CSS uses to animate an end state the elements would otherwise hold anyway — with one exception noted below. `<nav aria-label="Primary">` and `<nav aria-label="Footer">`. `:focus-visible` gives `outline: 2px solid #173a5e; outline-offset: 3px; border-radius: 2px` on links, buttons **and summaries** — summaries are the one focus target authors routinely forget, and this page has five of them.

  The exception: `.mrd-rise` and `.mrd-hair` sit at `opacity: 0` and `scaleX(0)` in the base stylesheet, so with JavaScript disabled *and* reduced motion off, those elements never reveal. If a fully script-free render matters more than the reveal, invert the defaults — ship the end state and have the script add a `.mrd-armed` class that installs the start state before observing. Ship one or the other and say which; do not claim both.
- **Honesty is part of the design.** Because the page renders financial-looking figures, every number sits within one line of sight of text calling it sample data. There are seven such disclosures and all of them stay: the hero note, all three ledger cell descriptions, the record lede, the chart tag, the figcaption's centre item, FAQ answers 1, 2 and 3, and the footer fine print. If you cut copy, cut something else.

## Build notes

- React + Tailwind, **no external packages** — no charting library, no animation library. The chart is about 40 lines of module-scope maths plus inline SVG; the draw-in is `pathLength` and two CSS rules.
- All CSS lives in one in-component `<style>` block, every selector prefixed `mrd-`. The root sets `overflow-x: clip`, `position: relative`, and `box-sizing: border-box` on itself and all descendants including pseudo-elements.
- One `useEffect`, one observer, one `matchMedia` `change` subscription. No `useState`, no rAF, no resize or scroll listener.
- Mobile: nav links hide below 840px; the ledger stacks to one column below 760px with dividers becoming top borders; the explainer grid stacks below 900px; the chart's axis labels hide below 900px. The chart never needs a horizontal scroller because it scales.
- Give the hatch `<pattern>` and every other SVG `id` the `mrd-` prefix too. There is exactly one `<pattern>` on the page and it is referenced once, as `fill="url(#mrd-hatch)"`.

## Acceptance checks

- Scrolling the chart into view inks the curve left to right over 2.2s. The hatched fill starts at 0.6s and completes at 2.0s, while the line is still drawing. Each of the five nodes appears **after** the tip has passed it — step through at 0.15s, 0.65s, 0.90s, 1.20s and 2.30s and confirm the node is behind the advancing end of the stroke, never ahead of it.
- The curve is smooth through all 17 points with no cusps and no overshoot loops at any of the **five** dips (`14→11`, `26→22`, `39→35`, `55→51`, `74→71`). The path string is one `M` followed by exactly 16 `C` segments.
- Exactly five nodes, at periods 1, 5, 9, 13 and 17, each paper-filled so the curve passes visually behind it.
- Every numeral on the page — the three ledger values, the five axis labels, the four step counters, the two `Line 0n` keys, `Period 01`, `Indexed to 100`, `Period 17` — is monospaced and tabular. Swap any digit for a `1` and no neighbouring glyph moves. The figcaption is the item most often missed.
- Exactly four rules sweep in from the left over ~0.95s: the 2px ink rule and the hairline bracketing the ledger, and the hairlines opening the explainer and the FAQ. Every other line on the page is a static border and does not move.
- The H1 sets to three lines — `A bank account` / `that keeps its own` / `books.` — at 1440px, 1080px, 768px and 375px, with the italic navy word alone on the last line and no `<br>` in the markup.
- Above 900px the chart's axis labels render at 10px or larger; below 900px they are absent and the gridlines remain. At no width does the chart introduce a horizontal scroller.
- Open and close a FAQ item: the mark is a `+` when closed and a `−` when open, with a quarter-turn between them. If the open state shows a single vertical stroke, the marker was built as one rotating bar instead of two.
- With JavaScript disabled: all copy renders, the FAQ opens and closes, and the page is fully navigable by keyboard.
- With reduced motion on: the chart is fully drawn and hatched on arrival with all five nodes visible, all four rules are at full width, and nothing is stuck at `opacity: 0`. Toggling the OS setting mid-session flips the page to the other mode without a reload.
- Tabbing reaches the wordmark, the four nav links, the nav CTA, both hero CTAs, all five FAQ summaries, both closing CTAs and the four footer links, each with a visible navy ring at 3px offset — summaries included.
- Every figure on the page sits within one line of sight of text calling it sample data; count seven separate disclosures.
- No network request leaves the page for an image, font or script, and there is no `box-shadow` anywhere in the stylesheet.

More like this