Variable Font Scroll
A headline whose variable-font axes interpolate with scroll position, thickening character by character as it crosses the viewport.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/variable-font-scroll.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VariableFontScroll" component. No
dependencies beyond React and Tailwind.
Contract
- Export a forwardRef<HTMLElement, VariableFontScrollProps> extending
Omit<React.HTMLAttributes<HTMLElement>, "children">; remaining props are
spread on the root.
- Props: text (string, required — the headline); as (React.ElementType,
default "h2"); axes (a map of 4-character OpenType axis tags to
[from, to] ranges, default { wght: [200, 800] }, typed with named
wght/wdth/slnt/opsz keys plus an open index signature for custom tags);
scrollWindow ([enter, settle] fractions of viewport height measured from
the top, 1 = bottom edge, default [0.9, 0.4]); stagger (0–0.9, the share
of the window spread across the characters, default 0.4); fallbackWeight
(number, defaults to the top of the wght range or 700); variableFont
(boolean, overrides the automatic probe); disabled (boolean, freezes the
axes at mid-range); className merged last via cn().
Behavior
- Progress: on every tick read the root's getBoundingClientRect(), take the
element's midpoint as a fraction of window.innerHeight, and map it across
the scroll window: 0 while the midpoint sits at or below `enter`, 1 once
it has risen to `settle`, clamped outside. Guard a degenerate window
(enter === settle) with a hard 0/1 step.
- Per-character stagger: split the text per code point; whitespace runs stay
raw text nodes (no span, no index) so wrapping and selection are unchanged.
Browsers do not shape text across inline element boundaries, so guard the
split with a regex over the scripts that join or stack (Arabic/Hebrew,
Indic, Thai, combining marks, ZWJ / skin-tone / flag emoji): those strings
split per *word* instead — a shaping run never crosses a space, so the
glyphs stay joined and only the wave's granularity changes.
Character i uses localProgress = clamp01((progress - i/(n-1) * spread) /
(1 - spread)) with spread = clamp(stagger, 0, 0.9), then smoothstep for
soft ends. Because the offset is normalized, the last character always
finishes exactly at progress 1 regardless of headline length.
- Writing: build `"<tag>" <value>, …` from the axis ranges and assign it to
each character's style.fontVariationSettings directly through refs — never
through React state. Cache the last string per character and skip
identical writes, so a headline clamped at either end of its window costs
nothing while the user keeps scrolling. Also publish the raw progress on
the root as a --vfs-progress custom property for consumers to hook into
(variable mode only — the static branch below never measures anything).
- Listeners: ONE rAF-throttled handler for a passive, capture-phase scroll
listener on document (capture also catches nested scrollers, since scroll
does not bubble), a passive window resize listener, a ResizeObserver on the
root (fonts loading / container resize change geometry without a scroll
event) and an IntersectionObserver with rootMargin "100% 0px" that gates
the handler, so off-screen headlines never measure. Every listener,
observer and pending rAF is torn down on unmount. Nothing ever calls
preventDefault, so touch scrolling is untouched. Keep the host node in
state (set from the ref callback) and list it in the effect's deps, so
changing `as` re-attaches both observers to the element that is actually
mounted instead of stranding them on a detached node.
- Variable-font probe: CSS.supports only proves the browser parses the
property, so measure instead — append a hidden aria-hidden probe span
inside the root (it inherits the family) and compare its width at both ends
of every axis the caller asked for, not just "wght" (a family may expose
only "wdth"). Overshoot each range by 100: out-of-range values are clamped
to what the face supports, so even a narrow range still swings as wide as
it can. A static family ignores the property and every measurement matches.
Run it in a layout effect at mount and again on document.fonts.ready (the
real face may not have loaded yet), guarded by a cancelled flag; key the
effect on a serialized axis string so an inline axes literal does not
re-probe on every render. Only axes that move the advance width are visible
this way — monospaced families and "slnt"/"GRAD"-only axes read as static,
which the `variableFont` prop documents as the escape hatch.
- Static fallback: when the probe fails (or axes is empty), render the text
as one plain text node with fontWeight = fallbackWeight — no split spans,
no listeners. This is also the server-rendered and pre-hydration output,
so the headline is legible before and without JS.
- Reduced motion: read prefers-reduced-motion via useSyncExternalStore on
matchMedia (server snapshot false, listener removed on unmount, mid-session
changes respected). When reduced — or when `disabled` — attach no scroll
listener and paint every axis once at the middle of its range, the same as
`disabled`. The headline is always fully rendered; motion is the only thing
that stops.
- SSR: touch window/document only inside effects; the layout effect falls
back to useEffect on the server.
Rendering & styling
- Semantic tokens only. The component sets no color, size or family of its
own — it inherits typography from className and the surrounding context, so
it stays theme-agnostic; use text-foreground / text-muted-foreground or any
token class from the outside.
- Expose data-font-mode="variable|static" and data-scroll-driven="true|false"
on the root for styling and testing hooks.
- Accessibility: while split, the root renders the real string in a
visually hidden node (sr-only) next to the aria-hidden per-character layer,
so a screen reader reads the sentence, not 40 fragments. Do NOT use
aria-label for this: ARIA prohibits a name on the generic and paragraph
roles, so as="span" / as="p" would drop the headline from the
accessibility tree entirely. There is nothing interactive here, so no focus
handling is required; the element remains a real heading.
Customization levers
- Drama: widen the wght range ([100, 900] is the loudest a family allows) or
narrow it ([400, 650]) for an editorial, barely-there swell.
- Extra axes: add wdth ([88, 112]), slnt ([0, -8]), opsz or any custom tag —
they interpolate on the same progress and are ignored by families that do
not expose them.
- Pace: stagger 0 moves the line as a block; 0.6–0.85 sends a visible wave
across it; a narrow scrollWindow ([0.72, 0.52]) makes the sweep feel like a
switch, a wide one ([1, 0.2]) makes it a slow build.
- Element: `as="h1"` for the hero, `as="p"` for a lede, `as="span"` inline.
- Wrapping: leave `text-wrap: balance` off a scroll-driven headline — weight
changes advance widths, so the browser re-runs its line-break search on
every frame and a multi-line headline visibly re-wraps mid-sweep. Balance
the static type around it instead, or keep the swept range narrow.
- Downstream effects: read var(--vfs-progress) in your own CSS to fade a
rule, shift letter-spacing, or tint a decorative layer with the same curve.
- Fallback look: fallbackWeight decides how static families land — match it
to the resting weight of your design rather than the animation's endpoint.Concepts
- Viewport window as the timeline — scroll position is not a trigger here, it is the playhead: the element's midpoint is mapped through
[enter, settle]viewport fractions into a 0–1 progress that can run backwards as freely as forwards, unlike a one-shotIntersectionObserverentrance. - Normalized per-character stagger — the stagger is expressed as a share of the window rather than a per-character delay, so a three-word headline and a twelve-word one both finish at exactly progress 1; only the wave's steepness changes. The staggered unit is a character in Latin-script strings and a word in scripts that shape across characters (Arabic, Devanagari, Thai, ZWJ emoji), because browsers refuse to shape across inline element boundaries and a per-character split would visibly break the glyphs.
- Direct DOM writes, zero re-renders — every frame assigns
font-variation-settingsstraight onto the character spans through refs and caches the last string, so scrolling produces no React work at all and a clamped headline produces no style work either. - Measured capability, not assumed capability —
CSS.supportsonly proves the browser understands the property; a hidden probe rendered at both ends of every requested axis proves the loaded family actually moves, and it re-runs afterdocument.fonts.readyin case the webfont arrived late. Its blind spot is honest and documented: only axes that change the advance width are detectable, so monospaced families (constant advance at every weight, by definition) andslnt/GRAD-only axes needvariableFontpassed explicitly. - Static fallback is the default state — the non-variable branch is what the server renders and what ships before hydration: one plain text node at
fallbackWeight. There is no arrangement of failures (no JS, no variable font, no observers) that leaves the headline invisible or half-formed. - Frozen at mid, not frozen at zero — under
prefers-reduced-motionthe axes park in the middle of their ranges instead of at the thin start, so motion-sensitive readers get the intended typographic voice rather than the un-animated first frame.