Colophon / Build notes

How this
was built.

Kern is a fictional product. The typography isn’t. Everything below is the actual technique used on the page you just scrolled, including the parts that went wrong first.

Type
Roboto Flex (13 axes), Space Mono
Palette
#0B0B0C, #F4F4F2, #FF3B00
Stack
Hand-written HTML, CSS, JS. No build step.
Assets
None. Every mark is inline SVG or a glyph.
01 / Concept

The specimen is the interface.

Kern sells AI copywriting to people who care about words. That audience has seen a thousand chat boxes with a gradient behind them, so the site doesn’t have one. It’s shaped like a foundry type specimen instead: size and leading notation in the gutter, a waterfall plate, an ampersand parade, crop marks, registration marks at every field flip. If you’ve ever downloaded a PDF specimen from a type foundry, the furniture is familiar.

That shape earns its place because of one idea: voice is a set of axis positions, not a preset you pick from a dropdown. A variable font makes that argument visually in a way prose can’t. In the Voice section, dragging the register slider rewrites the sentence and moves the type at the same time, because they’re the same decision. Blunt copy comes out heavy and condensed at five words. Formal copy comes out thin and extended at nineteen. You can see the padding before you read it.

The palette is three values and no more. Black field, white ink, and fire used at roughly one percent: the caret trailing the headline, the live axis numbers, the strikethroughs, one registration mark per seam. There are no shadows anywhere on the site, which is a deliberate position rather than an oversight. This design is descended from print, where depth comes from hairlines, inversion, and scale contrast. A drop shadow under a 291px hairline headline would be a lie about how the thing was made. There’s no grain either, for the same reason: on a page that lives or dies on edge quality, texture is a liability.

02 / Technique

Five things worth stealing.

01 / The kerning problem

Let the browser shape it, then read the glyphs back

Driving font-variation-settings per letter needs one element per letter. Splitting a string into spans also destroys the font’s kern table: each span shapes in isolation, so the browser never sees the pairs. On a site judged purely on typography that’s fatal. Roboto Flex actually shapes "EVERY WORD" 5.95px wider with kerning on than off, because it opens up E V and E R rather than tightening them.

So don’t reimplement kerning. Shape the intact string in a hidden probe, ask a Range where every glyph landed, and place absolutely positioned spans on those exact coordinates. The shaping is the browser’s own, so it’s correct by construction.

const rng = document.createRange();
function shape(text, wdth) {
  probe.style.fontVariationSettings = fvs(TABLE_WGHT, wdth, 0);
  probe.textContent = text;                 // shaped intact: kerning applies
  const box = probe.getBoundingClientRect();
  const tn = probe.firstChild, xs = [];
  for (let i = 0; i < text.length; i++) {
    rng.setStart(tn, i); rng.setEnd(tn, i + 1);
    xs.push((rng.getBoundingClientRect().left - box.left) / 100);
  }
  return { xs, total: box.width / 100 };    // em, at a 100px probe
}
02 / The wave

GRAD is why the headline never jitters

Most variable-font waves animate wght, which changes each glyph’s advance width, which reflows the line every frame. It looks like the letters are fighting. Roboto Flex has a GRAD axis built for exactly this: it changes apparent weight and leaves the advance untouched. Measured across the full −200..150 range, "EVERY WORD" stays at 486.922px to three decimal places.

GRAD alone tops out around an apparent 550, which reads as "slightly firmer" rather than bold, so the wave runs wght and GRAD together for a ~220 to ~950 swing. Positions are sampled once at the midpoint weight and letters are pinned there regardless of current weight, so the line still cannot reflow. The only cost is that tracking breathes a few px as the crest passes, which reads as the wave squeezing the letters.

// per letter, per frame. No layout is read here, ever.
const d = Math.hypot(glyphX - crestX, lineY - crestY);
const infl = Math.exp(-(d * d) / (R * R));        // gaussian falloff
L.gradT = lerp(REST_GRAD, PEAK_GRAD, infl);       // -100 .. 150
L.wghtT = lerp(REST_WGHT, PEAK_WGHT, infl);       //  320 .. 800
L.grad  = lerp(L.grad, L.gradT, 0.14);            // critically damped-ish
L.wght  = lerp(L.wght, L.wghtT, 0.14);
L.el.style.fontVariationSettings =
  `'wght' ${L.wght.toFixed(0)}, 'wdth' ${wdth}, 'opsz' 144, 'GRAD' ${L.grad.toFixed(1)}`;
03 / Optical margins

Fit the ink, not the advance box

A Range reports a glyph’s advance box, sidebearings included. Set a line flush to that and the ink sits indented: the giant E landed 12px to the right of the rail directly above it, which on a specimen reads as a mistake. Canvas exposes real ink bounds, so measure the sidebearings, fit the ink to the measure, and hang the bearings outside it. Both hero lines then start and end on exactly the same optical edge.

