# Split Flap (`split-flap`)

> A split-flap departure board — each character is a mechanical flap module that clatters forward through the alphabet until it lands on its target.

- **Docs:** https://www.mellowui.com/components/split-flap
- **Markdown:** https://www.mellowui.com/components/split-flap.md
- **Registry:** https://www.mellowui.com/r/split-flap.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/split-flap
- **Categories:** text, display, 3d, animation
- **Dependencies:** motion

## AI prompt

Add a SplitFlap component from the mellow library — a split-flap departure board where each cell flips through the charset until it lands on its target character. Pass `text`, and set `length` to a fixed cell count so the board never resizes. Tune `stepMs`, `size` and `trigger` ('scroll' | 'mount').

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/split-flap.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | — | Text to display — flips character by character like a departure board. |
| `length` | `number` | — | Fixed cell count; text is padded/truncated so the board never resizes. |
| `charset` | `string` | `" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-'&"` | Characters each cell cycles through, in order. |
| `stepMs` | `number` | `48` | Duration of one flap in ms. |
| `size` | `number` | `44` | Cell height in px. |
| `trigger` | `"scroll" \| "mount"` | `"scroll"` | Start when scrolled into view, or immediately on mount. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";

export interface SplitFlapProps {
  /** Text to display — flips character by character like a departure board. */
  text: string;
  /** Fixed cell count; text is padded/truncated so the board never resizes. */
  length?: number;
  /** Characters each cell cycles through, in order. */
  charset?: string;
  /** Duration of one flap in ms. */
  stepMs?: number;
  /** Cell height in px. */
  size?: number;
  /** Start when scrolled into view, or immediately on mount. */
  trigger?: "scroll" | "mount";
  className?: string;
  style?: React.CSSProperties;
}

const DEFAULT_CHARSET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-'&";

// opaque face: page background with an ink tint, so flaps hide what's behind
const FACE_BG =
  "linear-gradient(rgba(var(--ink-rgb),0.08), rgba(var(--ink-rgb),0.08)) var(--background)";

/**
 * A split-flap departure board — each character is a mechanical flap module
 * that clatters forward through the alphabet until it lands on its target.
 * Changing `text` re-flips only the cells that differ.
 */
export function SplitFlap({
  text,
  length,
  charset = DEFAULT_CHARSET,
  stepMs = 48,
  size = 44,
  trigger = "scroll",
  className,
  style,
}: SplitFlapProps) {
  const ref = useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.5 });
  const reduced = useReducedMotion();
  const started = trigger === "mount" || inView;

  const upper = text.toUpperCase();
  const padded = length !== undefined ? upper.padEnd(length, " ").slice(0, length) : upper;
  const sanitized = padded
    .split("")
    .map((c) => (charset.includes(c) ? c : " "))
    .join("");
  const target = started ? sanitized : " ".repeat(sanitized.length);

  return (
    <span
      ref={ref}
      aria-label={sanitized.trimEnd() || "blank board"}
      className={["inline-flex", className].filter(Boolean).join(" ")}
      style={style}
    >
      <span aria-hidden="true" className="flex gap-[3px]">
        {sanitized.split("").map((_, i) => (
          <FlapCell
            key={i}
            target={target[i] ?? " "}
            charset={charset}
            delay={i * 34}
            stepMs={stepMs}
            size={size}
            reduced={!!reduced}
          />
        ))}
      </span>
    </span>
  );
}

