Counter Flip Text
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). 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>
  );
}