Rotates one word inside a sentence with a direction-aware rise enter and half-duration exit.
npx shadcn@latest add @paragon/text-loop"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { cn } from "@/lib/utils";
/** Mirrors var(--ease-out) — motion transition arrays can't read CSS custom properties. */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Mirrors var(--ease-exit). */
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
const ENTER_DURATION = 0.3;
/** Exits run at half the enter so the old word never fights for attention. */
const EXIT_DURATION = ENTER_DURATION / 2;
const RISE_DISTANCE = 12;
export interface TextLoopProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The words to rotate through, in order. */
children: string[];
/** Seconds each word holds before rotating to the next. */
interval?: number;
/**
* Travel direction. "up" (default) enters from below and exits above;
* "down" reverses both so the stack reads as scrolling the other way.
*/
direction?: "up" | "down";
/** Swaps words instantly, without motion. */
static?: boolean;
}
/**
* Rotates one word inside a sentence using the house enter/exit language:
* the incoming word rises in (opacity, translateY, blur, ease-out) while the
* outgoing word exits the opposite way at half duration with an exit ease.
*
* Layout stability comes from an inline-grid: every word is rendered as an
* invisible sizing span stacked in the same grid cell, so the container is
* always as wide as the longest word and the surrounding sentence never
* jumps — no measurement, no width animation, correct on first paint.
*
* The loop pauses offscreen (useInView) and skips ticks in hidden tabs.
*/
export function TextLoop({
children,
interval = 2.5,
direction = "up",
static: isStatic = false,
className,
...props
}: TextLoopProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref);
const reducedMotion = useReducedMotion();
const [index, setIndex] = React.useState(0);
const count = children.length;
React.useEffect(() => {
if (count < 2 || !inView) return;
const id = setInterval(() => {
// Skip ticks while the tab is hidden so the loop resumes in place.
if (document.hidden) return;
setIndex((i) => (i + 1) % count);
}, interval * 1000);
return () => clearInterval(id);
}, [count, interval, inView]);
if (count === 0) return null;
const word = children[index % count];
const enterY = direction === "up" ? RISE_DISTANCE : -RISE_DISTANCE;
const exitY = -enterY;
return (
<span
ref={ref}
className={cn(
"relative inline-grid items-baseline whitespace-nowrap",
className,
)}
{...props}
>
{/* Stable text for assistive tech — the animated layer briefly holds
both the old and new word during the swap. */}
<span className="sr-only">{word}</span>
{/* Invisible sizing spans: the widest word sets the cell width, so the
sentence around the loop never reflows. */}
{children.map((w, i) => (
<span
key={`${w}-${i}`}
aria-hidden
className="invisible [grid-area:1/1] whitespace-nowrap"
>
{w}
</span>
))}
{isStatic ? (
<span aria-hidden className="[grid-area:1/1]">
{word}
</span>
) : (
<AnimatePresence initial={false}>
<motion.span
key={index}
aria-hidden
className="[grid-area:1/1]"
initial={{
opacity: 0,
y: reducedMotion ? 0 : enterY,
filter: reducedMotion ? "blur(0px)" : "blur(4px)",
}}
animate={{
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: ENTER_DURATION, ease: EASE_OUT },
}}
exit={{
opacity: 0,
y: reducedMotion ? 0 : exitY,
filter: reducedMotion ? "blur(0px)" : "blur(4px)",
transition: { duration: EXIT_DURATION, ease: EASE_EXIT },
}}
>
{word}
</motion.span>
</AnimatePresence>
)}
</span>
);
}