Text Effects

Counter Flip Text

A 3D card that tumbles between a rotating list of words like a departures board, tabular for numbers.

Install

npx shadcn@latest add @paragon/counter-flip-text

counter-flip-text.tsx

"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>
  );
}