Text Animationsv0.1.0
Word Rotator
A rotating word that morphs its width.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/word-rotator.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/word-rotator/word-rotator.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type PointerEvent } from "react"; import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { duration, ease, spring } from "@/lib/motion"; export interface WordRotatorProps extends Omit<ComponentProps<"span">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Words to cycle through. */ words: string[]; /** Milliseconds each word stays. @default 2400 */ interval?: number; /** Controlled index of the visible word. */ index?: number; /** Initial index when uncontrolled. @default 0 */ defaultIndex?: number; /** Called with the next index when it's time to rotate. In controlled mode, update `index` yourself. */ onIndexChange?: (index: number) => void; /** Stop rotating. Rotation also pauses while offscreen. @default false */ paused?: boolean; /** Pause while the pointer is over the word. @default true */ pauseOnHover?: boolean; /** Where words sit while the width animates. @default "start" */ align?: "start" | "center" | "end"; } const JUSTIFY = { start: "flex-start", center: "center", end: "flex-end" } as const; export function WordRotator({ words, interval = 2400, index, defaultIndex = 0, onIndexChange, paused = false, pauseOnHover = true, align = "start", className, onPointerEnter, onPointerLeave, ...props }: WordRotatorProps) { const reduceMotion = useReducedMotion(); const [internal, setInternal] = useState(defaultIndex); const [hovered, setHovered] = useState(false); const [widths, setWidths] = useState<number[]>([]); const measureRef = useRef<HTMLSpanElement>(null); const boxRef = useRef<HTMLSpanElement>(null); const inView = useInView(boxRef, { amount: 0.5 }); const count = words.length; const current = count ? (((index ?? internal) % count) + count) % count : 0; const changeRef = useRef(onIndexChange); useEffect(() => { changeRef.current = onIndexChange; }); // Every word's width, measured from hidden copies; re-measured when fonts load or the size changes. const wordsKey = words.join("\u0000"); useEffect(() => { const box = measureRef.current; if (!box) return; const words = Array.from(box.children) as HTMLElement[]; const measure = () => setWidths(words.map((el) => el.offsetWidth)); // Observe each word: the zero-size box itself never resizes. const observer = new ResizeObserver(measure); for (const el of words) observer.observe(el); return () => observer.disconnect(); }, [wordsKey]); useEffect(() => { if (paused || (pauseOnHover && hovered) || !inView || count < 2) return; const timer = window.setTimeout(() => { const next = (current + 1) % count; if (index === undefined) setInternal(next); changeRef.current?.(next); }, interval); return () => window.clearTimeout(timer); }, [current, count, interval, paused, pauseOnHover, hovered, inView, index]); const width = widths[current]; const variants = { enter: reduceMotion ? { opacity: 0 } : { opacity: 0, y: "0.45em", filter: "blur(6px)" }, center: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.slow, ease: ease.out } }, exit: reduceMotion ? { opacity: 0, transition: { duration: duration.quick } } : { opacity: 0, y: "-0.45em", filter: "blur(6px)", transition: { duration: duration.base, ease: ease.out } }, }; return ( <span className={cn("inline-flex", className)} onPointerEnter={(event: PointerEvent<HTMLSpanElement>) => { onPointerEnter?.(event); if (event.pointerType === "mouse") setHovered(true); }} onPointerLeave={(event: PointerEvent<HTMLSpanElement>) => { onPointerLeave?.(event); setHovered(false); }} {...props} > <span className="sr-only select-none">{words.join(", ")}</span> {/* Width animates to the next word, so text after the rotator glides instead of jumping. */} <motion.span ref={boxRef} aria-hidden="true" className="relative inline-flex whitespace-nowrap" style={{ justifyContent: JUSTIFY[align], clipPath: "inset(-0.35em -0.02em)" }} initial={false} animate={width === undefined ? undefined : { width }} transition={reduceMotion ? { duration: 0 } : spring.smooth} > {/* Zero-size and clipped, so the longest word never adds layout or scroll overflow. */} <span ref={measureRef} className="pointer-events-none invisible absolute top-0 left-0 flex size-0 flex-col overflow-hidden"> {words.map((word, i) => ( <span key={i} className="w-max whitespace-nowrap"> {word} </span> ))} </span> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={current} variants={variants} initial="enter" animate="center" exit="exit" className="inline-block"> {words[current]} </motion.span> </AnimatePresence> </motion.span> </span> ); }
Usage
import { WordRotator } from "@/components/ui/word-rotator/word-rotator";
export function Hero() {
return (
<h1 className="text-4xl font-semibold">
Design systems for <WordRotator words={["startups", "agencies", "everyone"]} />
</h1>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
words* | string[] | – | Words to cycle through. |
interval | number | 2400 | Milliseconds each word stays. |
index | number | – | Controlled index of the visible word. |
defaultIndex | number | 0 | Initial index when uncontrolled. |
onIndexChange | (index: number) => void | – | Called with the next index when it's time to rotate. In controlled mode, update `index` yourself. |
paused | boolean | false | Stop rotating. Rotation also pauses while offscreen. |
pauseOnHover | boolean | true | Pause while the pointer is over the word. |
align | "start" | "center" | "end" | "start" | Where words sit while the width animates. |
Also accepts every native <span> attribute, including ref and className.
- Every word is measured from hidden copies (and re-measured when fonts load), and the width springs to the next word, so text after the rotator glides instead of jumping.
- Padding and backgrounds on className stay outside the animating box, so pill styles work as-is.
- Control it with `index` + `onIndexChange` (combine with `paused` to drive it only from your own buttons).
Accessibility
- Screen readers read every word once, from a visually hidden list; the rotating word is aria-hidden, so it isn't announced every few seconds.
- Rotation pauses while hovered and while offscreen; pass `paused` to stop it, e.g. from a pause control.
- With reduced motion enabled, words crossfade and the width changes instantly.