function ink(ch) {                       // sidebearings, in em
  inkCtx.font = '100px "Roboto Flex"';
  const m = inkCtx.measureText(ch);
  return {
    l: -m.actualBoundingBoxLeft / 100,
    r: (m.width - m.actualBoundingBoxRight) / 100,
  };
}
ln.lsb = ink(ln.text[0]).l;
const rsb = ink(ln.text[ln.text.length - 1]).r;
ln.fs = measure / (rest.total - ln.lsb - rsb);   // ink spans the measure
// then every glyph is shifted left by lsb when positioned
04 / Scroll without jank

Precompute a width table, then never touch layout

Scrolling drives the whole specimen down the width axis, 100 to 62. Re-measuring every frame would mean two forced layouts per line per frame. Instead the glyph offsets are sampled once across seven width values at build time, and the frame loop lerps between the two bracketing samples. Document-space geometry is cached at layout, so the rAF loop reads nothing and only writes transform and font-variation-settings.

Because font-size is fixed and only the width axis moves, the line can only ever get narrower. Horizontal overflow is impossible by construction rather than by clamping.

measure() {                            // once, at build + on resize
  for (let i = 0; i < N_SAMPLES; i++) {
    const wd = WDTH_MIN + (WDTH_MAX - WDTH_MIN) * i / (N_SAMPLES - 1);
    ln.samples.push(shape(ln.text, wd));
  }
}
sampleAt(ln, wdth) {                   // pure arithmetic, per frame
  const t = clamp((wdth - WDTH_MIN) / (WDTH_MAX - WDTH_MIN), 0, 1) * (N_SAMPLES - 1);
  const i0 = Math.min(Math.floor(t), N_SAMPLES - 2);
  return { a: ln.samples[i0], b: ln.samples[i0 + 1], f: t - i0 };
}
05 / Loading the axes

Google Fonts pins every axis you don’t ask for

This is the one that silently kills a variable-font site. The CSS API v2 serves a partial instance: any axis missing from the URL is pinned at its default and the axis is gone from the file. Request wght only and the latin subset is 34KB; request all thirteen and it’s 326KB. That 10x gap is the axes. Tags must be sorted lowercase-first, then uppercase.

Verify it rather than trusting it. Render text at both extremes and measure: if the widths match, the axis is dead. That probe is also how the GRAD claim above got confirmed.

<!-- opsz,slnt,wdth,wght then GRAD,XOPQ,XTRA,YOPQ,YTAS,YTDE,YTFI,YTLC,YTUC -->
family=Roboto+Flex:opsz,slnt,wdth,wght,GRAD,...@8..144,-10..0,25..151,100..1000,-200..150,...

/* the check that matters */
el.style.fontVariationSettings = "'wdth' 25";  const a = el.getBoundingClientRect().width;
el.style.fontVariationSettings = "'wdth' 151"; const b = el.getBoundingClientRect().width;
console.assert(Math.abs(b - a) > 5, 'wdth axis is not loading');
// measured 237.9 -> 974.3 = live. GRAD: 638.0 -> 638.0 = width-stable, as designed.
03 / Asset pipeline

There are no assets.

Not one image is loaded. There’s no assets/ folder, no generated art, no MCP image step. Everything you see is a glyph, a hairline, or a handful of inline SVG paths, which is the correct answer for a site whose whole subject is type.

  • The ampersand parade is seven & characters at wght 100 through 1000 in fixed grid columns, so scroll can drive the width axis from 38 to 151 without ever reflowing the row.
  • The giant ampersand behind the final CTA is one character at wght 100 / wdth 151, filled with transparent and outlined with -webkit-text-stroke. Cropped by the viewport, it reads as a foil emboss.
  • Crop and registration marks are four-line SVG paths, drawn to real print proportions and set in the section’s own hairline tone, with one fire mark per seam.
  • The favicon is an inline SVG data-URI: a black field, a white K built from a rect and two polygons, and a fire caret bar. No file request.
  • The strikethroughs are animated background-size on a linear-gradient, with box-decoration-break: clone so a cut spanning two lines gets a rule on each. An absolutely positioned ::after only covers the bounding box and collapsed into one stray dash across the gap. That bug is visible in the pass 1 screenshots.
04 / Recreate it

The prompt.

Paste this into Claude. It’s structured Role, Task, Context, Format, Constraints, Examples.

prompt.txt
ROLE
You are an art director and creative developer who sets type for a living.
You have opinions about sidebearings. Act like it.

TASK
Build a single-page marketing site for a fictional AI copywriting tool called
Kern, plus a /guide route documenting how you built it. Hand-written HTML, CSS
and vanilla JS only. No frameworks, no build step, no images.

CONTEXT
The audience is people who care about words: writers, editors, designers. The
site must read as a foundry type specimen, not as a SaaS landing page. The
organising idea is that voice is a set of axis positions rather than a preset,
and the typography must make that argument, not just decorate it.

FORMAT
- index.html, styles.css, main.js, guide/index.html
- Beats: nav, hero specimen, proof strip, axes plate, live voice demo,
  feature grid, playground, product mock, pricing, final CTA, footer
- Specimen furniture throughout: size/leading notation, a waterfall plate,
  an ampersand parade, crop marks, registration marks at field flips

