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). After the quarter turn the box snaps back to 0°
* with the transition disabled — the new front face is pixel-identical to the
* landed back face, so the reset is invisible. Faces shade darker as they tip
* away from the light (a color-mix transition, safe with backface-visibility).
*
* Every word is stacked invisibly in a grid cell, so the tile reserves the
* true rendered maximum width/height — no reflow, whatever the font. `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`) 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]);
// Modulo-safe: `words` can shrink under us (live props/controls).
const current = list[index % list.length];
const next = list[(index + 1) % list.length];
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;
// Faces darken as they tip away. currentcolor here resolves to the
// inherited color, so the mix tracks any text color in both themes.
const shaded = "color-mix(in oklab, currentcolor 70%, black)";
// Snap (no transition) outside the flip so the post-flip reset — new front
// face already pixel-identical to the landed back face — is invisible.
const boxTransition = flipping
? `transform ${duration}ms var(--ease-in-out)`
: "none";
const faceTransition = flipping
? `color ${duration}ms var(--ease-in-out)`
: "none";
return (
<span
ref={ref}
data-slot="counter-flip-text"
className={cn(
"relative inline-grid align-baseline",
tabular && "tabular-nums",
className,
)}
style={{ perspective: 600, ...style }}
{...props}
>
{/* Live announcement for screen readers. */}
<span className="sr-only" aria-live="polite">
{current}
</span>
{/* Every word stacked in one grid cell reserves the true max extent. */}
{list.map((word, i) => (
<span
key={`${word}-${i}`}
aria-hidden
className="invisible col-start-1 row-start-1 whitespace-nowrap"
>
{word}
</span>
))}
<span
aria-hidden
className="relative col-start-1 row-start-1 [transform-style:preserve-3d]"
style={{
transformStyle: "preserve-3d",
transform: flipping ? `${rot}(${outSign * 90}deg)` : `${rot}(0deg)`,
transition: boxTransition,
}}
>
{/* Front face — current word, dims as it swings away. */}
<span
className="absolute inset-0 flex items-center justify-center [backface-visibility:hidden]"
style={{
backfaceVisibility: "hidden",
transform: `${rot}(0deg)`,
color: flipping ? shaded : undefined,
transition: faceTransition,
}}
>
{current}
</span>
{/* Back face — next word, pre-rotated so it lands upright, brightening. */}
<span
className="absolute inset-0 flex items-center justify-center [backface-visibility:hidden]"
style={{
backfaceVisibility: "hidden",
transform: `${rot}(${-outSign * 90}deg)`,
color: flipping ? undefined : shaded,
transition: faceTransition,
}}
>
{next}
</span>
</span>
</span>
);
}