Text
Word Rotate
A jitter-free rotating word inside a headline — cycles through a word list with a slide or fade transition and never shifts layout width.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/word-rotate.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "WordRotate" component.
Contract
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement>.
- Props: words (string[], at least one), interval (ms between rotations,
default 2500), variant = "slide" | "fade" (default "slide"). className
is applied to the word spans themselves (not the outer wrapper), so it
inherits the surrounding font-size and can carry per-word styling like
color or a gradient-text treatment.
Behavior
- Keep ONE piece of state: the active word index. A single setInterval
advances it modulo words.length; state is only ever set from that async
callback, and the timer is cleared on unmount and whenever words/interval
change. Reset the index during render (compare a previous "words" key,
no effect) if the words array itself changes, so it never reads out of
bounds.
- Zero layout jitter: render every candidate word stacked in the SAME CSS
grid cell (col-start-1/row-start-1 on an inline-grid container) as an
invisible (visibility: hidden, not display: none) sizing layer — the
grid track auto-sizes to the widest word, so the container's width is
permanently pinned to the longest candidate and never reflows the rest
of the line as shorter/longer words rotate through.
- On top of that sizing layer, render exactly one visible span for the
current word. Key it by the active index so React remounts a fresh DOM
node on every rotation — mounting is what triggers the CSS enter
animation (no imperative animation library needed).
- Read prefers-reduced-motion via useSyncExternalStore on matchMedia
(server snapshot false). Rotation keeps advancing under reduced motion
(the information still changes) but the visible span's key becomes a
constant, so the same node persists and the text swaps in place with
no animation — respecting the setting without hiding content.
Rendering & styling
- Semantic tokens only; no component-level font-size or color — the
component inherits typography from its surrounding context, and
className is merged (cn()) onto both the sizing spans and the visible
span so width measurement and final rendering always agree.
- Two hoisted @keyframes shipped via a React 19 <style href precedence>
tag (dedupes by href, no Tailwind config edits): slide animates
translateY(0.6em) + opacity 0 to translateY(0) + opacity 1; fade
animates opacity 0 to 1. ~400ms ease-out.
- Accessibility: the rotating layer is noise for screen readers mid
transition, so it's aria-hidden; the outer span carries a static
aria-label of all candidate words joined with ", " so assistive tech
gets the full set once instead of a moving target.
Customization levers
- Direction: swap translateY(0.6em) for translateY(-0.6em) to have new
words drop in from above instead of rising from below.
- Pace: interval controls how long each word is shown; the 400ms
transition duration can be tuned independently for a snappier or
softer swap.
- Color / gradient: pass className with a text color token, or a
bg-gradient-to-r + bg-clip-text + text-transparent combo — it lands
directly on the word spans, so gradient-text treatments clip correctly
per word instead of across the whole line.
- Word list: any length ≥ 1 works, including a single word (rotation
simply never fires) — useful for reusing the same component as a
static styled word.Concepts
- Width reservation via grid stack — every word is rendered in the same
col-start-1/row-start-1grid cell so the browser auto-sizes the track to the widest candidate; the container's width never changes as words rotate in and out. - Invisible, not removed — the sizing copies use
visibility: hidden, which keeps their layout box (and therefore their contribution to the grid track) whiledisplay: nonewould drop them from sizing entirely. - Remount-as-animation trigger — the visible word is keyed by its index, so a rotation swaps in a brand-new DOM node; the CSS
@keyframesfire on mount, no animation library or manual class toggling required. - Reduced-motion keeps the information, drops the motion — rotation keeps advancing (the words still change) but under
prefers-reduced-motionthe key stops changing, so the same node persists and the text swaps instantly instead of playing the enter animation. - Static aria-label over live region — the mid-transition text is
aria-hidden; screen readers get every candidate word once via a staticaria-label, avoiding the noise of anaria-liveregion re-announcing every rotation.