function FlapCell({
  target,
  charset,
  delay,
  stepMs,
  size,
  reduced,
}: {
  target: string;
  charset: string;
  delay: number;
  stepMs: number;
  size: number;
  reduced: boolean;
}) {
  const [current, setCurrent] = useState(" ");
  const [next, setNext] = useState<string | null>(null);
  const startedRef = useRef(false);

  useEffect(() => {
    if (reduced) {
      setNext(null);
      setCurrent(target);
      return;
    }
    if (current === target || next !== null) return;
    const idx = charset.indexOf(current);
    const nx = charset[(idx + 1) % charset.length];
    if (!startedRef.current) {
      startedRef.current = true;
      const t = setTimeout(() => setNext(nx), delay);
      return () => clearTimeout(t);
    }
    setNext(nx);
  }, [current, target, next, reduced, charset, delay]);

  const half = size / 2;
  const width = Math.round(size * 0.72);
  const glyph = (c: string, shifted: boolean) => (
    <span
      className="block w-full text-center [font-family:var(--font-mono)] font-medium text-[var(--ink)]"
      style={{
        height: size,
        lineHeight: `${size}px`,
        fontSize: Math.round(size * 0.62),
        transform: shifted ? `translateY(-${half}px)` : undefined,
      }}
    >
      {c}
    </span>
  );

  return (
    <span
      className="relative block overflow-hidden rounded-[4px] border border-[var(--rule)]"
      style={{ width, height: size, perspective: 600, background: FACE_BG }}
    >
      {/* top static — during a flip, already shows the incoming character */}
      <span
        className="absolute inset-x-0 top-0 overflow-hidden"
        style={{ height: half, background: FACE_BG }}
      >
        {glyph(next ?? current, false)}
      </span>
      {/* bottom static — keeps the old character until the flap lands */}
      <span
        className="absolute inset-x-0 bottom-0 overflow-hidden"
        style={{ height: half, background: FACE_BG }}
      >
        {glyph(current, true)}
      </span>
      {/* falling flap */}
      {next !== null && !reduced && (
        <motion.span
          key={`${current}-${next}`}
          initial={{ rotateX: 0 }}
          animate={{ rotateX: -180 }}
          transition={{ duration: Math.max(stepMs, 30) / 1000, ease: "easeIn" }}
          onAnimationComplete={() => {
            setCurrent(next);
            setNext(null);
          }}
          className="absolute inset-x-0 top-0 z-10 block"
          style={{
            height: half,
            transformOrigin: "50% 100%",
            transformStyle: "preserve-3d",
          }}
        >
          <span
            className="absolute inset-0 overflow-hidden [backface-visibility:hidden]"
            style={{ background: FACE_BG }}
          >
            {glyph(current, false)}
          </span>
          <span
            className="absolute inset-0 overflow-hidden [backface-visibility:hidden]"
            style={{ background: FACE_BG, transform: "rotateX(180deg)" }}
          >
            {glyph(next, true)}
          </span>
        </motion.span>
      )}
      {/* hinge slit */}
      <span
        className="absolute inset-x-0 z-20 block bg-[rgba(var(--background-rgb),0.9)]"
        style={{ top: half - 0.5, height: 1 }}
      />
    </span>
  );
}

export default SplitFlap;

```

## Demo

```tsx
"use client";

import React, { useEffect, useState } from "react";
import { SplitFlap } from "../mellow/split-flap";

const BOARD: [string, string, string][] = [
  ["AMSTERDAM", "09:42", "A3"],
  ["NEW DELHI", "11:05", "B7"],
  ["REYKJAVIK", "13:30", "C1"],
  ["KYOTO", "15:12", "A9"],
  ["BUENOS AIRES", "18:47", "D2"],
];

export default function SplitFlapDemo() {
  const [offset, setOffset] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setOffset((o) => (o + 1) % BOARD.length), 6000);
    return () => clearInterval(id);
  }, []);

  const rows = [0, 1, 2].map((i) => BOARD[(offset + i) % BOARD.length]);

  return (
    <div className="flex w-full flex-col items-center gap-5 overflow-x-auto p-6">
      <div className="flex flex-col gap-2">
        <div className="mb-1 flex items-center gap-4">
          <span className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.2em] text-[rgba(var(--ink-rgb),0.45)] uppercase">
            Departures
          </span>
          <span className="h-px flex-1 bg-[var(--rule)]" />
          <span className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.2em] text-[rgba(var(--ink-rgb),0.45)] uppercase">
            Terminal M
          </span>
        </div>
        {rows.map((row, i) => (
          <div key={i} className="flex items-center gap-4">
            <SplitFlap text={row[0]} length={12} size={30} trigger="mount" />
            <SplitFlap text={row[1]} length={5} size={30} trigger="mount" />
            <SplitFlap text={row[2]} length={2} size={30} trigger="mount" />
          </div>
        ))}
      </div>
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        The board reshuffles itself every few seconds
      </p>
    </div>
  );
}

```
