Swish UI
All componentsText Free

Word rotate

One word in a sentence slides up and out while the next slides in, and the gap resizes to fit it.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const WORDS = ["long weekends", "big families", "slow mornings"];

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const slot = useRef<HTMLSpanElement>(null);
  const words = useRef<(HTMLSpanElement | null)[]>([]);
  const [index, setIndex] = useState(0);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();

  useEffect(() => {
    if (!inView || reduced) return;
    const id = window.setInterval(() => setIndex((i) => (i + 1) % WORDS.length), 2600);
    return () => clearInterval(id);
  }, [inView, reduced]);

  useLayoutEffect(() => {
    const fit = () => {
      const word = words.current[index];
      if (word && slot.current) slot.current.style.width = `${word.offsetWidth}px`;
    };
    fit();
    const raf = requestAnimationFrame(() => slot.current?.setAttribute("data-ready", ""));
    const observer = new ResizeObserver(fit);
    observer.observe(root.current!);
    document.fonts?.ready.then(fit);
    return () => {
      cancelAnimationFrame(raf);
      observer.disconnect();
    };
  }, [index]);

  const prev = (index - 1 + WORDS.length) % WORDS.length;

  return (
    <div ref={root} className={styles.root}>
      <h2 className={styles.title} aria-label={`Villas made for ${WORDS.slice(0, -1).join(", ")} and ${WORDS.at(-1)}`}>
        <span aria-hidden="true">
          Villas made for{" "}
          <span ref={slot} className={styles.slot}>
            {WORDS.map((word, i) => (
              <span
                key={word}
                ref={(el) => { words.current[i] = el; }}
                className={`${styles.word} ${i === index ? styles.current : i === prev ? styles.out : ""}`}
              >
                {word}
              </span>
            ))}
          </span>
        </span>
      </h2>
      <p className={styles.sub}>Private pools, full kitchens, a host on call.</p>
    </div>
  );
}

demo.module.css

.root { container-type: size; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 20px; text-align: center; }
.title { font-size: clamp(26px, min(7cqi, 12cqh), 56px); line-height: 1.2; letter-spacing: -0.03em; font-weight: 650; }
.slot { position: relative; display: inline-block; height: 1.2em; vertical-align: top; overflow: hidden; text-align: left; }
.slot[data-ready] { transition: width 0.5s var(--out); }
/* Before hydration the first word sits in flow so server HTML shows it at its own width */
.slot:not([data-ready]) .current { position: static; display: block; }
.word { position: absolute; left: 0; top: 0; white-space: nowrap; color: var(--accent-text); translate: 0 100%; opacity: 0; }
.current { translate: 0 0; opacity: 1; transition: translate 0.55s var(--out), opacity 0.3s; }
.out { translate: 0 -100%; opacity: 0; transition: translate 0.55s var(--out), opacity 0.4s; }
.sub { font-size: 14px; color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
  .slot[data-ready], .current, .out { transition: none; }
}

Use it for

  • Showing two or three audiences for the same product
  • A secondary line under a clear, static headline

Avoid

  • Hero headlines: it is one of the most overused effects and reads as a template
  • Key information, since most visitors only ever see the first word
  • More than three options

How it works

The words are stacked absolutely inside an inline-block slot with overflow hidden. The current word sits at translate 0, the previous one slides to minus 100 percent and the rest wait below at 100 percent with no transition, so they jump back unseen. After each change the slot width is set to the incoming word's offsetWidth and a width transition lets the sentence reflow smoothly. A ResizeObserver refits it when the font size changes.

Accessibility

The heading has an aria-label with the full sentence and every option, and the moving words are aria-hidden. The interval only runs while the demo is in view. Reduced motion keeps the first word and never rotates.

Found on