A 3D card that tumbles between a rotating list of words like a departures board, tabular for numbers.
npx shadcn@latest add @paragon/counter-flip-text"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface CounterFlipTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The rotating list of words/values shown one at a time. */
words: string[];
/** Milliseconds each word stays face-up before flipping. */
interval?: number;
/** Flip duration in milliseconds. */
duration?: number;
/** Flip axis. `x` tumbles vertically (default), `y` spins horizontally. */
axis?: "x" | "y";
/** Use tabular figures — align digits when the words are numeric. */
tabular?: boolean;
/** Render only the first word with no motion. */
static?: boolean;
}
/**
* CounterFlipText — a single 3D card that flips between a rotating list of
* words. The card is a `preserve-3d` box: the current face reads flat, the next
* face waits rotated 90° behind it, and on each tick the box rotates a quarter
* turn so the incoming word swings up into place (like a mechanical counter or
* a departures board tile).
*
* The board sizes itself to the widest word so the line never reflows.
* `tabular` locks digits to equal advance for numeric values. The live word is
* announced to screen readers; the tumbling face is decorative. Reduced motion
* (or `static`) cross-fades — or just shows the first word — instead of
* tumbling. Pauses while offscreen.
*/
export function CounterFlipText({
words,
interval = 2200,
duration = 620,
axis = "x",
tabular = false,
static: isStatic = false,
className,
style,
...props
}: CounterFlipTextProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { amount: 0.4 });
const reducedMotion = useReducedMotion() ?? false;
const list = words.length ? words : [""];
const [index, setIndex] = React.useState(0);
const [flipping, setFlipping] = React.useState(false);
const animated = !isStatic && !reducedMotion;
React.useEffect(() => {
if (!animated || !inView || list.length < 2) return;
let flipTimer: ReturnType<typeof setTimeout>;
const tick = setInterval(() => {
setFlipping(true);
flipTimer = setTimeout(() => {
setIndex((i) => (i + 1) % list.length);
setFlipping(false);
}, duration);
}, interval);
return () => {
clearInterval(tick);
clearTimeout(flipTimer);
};
}, [animated, inView, list.length, interval, duration]);
const current = list[index];
const next = list[(index + 1) % list.length];
// Reserve the widest word so the tile never resizes mid-flip.
const widest = React.useMemo(
() => list.reduce((a, b) => (b.length > a.length ? b : a), ""),
[list],
);
if (isStatic || reducedMotion) {
return (
<span
ref={ref}
data-slot="counter-flip-text"
className={cn("inline-block", tabular && "tabular-nums", className)}
style={style}
aria-live="polite"
{...props}
>
{list[0]}
</span>
);
}
const rot = axis === "x" ? "rotateX" : "rotateY";
const outSign = axis === "x" ? -1 : 1;
return (
<span
ref={ref}
data-slot="counter-flip-text"
className={cn("relative inline-block align-baseline", className)}
style={{ perspective: 600, ...style }}
{...props}
>
{/* Screen-reader + width reservation. */}
<span className="sr-only" aria-live="polite">
{current}
</span>
<span
aria-hidden
className={cn("invisible inline-block", tabular && "tabular-nums")}
>
{widest}
</span>
<span
aria-hidden
className={cn(
"absolute inset-0 inline-block [transform-style:preserve-3d]",
tabular && "tabular-nums",
)}
style={{
transformStyle: "preserve-3d",
transform: flipping ? `${rot}(${outSign * 90}deg)` : `${rot}(0deg)`,
transition: `transform ${duration}ms var(--ease-in-out)`,
}}
>
{/* Front face — current word. */}
<span
className="absolute inset-0 flex items-center justify-center [backface-visibility:hidden]"
style={{ backfaceVisibility: "hidden", transform: `${rot}(0deg)` }}
>
{current}
</span>
{/* Back face — next word, pre-rotated so it lands upright. */}
<span
className="absolute inset-0 flex items-center justify-center [backface-visibility:hidden]"
style={{
backfaceVisibility: "hidden",
transform: `${rot}(${-outSign * 90}deg)`,
}}
>
{next}
</span>
</span>
</span>
);
}