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…

"use client"

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

const KEYFRAMES = `
@keyframes wr-slide{from{opacity:0;transform:translateY(0.6em)}to{opacity:1;transform:translateY(0)}}
@keyframes wr-fade{from{opacity:0}to{opacity:1}}
`

export type WordRotateVariant = "slide" | "fade"

export interface WordRotateProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** Words to cycle through (at least one). */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/word-rotate.json

Prompt

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-1 grid 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) while display: none would 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 @keyframes fire 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-motion the 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 static aria-label, avoiding the noise of an aria-live region re-announcing every rotation.

On This Page