Variable font weight as a motion channel

Animating a weight axis instead of opacity or transform gives you motion that never reflows the composition — but it bills you for a relayout on every frame, and the curve you reach for first is the wrong one.

Most interface motion moves things. Something slides, fades, scales; the composition is briefly not where it was. That is fine for a panel and wrong for a name set at display size, because a name that drifts under the pointer stops reading as typography and starts reading as a widget.

A variable font offers a third option. The weight axis changes how heavy a glyph is drawn without moving the line it sits on, so the mark can respond to a pointer while the composition stays exactly where the layout put it. No transform, no opacity, no colour change. Just a letter getting heavier.

This site's display name does that, and the interesting parts are all the places where the obvious implementation is wrong.

The rule is "move away from rest", not "get bolder"

The heading is two lines at different resting weights: the given name at 500, the family name at 900. The face is Rubik Variable and the loaded cut exposes a wght axis from 300 to 900, so 900 is the ceiling — the family name has nowhere heavier to go.

So the two lines move in opposite directions:

export const NAME_WEIGHTS: Record<NameLine, { rest: number; active: number }> = {
  given: { rest: 500, active: 800 },
  family: { rest: 900, active: 650 },
};

The given name gains weight, the family name loses it. Both are "the character under the pointer departs from its resting weight", which is one rule rather than two special cases, and it survives the ceiling instead of being defeated by it.

There is a second constraint hiding in those numbers. Only one character is ever active, so the pairings a viewer actually sees on screen are active-given (800) against resting-family (900), and active-family (650) against resting-given (500). The family name stays the heavier line in every reachable state. Pick the active values carelessly and the hierarchy inverts halfway through a hover.

Changing weight costs a relayout, and the naive loop pays it eleven times

This is the part that does not come up with opacity.

Changing wght changes the glyph's advance width. The letter is physically wider at 800 than at 500, so writing a new weight is not a paint — it is a style recalc and a relayout of the line. Every character you touch, every frame, is billed.

The first version wrote all eleven characters on every frame, ten of which were sitting still at their resting weight and being rewritten with the value they already had. That measured roughly 45ms per frame. A ramp that only gets a handful of frames does not glide; it lands in visible steps.

The fix is to compare against the last value actually written and skip the write when nothing changed:

const write = (index: number, weight: number) => {
  const value = weightStyle(weight);
  if (written[index] === value) return;

  written[index] = value;
  chars[index]?.style.setProperty('font-variation-settings', value);
};

That reduces the per-frame work to the one or two characters genuinely in motion. It is an unremarkable guard, and it is the difference between the effect working and the effect being a slideshow.

The curve you reach for first is the wrong one

The natural choice for a hover ramp is an ease-out cubic. It is what most interfaces use and it is what the interface this one was measured against uses.

It was still wrong here, and the reason is frame budget rather than taste.

An ease-out cubic is at its steepest the instant it starts — slope 3 — so the largest jump of the entire ramp is its very first frame. A page handing the ramp a frame every 16ms gets away with that: the first step is about 44 weight units, which reads as fast rather than as a jump. This hero has a raster field running underneath it and measures nearer 28ms per frame. The same curve put 92 units into a single step, and the effect read as a snap followed by a glide rather than as one movement.

Smoothstep peaks at slope 1.5, in the middle of the ramp, where the eye is already tracking motion. At the measured frame interval that is roughly 33 units per step on the engage ramp and 20 on the release — smaller steps than the reference's own, from a curve that is nominally less sophisticated.

The general point: an easing curve is a promise about frames you have not measured. Any curve that is steepest at the start assumes you will be there to draw it. If your frame budget is spoken for, a curve with its peak in the middle is not a compromise, it is the correct choice.

The ramps are also asymmetric — 380ms to engage, 620ms to release. Quick to answer, slow to let go. That asymmetry carries more of the feel than either absolute number does.

No distance falloff, and why that was a finding rather than a shortcut

The intuitive implementation gives each character a weight based on its distance from the pointer, so a soft bulge travels along the word. Measuring the behaviour this was modelled on found something simpler: exactly one character responds, and it responds when the pointer is inside its own layout box. A radius probe at two viewports found the response confined to the glyph box, with no neighbour ever leaving its resting weight.

That is worth stating because it inverts the usual assumption. The falloff version is more code, more per-frame work — every character is now moving, so the write guard above saves nothing — and it reads as mushier. The restrained version is cheaper and sharper at the same time.

It has to be complete without any of this

The whole interaction is gated off under prefers-reduced-motion: reduce and on coarse pointers, and both gates are live: plugging in a mouse flips pointer: fine, and the motion preference is a system setting a visitor can change with the page open. So the enable and disable paths are a full lifecycle, not a one-time branch at mount.

That is only survivable because the resting composition is the finished design. The base stylesheet paints 500 and 900; the server-rendered HTML is already correct; the client component's first frame after hydration is identical to the frame before it. Nothing flashes, and every path where the interaction does not run lands on the complete treatment rather than on a neutralised one.

Which is the same argument as writing the reduced-motion contract before the animation — the version without the enhancement has to be the one that works.

Is the axis worth its bytes?

Honest accounting: the shipped Latin cut is 35,348 bytes and carries weight 300–900. A set of static cuts at 500 and 900 would be smaller.

They also cannot do this. Interpolating between two static weights means cross-fading two elements, which is opacity animation wearing a costume: the glyph edges double, the text goes soft mid-transition, and the advance widths do not match so the line reflows anyway. The weight axis is the only way to get a continuous, single-glyph transition — and continuity is the entire effect.

That is the test for any variable axis. Not "does it give us more expression" but "is there a requirement here that static cuts genuinely cannot meet". For the weight axis on this site, there is. For the width axis, measured separately, there was not — so it does not ship.

All articles