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.