CONSTRAINTS
- Palette: black #0B0B0C, white #F4F4F2, fire #FF3B00. Nothing else. Fire at
  roughly 1% of the page, and only where something is live or interactive.
- Type: Roboto Flex loaded with ALL 13 axis ranges in the Google Fonts URL
  (axes you omit get pinned and silently disappear), plus Space Mono for labels.
- No shadows anywhere. Depth comes from hairlines, inversion and scale.
- Copy: contractions, no em-dashes, no marketing verbs. Blunt and specific.
  No fake testimonials, no invented statistics.
- Every axis claim must be measured in a browser before you rely on it.

MONEY SHOT
A giant variable headline where each letter’s weight answers to pointer
proximity, plus an idle auto-wave so it’s alive with no pointer, plus a
scroll-driven run through the width axis. Requirements:
- Per-letter spans kill kerning. Shape the intact string in a hidden probe,
  read each glyph’s position with a Range, and place absolute spans on those
  coordinates. Do not hand-roll kerning.
- Drive the wave with GRAD, not wght alone. GRAD changes apparent weight
  without changing advance width, so the line cannot reflow.
- Fit the ink to the measure, not the advance box: measure sidebearings with
  canvas measureText and hang them outside the measure.
- Precompute glyph offsets across the width range so the rAF loop reads no
  layout and only writes transform and font-variation-settings.

EXAMPLES
- Headline: "EVERY WORD PAYS RENT", set as a justified stack where each line
  is sized to fill the identical measure. Shorter lines end up larger.
- Voice demo: one slider, five registers, same message. Blunt is wght 900 /
  wdth 68 at five words. Formal is wght 180 / wdth 140 at nineteen. The slider
  rewrites the sentence and moves the axes together.
- Notation: read size and leading back off the DOM with getComputedStyle so
  the numbers stay honest at every breakpoint.
05 / Iteration log

What actually went wrong.

Three passes, screenshotted and reviewed each time. The interesting entries are the failures.

  1. Pass 1 / Structure

    The money shot was dead on arrival. The hero headline fit computed 25px instead of 241px. Cause: .wrap carries margin-inline: auto, and .display-sect is a column flex container. Auto margins on a flex item’s cross axis beat align-self: stretch, so every wrap in the hero collapsed to shrink-to-fit and the measure hit my 120px floor. The tell was that "SAY LESS" in the final CTA rendered correctly, because that wrap sits in a normal block. Fixed with width: 100%.

    Also fixed: a this.measure = measure assignment clobbering the measure() method, which threw on every load. The wave was far too subtle, with a gaussian radius of 0.4× the line so adjacent letters barely differed, and GRAD alone capping the crest around apparent 550. Tightened the radius to 0.27× and added wght to the swing. The fire caret was rendering 21px wide and 224px tall, a slab rather than a cursor, and it was covering the spec notation. The display line sat on its advance box, indenting the giant E 12px from the rail above it. .mono-xs never set font-family, so every small label using it standalone rendered in Roboto Flex instead of Space Mono. The axis readout printed 'WGHT' in caps, which is simply wrong: OpenType axis tags are case-sensitive. Entrance animation used transform, which the rAF loop overwrites on the next frame, so it became a clip-path wipe. <strong> rendered identically to body text, because font-variation-settings on a parent silently beats font-weight on a child. Fifteen fixes in total.

  2. Pass 2 / Depth

    Added a waterfall plate, the specimen convention the page was missing, with size/leading notation read back off the DOM via getComputedStyle rather than typed in, so it reports 95/84 and 63/58 rather than the round numbers in the source. Each row’s opsz is pinned to its own rendered size, which is the entire point of an optical size axis.

    Also added: nav scroll-spy driving a fire underline on the section you’re in; hero entrance choreography staggering the rail, letters, copy, CTAs and readout across 1.1s; a staggered reveal on the proof strip; hover morphs on the pricing tier names and waterfall rows that push the type to its condensed black end; a third paragraph and a fourth margin note in the editor mock. The playground pane was pinned to 240px by a JS auto-grow while its column stretched to 500px, leaving 260px of dead black, so it now fills its column properly.

  3. Pass 3 / QA

    Verified 390px mobile with no horizontal overflow and the display stack re-grouping from two lines to four. Confirmed reduced-motion swaps the roaming wave for a static designed weight ramp rather than just freezing it. Checked the console clean, the guide route styled, and meta, favicon and og tags present. Re-shot against the live deploy to confirm production renders identically to local.

06 / Attribution

Who made this.

This site was designed and built entirely by Claude Opus 4.8. Art direction, copy, layout, the specimen engine, all three iteration passes and the deploy. There’s no human design work in it and no other model contributed to it.

The wider showcase it belongs to was started on Claude Fable 5, which built the other sites in the set. Fable’s usage credits ran out partway through the run, and the remaining sites, including this one, were finished on Opus 4.8. So the showcase has two authors, split by site rather than mixed within one. Kern is entirely Opus 4.8’s.

Kern isn’t a real company and you can’t buy it. The pricing, the customer names in the proof strip, and the editor mock are all invented for the exercise. Roboto Flex and Space Mono are real, and they’re both worth your time.