effects
Word Rotate
An inline rotating word that swaps through a list via a spring-driven vertical slide — the outgoing word slides up and out while the incoming word slides in from below. The slot smoothly interpolates to each new word's measured width (Motion layout animation) so the surrounding sentence never jumps, and a transient --primary color flash on enter marks the swap. Inherits parent font size and weight, gates on IntersectionObserver, and pauses on hover. Reduced motion degrades to an instant content cut (no slide, fade, or color flash).
Make it faster
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/word-rotateUsage
Usagetsx
<WordRotate words={[]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| words* | string[] | — | Ordered list of words to cycle through. Min 2 items. |
| interval | number | 2500 | Milliseconds between word swaps. Default: 2500. |
| className | string | Additional classes merged onto the inline wrapper span. | |
| motionProps | MotionProps | — | Escape hatch merged into the motion.span carrying each word, to override easing/duration (e.g. a custom `transition`). Spread BEFORE the internal props, so `variants`/`initial`/`animate`/`exit`/`className` always win and cannot be clobbered. |
| loop | boolean | true | Cycle back to the first word after the last. Default: true. |
Source
word-rotate.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import {
AnimatePresence,
motion,
useReducedMotion,
type MotionProps,
type Variants,
} from "motion/react";
import styles from "./word-rotate.module.css";
export type WordRotateProps = {
/** Ordered list of words to cycle through. Min 2 items. */
words: string[];
/** Milliseconds between word swaps. Default: 2500. */
interval?: number;
/** Additional classes merged onto the inline wrapper span. */
className?: string;
/**
* Escape hatch merged into the motion.span carrying each word, to override
* easing/duration (e.g. a custom `transition`). Spread BEFORE the internal
* props, so `variants`/`initial`/`animate`/`exit`/`className` always win and
* cannot be clobbered.
*/
motionProps?: Partial<MotionProps>;
/** Cycle back to the first word after the last. Default: true. */
loop?: boolean;
};
// Motion JS timings are SECONDS — NOT the --duration CSS token (that token is for
// CSS UI transitions ~300ms). These bespoke swap timings are legitimate numeric literals.
const ENTER_SPRING = { type: "spring" as const, stiffness: 280, damping: 24 };
const EXIT_TWEEN = { type: "tween" as const, ease: "easeIn" as const, duration: 0.18 };
const slideVariants: Variants = {
initial: { y: "100%", opacity: 0 },
animate: {
y: 0,
opacity: 1,
// Color flash: --foreground -> --primary -> --foreground over the enter phase.
// Intentional one-off: --primary is a background token used momentarily as a text
// color to mark the swap. Suppressed under reduced motion (see staticVariants).
color: ["var(--foreground)", "var(--primary)", "var(--foreground)"],
transition: {
y: ENTER_SPRING,
opacity: { duration: 0.26 },
color: { duration: 0.2, times: [0, 0.5, 1] },
},
},
exit: {
y: "-100%",
opacity: 0,
transition: { y: EXIT_TWEEN, opacity: { duration: 0.18 } },
},
};
// Reduced motion: no translate, no color flash, no fade — the swap is an instant
// content cut. Per the golden contract, reduced-motion users render the static
// base, never an animated layer.
const staticVariants: Variants = {
initial: { opacity: 1 },
animate: { opacity: 1 },
exit: { opacity: 1 },
};
export function WordRotate({
words,
interval = 2500,
className = "",
motionProps,
loop = true,
}: WordRotateProps) {
const [index, setIndex] = useState(0);
const [mounted, setMounted] = useState(false);
const [inView, setInView] = useState(false);
const [paused, setPaused] = useState(false);
const slotRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
// IntersectionObserver gate — rotation only starts once the slot is on screen.
useEffect(() => {
const node = slotRef.current;
if (!node || typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ threshold: 0 },
);
observer.observe(node);
return () => observer.disconnect();
}, [mounted]);
// Rotation timer. Resetting on every `index` change is the stagger guard:
// a new swap can't fire mid-transition because the timer restarts each step.
useEffect(() => {
if (!mounted || !inView || paused) return;
if (words.length < 2) return;
if (!loop && index === words.length - 1) return;
const id = window.setTimeout(() => {
setIndex((i) => (i + 1) % words.length);
}, interval);
return () => window.clearTimeout(id);
}, [mounted, inView, paused, index, interval, loop, words.length]);
const current = words[index] ?? words[0] ?? "";
const variants = reduce ? staticVariants : slideVariants;
// Static base for SSR + first client render (mounted=false) => byte-identical, no #418.
if (!mounted) {
return (
<span
ref={slotRef}
role="text"
className={[styles.slot, className].filter(Boolean).join(" ")}
>
<span className={styles.word}>{current}</span>
</span>
);
}
return (
<motion.span
ref={slotRef}
role="text"
layout={!reduce}
transition={ENTER_SPRING}
aria-live="polite"
aria-atomic="true"
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
className={[styles.slot, className].filter(Boolean).join(" ")}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={current}
{...motionProps}
className={styles.word}
variants={variants}
initial="initial"
animate="animate"
exit="exit"
>
{current}
</motion.span>
</AnimatePresence>
</motion.span>
);
}
Dependencies
- motion
- @bottega/tokens