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
Details
What Rotating words brings along, read from the registry and the source.
- Category
- Text accents
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The RotatingWordsProps interface from components/ui/rotating-words.tsx, with the comments from the code.
wordsrequiredstring[]The words that take turns, the first one is the one without JavaScript
effectoptionalRotatingWordsEffectslideblurflip
slide: up and out, up and in (default). blur: crossfade through a blur. flip: a short turn over the x axis.
intervaloptionalnumberMilliseconds a word stays. Default 2400.
toneoptional'inherit' | 'accent'inherit: the text color (default). accent: kit-accent-text.
pilloptionalbooleanA 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].
loopoptionalbooleanStart over after the last word. Default true. false stops on the last word.
suffixoptionalstringText glued right after the word that must not wrap away from it, e.g. a comma
labeloptionalstringWhat 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
'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.