Skip to content
UI Kit
Accent color

Rotating words

One word in a sentence takes turns, the slot glides to the width of the next word, nothing jumps. Pauses off screen and on hover.

One of 5 components in Text accents

3 variants

Variants

Slide

Inside a hero line, the word slides up and out while the next one rises.

Pill and blur

A soft accent pill holds the word, its width follows each word, the words cross through a blur.

Flip on scroll

The scroll position picks the word, each one turns over the horizontal axis. Left aligned at the end of the line.

Details

What Rotating words brings along, read from the registry and the source.

Category
Text accents
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/rotating-words.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The RotatingWordsProps interface from components/ui/rotating-words.tsx, with the comments from the code.

  • wordsrequired
    string[]

    The words that take turns, the first one is the one without JavaScript

  • effectoptional
    RotatingWordsEffect

    slideblurflip

    slide: up and out, up and in (default). blur: crossfade through a blur. flip: a short turn over the x axis.

  • intervaloptional
    number

    Milliseconds a word stays. Default 2400.

  • toneoptional
    'inherit' | 'accent'

    inherit: the text color (default). accent: kit-accent-text.

  • pilloptional
    boolean

    A soft accent pill behind the word, its width follows the word

  • triggeroptional
    'load' | 'view' | 'in-view' | 'scroll'

    view (or in-view, default) and load: the words take turns on a clock, paused off screen. scroll: the scroll position picks the word, every word gets its share of the way through the window and the first one returns at the end.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that runs through the words, 0 to 1. Default [0.04, 0.6].

  • loopoptional
    boolean

    Start over after the last word. Default true. false stops on the last word.

  • suffixoptional
    string

    Text glued right after the word that must not wrap away from it, e.g. a comma

  • labeloptional
    string

    What screen readers hear in place of the words. Default: the words joined with commas.

Accessibility

Read from the source of the component.

  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 191 lines
components/ui/rotating-words.tsx
'use client';

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';

export type RotatingWordsEffect = 'slide' | 'blur' | 'flip';

export interface RotatingWordsProps {
  /** The words that take turns, the first one is the one without JavaScript */
  words: string[];
  /** slide: up and out, up and in (default). blur: crossfade through a blur. flip: a short turn over the x axis. */
  effect?: RotatingWordsEffect;
  /** Milliseconds a word stays. Default 2400. */
  interval?: number;
  /** inherit: the text color (default). accent: kit-accent-text. */
  tone?: 'inherit' | 'accent';
  /** A soft accent pill behind the word, its width follows the word */
  pill?: boolean;
  /**
   * view (or in-view, default) and load: the words take turns on a clock,
   * paused off screen. scroll: the scroll position picks the word, every word
   * gets its share of the way through the window and the first one returns
   * at the end.
   */
  trigger?: 'load' | 'view' | 'in-view' | 'scroll';
  /** With trigger scroll: the part of the way through the window that runs through the words, 0 to 1. Default [0.04, 0.6]. */
  scrollRange?: [number, number];
  /** Start over after the last word. Default true. false stops on the last word. */
  loop?: boolean;
  /** Text glued right after the word that must not wrap away from it, e.g. a comma */
  suffix?: string;
  /** What screen readers hear in place of the words. Default: the words joined with commas. */
  label?: string;
  className?: string;
}

const CSS = `
.kitfx-rw{white-space:nowrap}
.kitfx-rw-slot{display:inline-grid;justify-items:start;align-items:baseline;box-sizing:content-box;overflow:clip;overflow-clip-margin:.25em;transition:width .55s cubic-bezier(.3,.7,.2,1);text-align:start}
.kitfx-rw[data-pill] .kitfx-rw-slot{padding-inline:.28em;margin-inline:.04em;border-radius:.24em;background:color-mix(in oklch,var(--primary) 13%,transparent);box-shadow:inset 0 0 0 1px color-mix(in oklch,var(--primary) 18%,transparent);overflow-clip-margin:0}
.kitfx-rw-word{grid-area:1/1;padding-block:.1em;margin-block:-.1em;transition:transform .6s cubic-bezier(.3,.7,.2,1),opacity .45s ease,filter .5s ease}
.kitfx-rw-word:is([data-state=out],[data-state=next]){opacity:0;pointer-events:none}
.kitfx-rw:not([data-ready]) .kitfx-rw-word:not([data-state=in]){position:absolute;left:0;top:0}
.kitfx-rw[data-effect=slide] .kitfx-rw-word[data-state=out]{transform:translateY(-.75em)}
.kitfx-rw[data-effect=slide] .kitfx-rw-word[data-state=next]{transform:translateY(.75em)}
.kitfx-rw[data-effect=blur] .kitfx-rw-word:is([data-state=out],[data-state=next]){filter:blur(8px)}
.kitfx-rw[data-effect=blur] .kitfx-rw-word[data-state=next]{transform:scale(.96)}
.kitfx-rw[data-effect=flip] .kitfx-rw-slot{perspective:4em}
.kitfx-rw[data-effect=flip] .kitfx-rw-word{transform-origin:50% 50% -.4em;backface-visibility:hidden}
.kitfx-rw[data-effect=flip] .kitfx-rw-word[data-state=out]{transform:rotateX(80deg)}
.kitfx-rw[data-effect=flip] .kitfx-rw-word[data-state=next]{transform:rotateX(-80deg)}
.kitfx-rw-word[data-state=next]{transition:none}
@media (prefers-reduced-motion:reduce){.kitfx-rw-slot,.kitfx-rw-word{transition:none}}`;

