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.json
Type · JSzero CLS
Ambient motion that is measured
the box never reflows

TextRotateesc or double-click to exit full screendouble-tap to exit

primitives/text-rotate.tsx
"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