Kinetic Headline
A fixed sentence with one rotating slot whose width springs to each incoming word, so the rest of the line glides instead of snapping.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/kinetic-headline.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "KineticHeadline" component (no animation
library — one rAF spring and CSS keyframes).
Contract
- forwardRef<HTMLSpanElement>, extends Omit<React.HTMLAttributes<HTMLSpanElement>,
"prefix"> (HTMLAttributes already declares an RDFa `prefix`), spreading the rest
onto the root span so the consumer can wrap it in their own <h1>/<p> and inherit
the typography.
- Props: words (string[]; one word is valid and simply never rotates), prefix and
suffix (ReactNode — the fixed halves of the sentence, rendered with an explicit
{" "} on each side), interval (ms per word, default 2400, clamped to a 600ms
floor and guarded against NaN), transition = "slide" | "flip" | "blur"
(default "slide"), slotClassName (colour / weight / tracking of the rotating
word only), className (merged onto the root with cn()).
- Two accessibility props: paused (boolean, default false — a controlled hold that
ORs into the internal pause predicate, so a keyboard-reachable Pause button is
one useState in the consumer and nothing else) and announce = "list" | "change"
(default "list"; see Accessibility below).
- The root also exposes data-paused while the rotation is held, so a consumer can
style a "paused" hint without lifting state.
Behavior
- State is one object: { index, prev }. Advancing sets index = (index + 1) %
words.length and parks the old index in prev; prev is the word on its way out
and -1 means nothing is exiting. When the words array itself changes (compare a
joined key during render, no effect), reset both so no index can point past the
end of a shorter list.
- Width spring, the whole point of the component. Render every candidate once in
an absolutely positioned, `invisible` (not display:none — it must keep a
measurable box) sizing layer inside the slot, and read each natural width with
getBoundingClientRect. On rotation, integrate a critically-damped spring
(stiffness ~210, damping ~28, dt clamped to 1/30 so a backgrounded tab cannot
teleport it) from the current width to the incoming word's width, writing the
result to a CSS custom property (--zy-kh-w) via the slot's style, NOT to React
state: 60 re-renders a second of the whole sentence buys nothing. The slot's
width is `var(--zy-kh-w, auto)`, so before measurement — and during SSR — it is
simply the natural width of the first word.
- Re-measure through a ResizeObserver on the sizing spans (breakpoint font-size
steps and late webfont swaps both change every candidate's width) and disconnect
it on unmount. A measured width of 0 means a display:none ancestor: leave the
slot on `auto` instead of collapsing it.
- Hand-off: the slot is an inline-grid; incoming and outgoing words share grid
cell 1/1 so they sit on the same baseline. The incoming span is keyed by index,
so a rotation mounts a fresh node and the enter keyframe fires on mount; the
outgoing span plays the exit keyframe and removes itself in onAnimationEnd —
no exit timer to leak. Three pairs of keyframes: slide (translateY ±0.62em),
flip (rotateX ±84deg with a perspective in em on the slot, so the depth scales
with type size), blur (blur(0.16em) + scale).
- Clip horizontally only, with a clip-path polygon whose vertical edges sit far
outside the box (0/-200% .. 100%/300%). A long word leaving while the width
shrinks must not spill over the text after it, but slide and flip have to travel
outside the line box — and overflow:hidden would clip both axes and destroy the
inline baseline of the slot.
- Pause, from five sources ORed together: the controlled `paused` prop (the only
one a consumer can drive from a keyboard-reachable button), hover
(pointerenter/leave, and only for pointerType "mouse" when
matchMedia("(pointer: coarse)") is false — touch fires enter events that never
get a matching leave, so a tap would freeze the line forever), focus-within
(onFocus/onBlur with a relatedTarget containment check, so a link inside
prefix/suffix holds the rotation while it is focused), an IntersectionObserver
that reports the headline off-screen, and document.hidden via a
visibilitychange listener. Pausing clears the interval; resuming starts a fresh
one. Every listener/observer/frame is torn down on unmount.
- prefers-reduced-motion is a stop, not a slower spin: index is pinned to 0, no
interval is created, no exit node is rendered, the incoming span's key is frozen
so nothing remounts, and the width is set flat instead of sprung. The sentence
still reads correctly — it is just a still line. Read the query (and the pointer
query) with useSyncExternalStore over matchMedia, server snapshot false, listener
removed on unmount, so a mid-session OS change is respected.
Rendering & styling
- Semantic tokens only, no hex/rgb/oklch and no palette classes: the decorative
rule under the slot is a from-transparent / via-primary/60 / to-transparent
gradient, and the word colour comes from whatever slotClassName the consumer
passes (text-primary, a chart token, or a bg-clip-text gradient).
- The component sets no font-size, weight or leading — it inherits all of it, so
the same instance works in a 16px paragraph and a 72px hero.
- Keyframes ship in one React 19 hoisted <style href precedence> tag (dedupes by
href, no Tailwind config edit); prefix every keyframe and custom property.
- The slot is box-content, so padding or a border added through slotClassName sits
outside the animated content width instead of clipping the word.
- Accessibility: the animated slot is aria-hidden (mid-transition it holds two
words at once) and an sr-only span sits exactly where the slot sits, so the
sentence still reads in order. What that span says is the `announce` switch.
Default "list": inert text naming every candidate once ("landing pages, pricing
tables, changelogs"), no live region — an endless polite announcement a keyboard
user cannot stop is a WCAG 2.2.2 (Pause, Stop, Hide) failure, so it is not the
default. "change" upgrades it to role="status" aria-live="polite"
aria-atomic="true" carrying the current word, announced once per rotation
without interrupting; pair it with the `paused` prop wired to a real control.
Both degrade to plain static text when the line cannot rotate at all (one word,
or reduced motion), because there is then nothing to announce. The decorative
rule is pointer-events-none.
Customization levers
- Pace: interval 1800–2200ms reads as energetic, 3000–4000ms as calm; the enter
(460ms) and exit (300ms) durations are independent constants — shortening the
exit makes the hand-off crisper.
- Spring feel: stiffness/damping are the only two numbers that matter. 210/28 is
quick and dead flat; ~140/22 gives a softer glide; dropping damping below ~20
introduces the overshoot bounce some brands want.
- Transition: "slide" for editorial hero copy, "flip" for product/tech pages,
"blur" for softer, slower brands. Adding a fourth is one more keyframe pair plus
one entry in the transition map.
- Palette: slotClassName is the single colour knob — text-primary by default, a
chart token per section, or bg-gradient-to-r + bg-clip-text + text-transparent
for a gradient word. Delete the rule layer for a bare word, or re-point it at
var(--chart-1..5) for a section accent.
- Sentence shape: prefix/suffix take nodes, so the fixed half can carry a link, an
<em>, or a second styled span; leave suffix out entirely for a headline that
ends on the rotating word.
- Pause policy: drop the IntersectionObserver if the headline is always above the
fold, or drop the hover branch if the line sits under a large hover target. The
`paused` prop is the pointer-free route: a `const [paused, setPaused] =
useState(false)` plus one Button ("Pause rotation" / "Resume rotation") next to
the headline gives keyboard and touch users the stop that hover-pause only gives
a mouse — and it is the precondition for turning `announce` up to "change".
- Announcement policy: leave `announce` on "list" for marketing copy nobody has to
track word by word (the list is read once and then the line is silent); switch to
"change" for a headline whose current word is real information — a status line, a
live count — and only alongside a pause control. Widening the switch (a third
mode that announces nothing at all) is one more branch on the same readout.Concepts
- Width as the animation, not the side effect — the sentence does not reserve the widest word and it does not jump: the slot's width is a spring chasing the measured width of the incoming word, so the text after the slot glides into its new position. That glide is the effect; the word transition is only the hand-off inside it.
- Measure, don't estimate — every candidate is rendered once in an
invisible(neverdisplay: none, which has no box to measure) sizing layer and read withgetBoundingClientRect. AResizeObserveron those spans catches the two things that silently invalidate a measurement: a responsive font-size step and a webfont arriving after first paint. - A custom property instead of state — the spring writes
--zy-kh-wstraight onto the slot every frame. Putting a width in React state would drag the whole sentence through reconciliation sixty times a second for a value only CSS reads. - Remount-as-enter, animationend-as-exit — the incoming word is keyed by index so mounting is the trigger for its keyframe, and the outgoing word deletes itself in
onAnimationEndrather than on asetTimeoutthat could outlive it. Both words share one grid cell, so they hand over on the same baseline. - Five ways to pause, one predicate — the controlled
pausedprop, hover (mouse only; a coarse pointer fires enter without a matching leave and would freeze the line), focus-within, off-screen viaIntersectionObserver, and a hidden tab. They OR into a single flag that clears the interval, so an unread headline costs nothing. Only the prop is reachable without a pointer, which is why it exists: hover-pause is a mouse affordance, and a rotation nobody can stop is a WCAG 2.2.2 problem, not a flourish. - Reduced motion is a stop, not a slower spin — no timer, no spring, no exiting node, and the incoming key is frozen so nothing even remounts. The headline settles on the first word and stays a perfectly readable sentence; nothing is left invisible waiting for an animation that will never run.
- Read the list, hide the machine — the animated slot is
aria-hiddenbecause mid-transition it genuinely contains two words; ansr-onlyspan in the same position carries the readable copy, so the sentence still reads in order. By default (announce="list") that copy is inert text naming every candidate once — a marketing headline should not keep talking to a screen reader forever.announce="change"opts into the politerole="status"live region, and belongs with apausedcontrol that can silence it.
External Link
An anchor that decides whether an href leaves your site — SSR-stable, origin declared as a prop — then adds an icon, the noopener patch and a new-window announcement to the ones that do, and nothing at all to the ones that do not.
Variable Font Scroll
A headline whose variable-font axes interpolate with scroll position, thickening character by character as it crosses the viewport.