/**
 * Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
 * travelled `start` of its way through the window, 1 at `end` (0 is its top
 * at the bottom edge, 1 its bottom at the top edge). The value is scaled to
 * the part of that way the page can still scroll, so a line at the end of a
 * short page still finishes. Never with reduced motion, then the CSS
 * fallback 1 keeps the final state.
 */
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
  const [start, end] = range;
  const listener = useRef(onChange);
  listener.current = onChange;
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const span = Math.max(0.0001, end - start);
    const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
      const most = at(r.top - rest, r.height, vh);
      const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
      el.style.setProperty('--kitfx-p', p.toFixed(4));
      listener.current?.(p);
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on, start, end]);
}

/** Measuring before paint in the browser, a no-op warning free effect on the server */
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/**
 * One word in a sentence takes turns with others. The slot animates its
 * width to the next word, so the sentence glides instead of jumping. The
 * cycle pauses off screen and while the pointer rests on it. Screen readers
 * hear all words once; without JavaScript and with reduced motion the first
 * word stands still.
 *   <h1>Websites for <RotatingWords words={['law firms', 'cafés', 'studios']} /></h1>
 */
export function RotatingWords({ words, effect = 'slide', interval = 2400, trigger = 'view', scrollRange = [0.04, 0.6], tone = 'inherit', pill, loop = true, suffix, label, className }: RotatingWordsProps) {
  const root = useRef<HTMLSpanElement>(null);
  const refs = useRef<(HTMLSpanElement | null)[]>([]);
  const [index, setIndex] = useState(0);
  const [prev, setPrev] = useState<number | null>(null);
  const [widths, setWidths] = useState<number[] | null>(null);
  const [onScreen, setOnScreen] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [still, setStill] = useState(true);
  const scroll = trigger === 'scroll';
  const shown = useRef(0);
  shown.current = index;
  // Scroll: the position picks the word, the first one stands again at the end
  useScrollLink(root, scroll && words.length > 1, scrollRange, (p) => {
    const next = p >= 1 ? 0 : Math.min(words.length - 1, Math.floor(p * words.length));
    if (next === shown.current) return;
    setPrev(shown.current);
    setIndex(next);
    shown.current = next;
  });

  // Measure every word, again when fonts load or the size changes
  useIsoLayoutEffect(() => {
    // offsetWidth ignores transforms, so a scaled or flipped word never measures too narrow
    const measure = () => setWidths(refs.current.map((el) => (el ? round(el.offsetWidth, 1) : 0)));
    measure();
    const ro = new ResizeObserver(measure);
    if (root.current) ro.observe(root.current);
    document.fonts?.ready.then(measure).catch(() => {});
    return () => ro.disconnect();
  }, [words.join('|')]);

  useEffect(() => {
    if (prefersReducedMotion() || words.length < 2 || scroll) return;
    setStill(false);
    const el = root.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setOnScreen(!!e?.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, [words.length, scroll]);

  useEffect(() => {
    if (still || !onScreen || hovered) return;
    if (!loop && index >= words.length - 1) return;
    const timer = window.setTimeout(() => {
      setPrev(index);
      setIndex((index + 1) % words.length);
    }, interval);
    return () => window.clearTimeout(timer);
  }, [still, onScreen, hovered, index, loop, interval, words.length]);

  const width = widths?.[index];
  return (
    <span
      ref={root}
      className={cn('kitfx-rw', className)}
      data-effect={effect}
      data-pill={pill ? '' : undefined}
      data-ready={widths ? '' : undefined}
      onPointerEnter={() => setHovered(true)}
      onPointerLeave={() => setHovered(false)}
    >
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <span className="sr-only">{label ?? words.join(', ')}</span>
      <span aria-hidden data-clip-ok className="kitfx-rw-slot" style={width ? { width: `${width}px` } : undefined}>
        {words.map((w, i) => (
          <span
            key={i}
            ref={(el) => {
              refs.current[i] = el;
            }}
            className={cn('kitfx-rw-word', tone === 'accent' && 'kit-accent-text')}
            data-state={i === index ? 'in' : i === prev ? 'out' : 'next'}
          >
            {w}
          </span>
        ))}
      </span>
      {suffix}
    </span>
  );
}

Frequent questions

How do I install Rotating words?

With aiw add @ai-website/rotating-words. The command puts components/ui/rotating-words.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Rotating words have?

3 variants, Slide, Pill and blur and Flip on scroll.

Can an AI assistant build with Rotating words?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Rotating words with aiw_components and install it with aiw_add @ai-website/rotating-words.

Often searched

Jump to

Copied to the clipboard