Text Effects

Split Flap

Airport-board style display where each character flips through intermediates before settling on the target.

Install

npx shadcn@latest add @paragon/split-flap

split-flap.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

/** Drum order — each cell walks this ring toward its target character. */
const DRUM = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:-+/&%$#";

/**
 * The flap sequence for one cell: intermediate characters stepped through
 * before landing on the target. Walks the drum ring from the current
 * character toward the target; long distances are sampled down to `maxFlips`
 * evenly spaced stops so the drum reads as spinning fast. Deterministic —
 * no randomness, so replays are identical.
 */
function buildPath(from: string, to: string, maxFlips: number): string[] {
  if (from === to) return [];
  const n = DRUM.length;
  const fromIndex = DRUM.indexOf(from.toUpperCase());
  const toIndex = DRUM.indexOf(to.toUpperCase());
  const start = fromIndex === -1 ? 0 : fromIndex;
  if (toIndex === -1) {
    // Target is off the drum — two quick intermediates, then land on it.
    return [DRUM[(start + 7) % n], DRUM[(start + 15) % n], to];
  }
  const distance = (toIndex - start + n) % n || n;
  const steps = Math.min(distance, Math.max(1, maxFlips));
  const path: string[] = [];
  for (let s = 1; s <= steps; s++) {
    const index = (start + Math.round((distance * s) / steps)) % n;
    // Land on the exact target so original casing is preserved.
    path.push(s === steps ? to : DRUM[index]);
  }
  return path;
}

interface Cell {
  char: string;
  /** Increments on every character change — keys the flap element so the
   * flip animation replays per swap. */
  flip: number;
}

export interface SplitFlapProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** Text the board settles on. Changing it re-flips only the cells that differ. */
  value: string;
  /** Fixed cell count — pads with blank flaps (or trims) so width never shifts between values. */
  length?: number;
  /** Which edge the text sits against when `length` pads the board. */
  align?: "left" | "right";
  /** Milliseconds per flap step. */
  flipMs?: number;
  /** Milliseconds between neighboring cells starting to flip. House range 50–100. */
  stagger?: number;
  /** Maximum intermediate flaps per cell before it settles. */
  maxFlips?: number;
  /** Milliseconds before the first cell starts — useful for staggering boards. */
  delay?: number;
  /** Render the value immediately with no motion. */
  static?: boolean;
}

/**
 * Airport split-flap board: each character cell flips (3D rotateX) through
 * intermediate characters before settling on its target, with a 50–100ms
 * stagger across cells. Cells are fixed-width monospace tiles, so the layout
 * never shifts mid-flip. Starts when scrolled into view; value changes
 * mid-flight retarget from whatever is currently showing.
 *
 * The full string stays available to screen readers; the flapping cells are
 * decorative. Reduced motion swaps characters straight to the target with a
 * plain crossfade.
 */
export function SplitFlap({
  value,
  length,
  align = "left",
  flipMs = 70,
  stagger = 60,
  maxFlips = 7,
  delay = 0,
  static: isStatic = false,
  className,
  ...props
}: SplitFlapProps) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const reducedMotion = useReducedMotion() ?? false;
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });

  const target = React.useMemo(() => {
    let text = value;
    if (length !== undefined) {
      text = text.slice(0, length);
      text =
        align === "right"
          ? text.padStart(length, " ")
          : text.padEnd(length, " ");
    }
    return text.split("");
  }, [value, length, align]);

  const [cells, setCells] = React.useState<Cell[]>(() =>
    target.map(() => ({ char: " ", flip: 0 })),
  );
  // Mirror of what's on the board right now, readable synchronously inside
  // the effect so a mid-flight value change retargets from the live state.
  const displayRef = React.useRef<string[]>(target.map(() => " "));

  const targetKey = target.join("");
  React.useEffect(() => {
    if (isStatic || !inView) return;

    const current = target.map((_, i) => displayRef.current[i] ?? " ");
    displayRef.current = current;

    // Cell count changed (value grew or shrank) — resize without animating.
    setCells((prev) =>
      prev.length === target.length
        ? prev
        : target.map((_, i) => prev[i] ?? { char: " ", flip: 0 }),
    );

    if (reducedMotion) {
      // Plain crossfade: one swap straight to the target, no intermediates.
      displayRef.current = target.slice();
      setCells((prev) =>
        target.map((char, i) => {
          const cell = prev[i];
          return cell && cell.char === char
            ? cell
            : { char, flip: (cell?.flip ?? 0) + 1 };
        }),
      );
      return;
    }

    const timers: number[] = [];
    target.forEach((toChar, i) => {
      const path = buildPath(current[i], toChar, maxFlips);
      path.forEach((char, step) => {
        timers.push(
          window.setTimeout(
            () => {
              displayRef.current[i] = char;
              setCells((prev) =>
                prev.map((cell, j) =>
                  j === i ? { char, flip: cell.flip + 1 } : cell,
                ),
              );
            },
            delay + i * stagger + step * flipMs,
          ),
        );
      });
    });
    return () => timers.forEach((timer) => window.clearTimeout(timer));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [targetKey, inView, reducedMotion, isStatic, flipMs, stagger, maxFlips, delay]);

  const shown: Cell[] = isStatic
    ? target.map((char) => ({ char, flip: 0 }))
    : cells;

  return (
    <span
      ref={ref}
      data-slot="split-flap"
      data-split-flap=""
      className={cn(
        "inline-flex items-center font-mono tabular-nums",
        className,
      )}
      {...props}
    >
      <style href="paragon-split-flap" precedence="paragon">{`
        @keyframes pg-flap {
          from { transform: rotateX(90deg); opacity: 0.25; }
          to { transform: rotateX(0deg); opacity: 1; }
        }
        @keyframes pg-flap-fade {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-split-flap] [data-flap] {
            animation-name: pg-flap-fade !important;
          }
        }
      `}</style>
      <span className="sr-only">{value}</span>
      <span aria-hidden="true" className="inline-flex items-center gap-[0.12em]">
        {shown.map((cell, i) => (
          <span
            key={i}
            className="relative inline-flex h-[1.45em] w-[1.35ch] shrink-0 items-center justify-center overflow-hidden rounded-[0.18em] bg-muted shadow-border [perspective:300px] before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1/2 before:bg-foreground/[0.04] dark:bg-muted/60"
          >
            <span
              key={cell.flip}
              data-flap=""
              className="leading-none [backface-visibility:hidden]"
              style={
                cell.flip > 0
                  ? {
                      animation: `pg-flap ${flipMs}ms var(--ease-out)`,
                    }
                  : undefined
              }
            >
              {cell.char}
            </span>
            <span className="pointer-events-none absolute inset-x-0 top-1/2 h-px bg-background/70" />
          </span>
        ))}
      </span>
    </span>
  );
}