Word swap
One word in a sentence swaps through a list, and the sentence never reflows.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/word-swap.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/word-swap
A muted sentence ending in a word that walks through a list. Hover it to hold one.
Usage
import { WordSwap } from "@/components/ui/word-swap"
<h2>
Interfaces that feel <WordSwap words={["calm", "quick", "honest"]} />
</h2>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| words | string[] | The words to walk through, required. The first is the still state and the accessible text. | |
| hold | number | 1.8 | Seconds each word rests. |
| duration | number | 0.6 | Seconds a swap takes. |
Source
word-swap/word-swap.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP);
const MOTION = "(prefers-reduced-motion: no-preference)";
const POINTER = "(hover: hover) and (pointer: fine)";
/** A word's travel in percent of its height: its own line, its descender and the mask's 0.16em bleed. */
const TRAVEL = 140;
type WordSwapProps = Omit<React.ComponentProps<"span">, "children"> & {
/** The words to walk through. The first is the still state and what screen readers get. */
words: string[];
/** Seconds each word rests. */
hold?: number;
/** Seconds a swap takes. */
duration?: number;
};
/**
* One word in a sentence swaps through a list behind a short vertical mask. Every word sits in the same
* grid cell, so the slot is as wide as the longest word with no measuring and the sentence never
* reflows. A swap always finishes: pausing only stops the next one, so no word is left half way.
*/
function WordSwap({ words, hold = 1.8, duration = 0.6, className, ...props }: WordSwapProps) {
const root = React.useRef<HTMLSpanElement>(null);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(MOTION, (_, contextSafe) => {
const element = root.current;
const items = gsap.utils.toArray<HTMLElement>("[data-slot=word-swap-word]");
if (!element || !contextSafe || items.length < 2) return;
let index = 0;
let hovered = false;
let focused = false;
let visible = false;
let waiting = false;
let timer: gsap.core.Tween | undefined;
const swap = contextSafe(() => {
if (hovered || focused || !visible) {
waiting = true;
return;
}
const next = (index + 1) % items.length;
const leaving = items[index];
gsap.set(items[next], { visibility: "visible", yPercent: TRAVEL });
gsap.to(items[next], { yPercent: 0, duration, ease: "power3.inOut" });
gsap.to(leaving, {
yPercent: -TRAVEL,
duration,
ease: "power3.inOut",
onComplete: () => void gsap.set(leaving, { visibility: "hidden" }),
});
index = next;
timer = gsap.delayedCall(duration + hold, swap);
}) as () => void;
const resume = () => {
if (!waiting || hovered || focused || !visible) return;
waiting = false;
timer = gsap.delayedCall(hold / 2, swap);
};
const watcher = new IntersectionObserver((entries) => {
visible = entries[entries.length - 1].isIntersecting;
resume();
});
watcher.observe(element);
timer = gsap.delayedCall(hold, swap);
const onEnter = () => {
hovered = true;
};
const onLeave = () => {
hovered = false;
resume();
};
const onFocus = () => {
focused = element.matches(":focus-visible");
};
const onBlur = () => {
focused = false;
resume();
};
if (window.matchMedia(POINTER).matches) {
element.addEventListener("pointerenter", onEnter);
element.addEventListener("pointerleave", onLeave);
}
element.addEventListener("focus", onFocus);
element.addEventListener("blur", onBlur);
return () => {
timer?.kill();
watcher.disconnect();
element.removeEventListener("pointerenter", onEnter);
element.removeEventListener("pointerleave", onLeave);
element.removeEventListener("focus", onFocus);
element.removeEventListener("blur", onBlur);
};
});
return () => media.revert();
},
{ scope: root, dependencies: [words.join(" "), hold, duration] },
);
return (
<span
ref={root}
data-slot="word-swap"
tabIndex={0}
className={cn("relative -my-[0.16em] inline-grid overflow-clip py-[0.16em] whitespace-nowrap", className)}
{...props}
>
<span className="sr-only">{words[0]}</span>
{words.map((word, i) => (
<span
key={word}
aria-hidden="true"
data-slot="word-swap-word"
className={cn("select-none [grid-area:1/1]", i > 0 && "invisible")}
>
{word}
</span>
))}
</span>
);
}
export { WordSwap };
For AI agents
Hand your agent this page, the markdown at /components/word-swap/prompt.md, or the Copy AI prompt button above.
When to use
- A hero sentence with several true endings
- A word at the end of a line, where the slot's spare width falls off the edge
- A few short words of similar length
When not to use
- In the middle of a centred line: the slot keeps the longest word's width
- Words people must read to act
- More than five or six words, nobody waits for them
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- It is an inline span, so it sits inside your own heading and takes on its type. Every word shares one grid cell, so the slot holds the longest word's width before any script runs
- Screen readers get the sentence with the first word, the visible words are aria-hidden and aria-live stays off
- It pauses off screen, on hover and on keyboard focus, which is why it is a tab stop. A swap in progress finishes first, so a word is never left half way
- Under reduced motion only the first word shows. Words must be unique, they are the React keys
The maths
- The slot is a one-cell grid, so its width is the widest word with no measuring.
- A word travels 140 percent of its height: 100 for its line, 16 for the mask's bleed and 24 for the descender.
- A full cycle is n × (hold + duration), 9.6 seconds for four words at the defaults.