Text

Variable Font Scroll

A headline whose variable-font axes interpolate with scroll position, thickening character by character as it crosses the viewport.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** `[from, to]` endpoints of one OpenType variation axis, walked as scroll progress goes 0 → 1. */
export type VariableFontAxisRange = [from: number, to: number]

export interface VariableFontAxes {
  /** Weight axis — the one every variable text family exposes. Typical range 100–900. */
  wght?: VariableFontAxisRange
  /** Width axis, in percent of normal. Typical range 75–125. Ignored by families without it. */
  wdth?: VariableFontAxisRange
  /** Slant axis, in degrees; negative leans forward. Typical range 0 to -10. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/variable-font-scroll.json

Prompt

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-shot IntersectionObserver entrance.
  • 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-settings straight 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 capabilityCSS.supports only 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 after document.fonts.ready in 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) and slnt/GRAD-only axes need variableFont passed 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-motion the 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.

On This Page