Text

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.

Preview in your theme

Loading preview…

"use client"

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

/**
 * Keyframes ship inside the component via React 19 hoisted <style> —
 * no tailwind config edits, and duplicates dedupe by href.
 */
const KEYFRAMES = `@keyframes zy-kh-slide-in{from{opacity:0;transform:translateY(0.62em)}to{opacity:1;transform:translateY(0)}}
@keyframes zy-kh-slide-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-0.62em)}}
@keyframes zy-kh-flip-in{from{opacity:0;transform:rotateX(-84deg) translateY(0.3em)}to{opacity:1;transform:rotateX(0deg) translateY(0)}}
@keyframes zy-kh-flip-out{from{opacity:1;transform:rotateX(0deg) translateY(0)}to{opacity:0;transform:rotateX(84deg) translateY(-0.3em)}}
@keyframes zy-kh-blur-in{from{opacity:0;filter:blur(0.16em);transform:scale(0.93)}to{opacity:1;filter:blur(0);transform:scale(1)}}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/kinetic-headline.json

Prompt

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 (never display: none, which has no box to measure) sizing layer and read with getBoundingClientRect. A ResizeObserver on 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-w straight 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 onAnimationEnd rather than on a setTimeout that 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 paused prop, hover (mouse only; a coarse pointer fires enter without a matching leave and would freeze the line), focus-within, off-screen via IntersectionObserver, 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-hidden because mid-transition it genuinely contains two words; an sr-only span 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 polite role="status" live region, and belongs with a paused control that can silence it.

On This Page