Skip to content

Rotating Word Swap Headline

A headline whose key word swaps through a list: letters roll up and out with a blur, the next word rolls in letter by letter, the line closes smoothly around each width, and every word gets its own accent. A hairline timer shows the cycle, hover or focus holds it, a tap skips ahead.

DOM + CSS0 deps1 file · 9 KB TSXreduced-motion aware

Freeplayfulminimal
Preview

Props

2200
Word is a button (skip, keyboard hold)
Hold while hovering
Hairline timer
No runtime dependencies
"use client";

/*
 * RotatingWordSwap: a headline whose key word swaps through a list.
 *
 * Every word is its own absolutely positioned layer of per-letter spans. The
 * outgoing word rolls up and out letter by letter with a blur, the incoming
 * word rolls up from below with a longer stagger, and the box around them
 * animates its width between the measured widths of the words, so the text
 * before it glides instead of jumping. Each word has its own accent colour.
 * A hairline under the word shows the timer. The timer itself is a timeout
 * that keeps its remaining time, so hover, focus, off-screen and a hidden tab
 * hold the cycle and release it where it left off.
 * Reduced motion keeps the timing but crossfades the words in place.
 * No dependencies, no stylesheet to import.
 */

import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";

const DEFAULT_WORDS = ["fast", "clean", "vibed", "shipped"];
const DEFAULT_COLORS = ["#ef4444", "#d97706", "#059669", "#3b82f6"];
const SEP = "\u0001";
const NBSP = String.fromCharCode(160);

const CSS = [
  ".rws-w{position:relative;display:inline-block;vertical-align:baseline;text-align:left;white-space:nowrap;transition:width 640ms cubic-bezier(.22,1,.36,1)}",
  "button.rws-w{appearance:none;background:none;border:0;margin:0;padding:0;font:inherit;letter-spacing:inherit;line-height:inherit;text-transform:inherit;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;touch-action:manipulation}",
  "button.rws-w:active{transform:scale(.97);transition:width 640ms cubic-bezier(.22,1,.36,1),transform 120ms ease-out}",
  "button.rws-w:focus-visible{outline:2px solid #3b82f6 !important;outline-offset:6px !important;border-radius:6px}",
  ".rws-s{display:inline-block;visibility:hidden}",
  ".rws-r .rws-s{width:0;overflow:clip}",
  ".rws-m{position:absolute;left:0;top:0;visibility:hidden;pointer-events:none;white-space:nowrap}",
  ".rws-l{position:absolute;left:0;top:0;display:inline-block;white-space:nowrap}",
  ".rws-c{display:inline-block}",
  '.rws-l[data-s="in"] .rws-c{opacity:1;transform:none;filter:blur(0);transition:transform 640ms cubic-bezier(.16,1,.3,1),opacity 360ms ease-out,filter 520ms ease-out;transition-delay:calc(90ms + min(calc(var(--i) * 30ms), 330ms))}',
  '.rws-l[data-s="out"] .rws-c{opacity:0;transform:translateY(-.62em);filter:blur(8px);transition:transform 420ms cubic-bezier(.6,0,.84,0),opacity 300ms ease-in,filter 360ms ease-in;transition-delay:min(calc(var(--i) * 14ms), 140ms)}',
  '.rws-l[data-s="idle"]{visibility:hidden}',
  '.rws-l[data-s="idle"] .rws-c{opacity:0;transform:translateY(.62em);filter:blur(8px);transition:none}',
  ".rws-b{position:absolute;left:0;right:0;bottom:-.04em;height:max(2px,.04em);border-radius:2px;opacity:.55;transform-origin:left center;transform:scaleX(0);animation:rws-bar linear forwards;pointer-events:none}",
  "@keyframes rws-bar{from{transform:scaleX(0)}to{transform:scaleX(1)}}",
  "@media (prefers-reduced-motion:reduce){.rws-w{transition:none !important}.rws-c{transform:none !important;filter:none !important;transition:opacity 320ms linear !important;transition-delay:0ms !important}.rws-b{opacity:0 !important}}",
].join("");

