Static prefix followed by a slot that springs through a list of accent-colored words on an interval, reserving the widest word's width so nothing around it shifts.
Preview
Customize
4 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add rotating-wordsWrites components/ui/rotating-words.tsx and components/styles/rotating-words.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { RotatingWords } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <RotatingWords />6 );7}Props
Accessibility
A visually hidden span reads the prefix and full word list once; the animated slot is aria-hidden. The slot width is reserved by invisible sizer copies, preventing layout shift. Under prefers-reduced-motion the interval is not started and the first word is shown statically.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/rotating-words.module.css";6 7export interface RotatingWordsProps {8 /** Static text rendered before the rotating word */9 prefix?: string;10 /** Words cycled in place */11 words?: string[];12 /** Milliseconds each word stays visible */13 interval?: number;14 /** Color of the rotating word */15 accentColor?: string;16 /** Optional className */17 className?: string;18}19 20const DEFAULT_WORDS = ["faster", "smoother", "bolder", "today"];21 22export function RotatingWords({23 prefix = "Ship",24 words = DEFAULT_WORDS,25 interval = 2000,26 accentColor = "#38bdf8",27 className = "",28}: RotatingWordsProps) {29 const prefersReducedMotion = useReducedMotion();30 const list = words.length > 0 ? words : DEFAULT_WORDS;31 const [index, setIndex] = React.useState(0);32 33 React.useEffect(() => {34 if (prefersReducedMotion || list.length < 2) return;35 const id = setInterval(() => setIndex((n) => (n + 1) % list.length), interval);36 return () => clearInterval(id);37 }, [list.length, interval, prefersReducedMotion]);38 39 const current = list[index % list.length];40 const label = `${prefix ? `${prefix} ` : ""}${list.join(", ")}`;41 42 return (43 <span44 style={{ "--mf-rw-accent": accentColor } as React.CSSProperties}45 className={`${styles.root} ${className}`.trim()}46 >47 <span className={styles.srOnly}>{label}</span>48 <span aria-hidden="true" className={styles.visual}>49 {prefix ? <span className={styles.prefix}>{prefix}</span> : null}50 <span className={styles.slot}>51 {list.map((word, i) => (52 <span key={`sizer-${i}`} className={styles.sizer}>53 {word}54 </span>55 ))}56 <AnimatePresence mode="wait" initial={false}>57 <motion.span58 key={`${index}-${current}`}59 className={styles.word}60 initial={prefersReducedMotion ? false : { y: "100%", opacity: 0 }}61 animate={{ y: 0, opacity: 1 }}62 exit={prefersReducedMotion ? undefined : { y: "-100%", opacity: 0 }}63 transition={{ type: "spring", stiffness: 300, damping: 25 }}64 >65 {current}66 </motion.span>67 </AnimatePresence>68 </span>69 </span>70 </span>71 );72}