Word rotator
Sentence with one slot that cycles through a list of words by sliding, flipping or blurring them in place.
WordRotator, slide
Installation
npx shadcn add https://www.reelcn.dev/r/word-rotator.jsonFiles land in src/reelcn/ with the items it builds on.
Usage
<Center>
<WordRotator before="Made for" words={["creators", "startups", "agencies"]} effect="slide" />
</Center>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.
Frames each word rests before the next one arrives. Defaults to 1s. duration sets the swap length.beforestringText before the slot.
words*string[]Words cycled through the slot.
afterstringText after the slot.
effectWordRotatorEffect Default
"slide".fit"word" | "widest"
widest: the slot always fits the widest word, so the sentence never moves. word: it eases to each word's width. Default "widest".loopbooleanStart over after the last word.
false stops on it. Default true.sizenumberFont size in design units. Default
96.weightnumber
font"mono" | "heading" | "body" Default
"heading".colorstring
accentColorstringColor of the rotating words. Defaults to the theme accent.
align"left" | "right" | "center" Default
"center".styleCSSProperties
classNamestring
Built from
Use
- Taglines that list audiences, platforms or benefits
- Hooks such as "Made for creators / startups / agencies"
Avoid
- Emphasizing one fixed phrase. Use
highlight
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/word-rotator.tsx
/**
* @title Word Rotator
* @category text
* @description Sentence with one slot that cycles through a list of words by sliding, flipping or blurring them in place.
* @duration sustained
* @use Taglines that list audiences, platforms or benefits
* @use Hooks such as "Made for creators / startups / agencies"
* @avoid Emphasizing one fixed phrase. Use `highlight`
* @tags rotate, cycle, swap, words, tagline
* @example
* <Center>
* <WordRotator before="Made for" words={["creators", "startups", "agencies"]} effect="slide" />
* </Center>
*/
import type React from "react";
import {
clamp01,
graphemes,
type MotionProps,
measurePx,
tween,
useMotion,
useTextMetrics,
useTheme,
useViewport,
} from "./core";
export type WordRotatorEffect = "slide" | "flip" | "blur";
export type WordRotatorProps = MotionProps & {
/** Text before the slot. */
before?: string;
/** Words cycled through the slot. */
words: string[];
/** Text after the slot. */
after?: string;
/** Frames each word rests before the next one arrives. Defaults to 1s. `duration` sets the swap length. */
holdFrames?: number;
effect?: WordRotatorEffect;
/** `widest`: the slot always fits the widest word, so the sentence never moves. `word`: it eases to each word's width. */
fit?: "widest" | "word";
/** Start over after the last word. `false` stops on it. */
loop?: boolean;
/** Font size in design units. */
size?: number;
weight?: number;
font?: "heading" | "body" | "mono";
color?: string;
/** Color of the rotating words. Defaults to the theme accent. */
accentColor?: string;
align?: "left" | "center" | "right";
style?: React.CSSProperties;
className?: string;
};
/** A word at swap progress `p`: the arriving word goes from offset 1 to 0, the leaving one from 0 to -1. */
function wordStyle(effect: WordRotatorEffect, p: number, arriving: boolean, fontPx: number): React.CSSProperties {
const offset = arriving ? 1 - p : -p;
const opacity = Math.min(Math.max(1 - Math.abs(offset), 0), 1);
switch (effect) {
case "slide":
return { translate: `0 ${offset * 105}%` };
case "flip":
return {
opacity,
transform: `perspective(${fontPx * 4}px) rotateX(${-offset * 90}deg)`,
backfaceVisibility: "hidden",
};
case "blur":
return { opacity, filter: `blur(${Math.abs(offset) * fontPx * 0.12}px)`, translate: `0 ${offset * 0.25}em` };
}
}
export function WordRotator({
before,
words,
after,
holdFrames,
effect = "slide",
fit = "widest",
loop = true,
size = 96,
weight,
font = "heading",
color,
accentColor,
align = "center",
style,
className,
...motion
}: WordRotatorProps) {
const theme = useTheme();
const { u, width, safe } = useViewport();
const m = useMotion(motion);
const fontPx = u(size);
const cycle = (holdFrames ?? m.fps) + m.enterFrames;
const elapsed = Math.max(0, Math.floor((m.frame - m.delay) / cycle));
const step = loop ? elapsed : Math.min(elapsed, words.length - 1);
const current = step % words.length;
const previous = step > 0 ? (step - 1) % words.length : -1;
const swapStart = m.delay + step * cycle;
// The leaving word goes first on an ease-in; the arriving one follows on the theme's motion, a beat later,
// so the two are never both half-legible. The very first word has nothing to wait for.
const lead = step > 0 ? Math.round(m.enterFrames * 0.3) : 0;
const pIn = tween(m.frame, m.fps, { from: swapStart + lead, duration: m.enterFrames - lead, motion: m.preset });
const pOut = clamp01((m.frame - swapStart) / Math.max(m.enterFrames * 0.55, 1)) ** 2;
const styleFor = (index: number): React.CSSProperties => {
if (index === current) return wordStyle(effect, pIn, true, fontPx);
if (index === previous && previous !== current) return wordStyle(effect, pOut, false, fontPx);
return { visibility: "hidden" };
};
const fontWeight = weight ?? (font === "heading" ? theme.headingWeight : 500);
const fontString = `${fontWeight} ${fontPx}px ${theme.fonts[font]}`;
const gate = useTextMetrics(
words.join(" "),
{ fontFamily: theme.fonts[font], fontSize: fontPx, fontWeight },
{
skip: fit !== "word",
},
);
const tracking = font === "mono" ? 0 : -0.025 * fontPx;
const widthOf = (index: number) =>
index < 0 ? 0 : measurePx(words[index], fontString) + tracking * graphemes(words[index]).length;
const slotWidth =
fit === "word" && gate.ready
? widthOf(previous < 0 ? current : previous) +
(widthOf(current) - widthOf(previous < 0 ? current : previous)) * clamp01(pIn)
: undefined;
return (
<div
className={className}
style={{
fontFamily: theme.fonts[font],
fontSize: fontPx,
fontWeight,
color: color ?? theme.colors.foreground,
lineHeight: 1.1,
letterSpacing: font === "mono" ? 0 : "-0.025em",
textAlign: align,
textWrap: "balance",
maxWidth: width - safe.x * 2,
opacity: m.presence,
translate: `0 ${-m.exit * fontPx * 0.2}px`,
...style,
}}
>
{before ? `${before} ` : null}
{/* Every word sits in the same grid cell, so the slot is natively as wide as the widest one. */}
<span
style={{
display: "inline-grid",
justifyItems: "start",
width: slotWidth,
color: accentColor ?? theme.colors.accent,
// Sliding words are clipped to the line; the padding keeps descenders and overhangs from being cut.
...(effect === "slide" && {
overflow: "hidden",
verticalAlign: "top",
padding: "0 0.06em 0.12em",
margin: "0 -0.06em -0.12em",
}),
}}
>
{words.map((word, index) => (
<span key={index} style={{ gridArea: "1 / 1", whiteSpace: "nowrap", ...styleFor(index) }}>
{word}
</span>
))}
</span>
{after ? ` ${after}` : null}
</div>
);
}