TextRotate
A headline word that cycles through a list on an interval while the box stays locked to the widest word, so nothing around it ever reflows.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/text-rotate.jsonType · JSzero CLS
Ambient motion that is measured
the box never reflows"use client";
import * as React from "react";
import { cx, useMotionRateRef, useMotionTrigger, useReducedMotion } from "@pouriahlabs/wisp-ui";
/**
* TextRotate (WordSwap) — a headline word that cycles without moving the page.
*
* The whole point of swapping a word in place is that the sentence around it
* holds still. But words are not the same width, so the naive version — swap
* the text and let the box resize to fit — shoves every word after it left and
* right on each tick, and on a centred headline the entire line breathes. That
* is a layout shift on a loop: the exact thing a decorative effect must never
* cause, made worse by repeating forever.
*
* So every word is always in the layout, stacked into one grid cell. The cell
* sizes itself to the widest and tallest word and then never changes as the
* active one changes — no measuring code, no ref, nothing to get wrong on the
* first paint. Only opacity and transform animate the swap; never a layout
* property. The reserved box is therefore correct from the first frame, and the
* surrounding text cannot notice a word underneath it come and go.
*
* Only the active word is exposed to assistive tech — the rest are aria-hidden
* — so the element reads as its current word rather than as all of them at once.
* We deliberately do NOT wrap it in an aria-live region: a live region would
* announce the swap on every interval, forever, which is noise, not content.
*
* Reduced motion holds the first word, static. The still frame is just the
* headline as it first painted — a real, readable state, not a blank box — so
* `useMotionTrigger` never starts the interval and the transition is disabled
* outright, leaving provably zero motion.
*/
export interface TextRotateProps extends React.HTMLAttributes<HTMLSpanElement> {
/** The words to cycle through. The first is the SSR paint and the still frame. */
words: string[];
/** Milliseconds each word is held before the next. */
interval?: number;
/** Milliseconds of the fade/slide between words. */
duration?: number;
as?: React.ElementType;
}
export function TextRotate({
words,
interval = 2200,
duration = 400,
as: Tag = "span",
className,
style,
...props
}: TextRotateProps) {
const [active, setActive] = React.useState(0);
const still = useReducedMotion();
// Speed read live, so a surrounding view (the full-screen viewer) can quicken
// or slow the cadence without restarting the cycle from the first word.
const rateRef = useMotionRateRef();
// Seeded explicitly — `useRef<T>()` with no argument is a type error under
// `@types/react` 19, and this file gets copied into consumers' repos.
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// One word (or none) has nothing to cycle: skip the whole lifecycle so it
// renders as plain static text, and so the modulo below never sees a zero.
const cycles = words.length > 1;
const ref = useMotionTrigger<HTMLElement>(
{
replay: true,
run: () => {
// A rotator has no natural end — it loops for as long as it's on screen
// — so `done()` is never called. Leaving the viewport (or backgrounding
// the tab) is what stops it, via settle. Self-rescheduling rather than a
// fixed interval so each wait reads the current speed: a mid-cycle change
// retimes the next swap instead of tearing the loop down.
const tick = () => {
setActive((i) => (i + 1) % words.length);
timer.current = setTimeout(tick, interval / (rateRef.current.speed || 1));
};
timer.current = setTimeout(tick, interval / (rateRef.current.speed || 1));
},
// Both stopping paths clear the timer; settle also returns to the first
// word, so a reader who scrolls back — or who flips on reduced motion
// mid-cycle — always re-enters from the same, deliberate opening frame.
settle: () => {
clearTimeout(timer.current);
setActive(0);
},
cancel: () => clearTimeout(timer.current),
disabled: !cycles,
},
[words.join("␟"), interval],
);
return (
<Tag
ref={ref}
className={cx("wisp-text-rotate", className)}
// inline-grid keeps the word in the flow of its sentence while still
// stacking every candidate into a single, self-sizing cell.
style={{ display: "inline-grid", ...style }}
{...props}
>
{words.map((word, i) => {
const shown = i === active;
return (
<span
key={i}
aria-hidden={!shown}
style={{
// Every word lands in the same cell, so the cell — and thus the
// element — is sized to the widest and tallest of them and holds
// that size for good. This one line is what reserves the box.
gridArea: "1 / 1",
justifySelf: "start",
// Only opacity and transform ever change: the outgoing word sinks
// and fades as the incoming one rises into place, and neither one
// ever touches a layout property.
opacity: shown ? 1 : 0,
transform: shown ? "none" : "translateY(0.35em)",
transition: still
? "none"
: `opacity ${duration}ms ease, transform ${duration}ms ease`,
// The transparent copies must not swallow selection or clicks from
// the word actually on screen.
pointerEvents: shown ? undefined : "none",
}}
>
{word}
</span>
);
})}
</Tag>
);
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React