const grapheme = (s: string): string[] => Array.from(s);

export function RotatingWordSwap({
  prefix = "Make it",
  words = DEFAULT_WORDS,
  interval = 1800,
  colors = DEFAULT_COLORS,
  interactive = false,
  pauseOnHover = true,
  progress = true,
  className,
}: {
  prefix?: string;
  words?: string[];
  interval?: number;
  /** Accent colour per word (cycled). Pass ["currentColor"] to inherit the text colour. */
  colors?: string[];
  /** Makes the word a button: Enter, Space or a tap skips ahead, and focusing it holds the cycle. */
  interactive?: boolean;
  /** Hold the cycle while the pointer is over the headline. */
  pauseOnHover?: boolean;
  /** Show the hairline timer under the word. */
  progress?: boolean;
  className?: string;
}) {
  const wordsKey = words.join(SEP);
  const list = useMemo(() => {
    const l = wordsKey.split(SEP).filter((w) => w.trim());
    return l.length ? l : DEFAULT_WORDS;
  }, [wordsKey]);
  const colorsKey = colors.join(SEP);
  const palette = useMemo(() => {
    const c = colorsKey.split(SEP).filter(Boolean);
    return c.length ? c : DEFAULT_COLORS;
  }, [colorsKey]);
  const n = list.length;
  const dur = Math.max(400, interval);

  const [st, setSt] = useState<{ cur: number; prev: number | null }>({ cur: 0, prev: null });
  const [widths, setWidths] = useState<number[] | null>(null);
  const [hover, setHover] = useState(false);
  const [focus, setFocus] = useState(false);
  const [away, setAway] = useState(false);
  const rootRef = useRef<HTMLParagraphElement>(null);
  const measureRefs = useRef<(HTMLSpanElement | null)[]>([]);

  const idx = st.cur % n;
  const prevIdx = st.prev === null ? null : st.prev % n;

  const advance = useCallback(() => {
    setSt((s) => ({ cur: (s.cur + 1) % n, prev: s.cur % n }));
  }, [n]);

  // Retire the exiting layer once its exit has finished.
  useEffect(() => {
    if (st.prev === null) return;
    const cur = st.cur;
    const t = window.setTimeout(() => setSt((s) => (s.cur === cur ? { cur: s.cur, prev: null } : s)), 1000);
    return () => window.clearTimeout(t);
  }, [st.cur, st.prev]);

  // Measure every word once and whenever fonts or sizes change.
  useEffect(() => {
    const els = measureRefs.current.slice(0, n).filter((e): e is HTMLSpanElement => !!e);
    if (els.length !== n) return;
    const read = () => {
      const next = els.map((e) => e.offsetWidth);
      setWidths((old) => (old && old.length === next.length && old.every((v, i) => v === next[i]) ? old : next));
    };
    const ro = new ResizeObserver(read);
    els.forEach((e) => ro.observe(e));
    let alive = true;
    if (document.fonts && document.fonts.ready) void document.fonts.ready.then(() => alive && read());
    return () => {
      alive = false;
      ro.disconnect();
    };
  }, [wordsKey, n]);

  // Sleep while off-screen or in a hidden tab.
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    let visible = true;
    const sync = () => setAway(!visible || document.hidden);
    const io = new IntersectionObserver((es) => {
      const e = es[es.length - 1];
      if (e) visible = e.isIntersecting;
      sync();
    });
    io.observe(root);
    document.addEventListener("visibilitychange", sync);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", sync);
    };
  }, []);

  const hold = away || focus || (pauseOnHover && hover);
  // The clock: a timer that keeps its remaining time across holds. (The hairline is only a picture of it.)
  const remain = useRef(dur);
  useEffect(() => {
    remain.current = dur;
  }, [st.cur, dur]);
  useEffect(() => {
    if (n < 2 || hold) return;
    const t0 = performance.now();
    const id = window.setTimeout(advance, remain.current);
    return () => {
      window.clearTimeout(id);
      remain.current = Math.max(0, remain.current - (performance.now() - t0));
    };
  }, [st.cur, hold, n, dur, advance]);

  const ready = widths !== null && widths.length === n;
  const boxStyle: CSSProperties | undefined = ready ? { width: widths[idx] } : undefined;
  const accent = palette[idx % palette.length] ?? "currentColor";

  const inner = (
    <>
      <span className="rws-s" aria-hidden="true">
        {list[0]}
      </span>
      {list.map((w, i) => {
        const state = i === idx ? "in" : i === prevIdx ? "out" : "idle";
        return (
          <span key={i} className="rws-l" data-s={state} style={{ color: palette[i % palette.length] }} aria-hidden="true">
            {grapheme(w).map((ch, j) => (
              <span key={j} className="rws-c" style={{ "--i": j } as CSSProperties}>
                {ch === " " ? NBSP : ch}
              </span>
            ))}
          </span>
        );
      })}
      {list.map((w, i) => (
        <span
          key={"m" + i}
          className="rws-m"
          aria-hidden="true"
          ref={(el) => {
            measureRefs.current[i] = el;
          }}
        >
          {w.replace(/ /g, NBSP)}
        </span>
      ))}
      {progress && n > 1 && (
        <span
          key={st.cur}
          className="rws-b"
          aria-hidden="true"
          style={{ background: accent, animationDuration: dur + "ms", animationPlayState: hold ? "paused" : "running" }}
        />
      )}
    </>
  );

  return (
    <>
    <style>{CSS}</style>
    <p
      ref={rootRef}
      className={(className ?? "text-3xl font-black") + (ready ? " rws-r" : "")}
      onPointerEnter={(e) => e.pointerType === "mouse" && setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <span className="sr-only">{prefix + " " + list.join(", ")}</span>
      <span aria-hidden="true">{prefix} </span>
      {interactive ? (
        <button
          type="button"
          className="rws-w"
          style={boxStyle}
          aria-label={"Next word, currently " + (list[idx] ?? "")}
          onClick={advance}
          onFocus={() => setFocus(true)}
          onBlur={() => setFocus(false)}
        >
          {inner}
        </button>
      ) : (
        <span className="rws-w" style={boxStyle} onClick={advance}>
          {inner}
        </span>
      )}
    </p>
    </>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this text effect

A headline where one word cycles through a list. Each word is its own layer of letter spans. When the cycle advances, the outgoing letters roll up 0.62em and fade through an 8px blur, staggered 14ms apart (420ms, accelerating ease), while the incoming letters rise from below with a 30ms stagger (capped at 330ms) on a long decelerating curve, 640ms for the movement and 360ms for the fade. Words are measured in hidden spans, and the box around the word animates its width between them over 640ms, so the text in front glides to its new position and a centred headline breathes rather than jumps. Every word takes a colour from `colors` (default red, amber, green, blue; pass [“currentColor”] to inherit). A 2px hairline under the word pictures the timer, which is a timeout that remembers its remaining time: the cycle holds while the pointer is over the headline (`pauseOnHover`), while the word has keyboard focus, while the headline is off-screen and while the tab is hidden. With `interactive` (default off, so existing headlines gain no tab stop; click or tap on the word still skips) the word is a button: click, tap, Enter or Space skips to the next word and restarts the clock. `progress={false}` hides the hairline; the timing is unchanged. `className` replaces the default text-3xl font-black. It uses plain CSS in one style element, React only, and needs no stylesheet. The prefix inherits the text colour, so it sits on light and dark pages alike. A word wider than the remaining line wraps as a whole onto the next line.

Curator’s note

Rebuilt in October without an animation library: the swap is per-letter CSS transitions, and the line closes around each new word instead of jumping.

Pairs well with

Typewriter Text

text effects

Scramble Text

text effects