variable font isn't one font file — it's a continuous range of one, with axes like weight or width you can dial to any value instead of picking from a fixed set of "Regular / Medium / Bold" cuts. Almost nobody uses that continuity for anything interactive; it mostly gets used the same way a normal font would, just picking a couple of fixed weights and calling it done. Our web service page hero does something with it that a static weight can't: each letter's weight tracks how close your cursor is to it, in real time.
Nothingisswapped,cropped,orcross-faded.Oneproperty—font-variation-settings—isjustbeingsettoadifferentnumber,sixtytimesasecond,perletter.
Move your cursor near this
Every letter measures its own distance to the cursor on every animation frame and maps that distance to a 0–1 value using smoothstep — the same t*t*(3-2*t) curve used to avoid a mechanical linear ramp — then uses that value to interpolate the weight axis between 260 (light) and 900 (heavy), and the color between a dim, desaturated blue and a bright, saturated one. Move away and the letters behind you cool back down on their own, because the calculation runs continuously — there's no explicit "unhover" event to write, just distance getting larger again.
typescript
const r = letter.getBoundingClientRect(); const d = Math.hypot(mx - (r.left + r.width / 2), my - (r.top + r.height / 2)); const raw = Math.max(0, 1 - d / 300); // 0 past 300px, 1 at the center const t = raw * raw * (3 - 2 * raw); // smoothstep letter.style.fontVariationSettings = `'wght' ${Math.round(260 + t * 640)}`; letter.style.color = `rgba(${186 + t*69}, ${230 + t*25}, 252, ${0.2 + t*0.8})`;
It never actually sits still
If the pointer hasn't moved in 2.6 seconds, the loop switches from cursor-distance to a slow per-letter sine wave — each letter breathing between light and slightly heavier on its own gentle offset, so the word never looks frozen when nobody's touching it. That threshold matters more than it sounds like it should: without it, a word that hasn't been touched yet just sits at its lightest weight looking inert, which reads as broken rather than as "waiting for you." The idle animation is what tells a first-time visitor there's something here to discover before they've found it themselves.
Why raw style mutation instead of React state
Every letter's style is written directly via letter.style.fontVariationSettings, completely outside React's render cycle — not useState, not a Framer Motion value. At up to 60 updates per second across every letter in the word simultaneously, routing that through React re-renders would mean scheduling and diffing work the browser doesn't actually need, when a direct DOM mutation on an already-existing element is exactly what requestAnimationFrame loops are for. React owns mounting the spans once; after that, the animation loop owns them completely until the component unmounts and the loop is cancelled.