# Split-flap board

> An airport board that flips to the next word.

- Type: Component (numbers)
- Page: https://textlab.dev/components/split-flap
- Markdown: https://textlab.dev/components/split-flap.md
- Registry item: https://textlab.dev/r/split-flap.json
- Source file: `registry/textlab/split-flap/split-flap.tsx`
- Dependencies: none
- Variants: dark, light, mini
- Keywords: split flap, solari, airport, departures, board, flip

## When to use

- Status boards, departures, scoreboards and countdown labels.
- Short uppercase words and codes.

## When not to use

- Lowercase prose; the default charset is uppercase.
- Long sentences; each tile flips through the alphabet.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/split-flap
pnpm dlx shadcn@latest add @textlab/split-flap
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

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

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { SplitFlap } from "@/components/textlab/split-flap/split-flap";

export function Example() {
  return <SplitFlap text={destination} length={10} />;
}
```

## API reference

### SplitFlap

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"dark" \| "light" \| "mini"` | `"dark"` | Classic black board, cream tiles, or compact tiles that take the text color. |
| `length` | `number` | – | Pad or trim to this many tiles. Keep it fixed when the text changes. |
| `charset` | `string` | `" A–Z 0–9 :.-/"` | Characters each tile cycles through. Others jump straight in. |
| `speed` | `number` | `50` | Milliseconds per half flip. |
| `stagger` | `number` | `35` | Milliseconds between neighbouring tiles starting. |
| `live` | `boolean` | `false` | Announce changes politely to screen readers. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- Screen readers read the text once, not the flipping letters.
- Tiles have a fixed width, so the line never shifts.

## Motion

- Each flip is two halves: the top leaf falls with ease-in, the bottom leaf lands with ease-out.
- Tiles start 35 ms apart from left to right.

## Reduced motion

Tiles show the final letters at once.

## Performance

- CSS keyframes only; no JavaScript animation loop.
- A tile does work only while it is flipping.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: dark, light, mini.

## Related

- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.

## Also in numbers

- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Price ticker](https://textlab.dev/components/price-ticker.md): Prices tick with a flash of up or down.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.

## Source

### components/textlab/split-flap/split-flap.tsx

```tsx
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ElementType } from "react";
import { SrText } from "../lib/a11y";
import { graphemes } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type SplitFlapVariant = "dark" | "light" | "mini";

export interface SplitFlapProps {
  text: string;
  /** dark is the classic black board, light is cream tiles, mini is compact and borderless. */
  variant?: SplitFlapVariant;
  /** Pad or trim to this many tiles. Defaults to the text length. */
  length?: number;
  /** Characters every tile cycles through, in order. Unknown characters jump straight in. */
  charset?: string;
  /** Milliseconds per flip. */
  speed?: number;
  /** Milliseconds between neighbouring tiles starting. */
  stagger?: number;
  /** Announce changes to screen readers. */
  live?: boolean;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

const DEFAULT_CHARSET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/";

const CSS = `
@keyframes tl-flap-top { from { transform: rotateX(0deg); } to { transform: rotateX(-90deg); } }
@keyframes tl-flap-bottom { from { transform: rotateX(90deg); } to { transform: rotateX(0deg); } }
.tl-flap { position: relative; display: inline-block; width: 0.8em; height: 1.18em; margin: 0 0.035em; perspective: 4em; border-radius: 0.12em; font-variant-numeric: tabular-nums; text-align: center; line-height: 1.18em; background: var(--tl-flap-bg); box-shadow: var(--tl-flap-shadow); }
.tl-flap-half { position: absolute; left: 0; right: 0; height: 50%; overflow: hidden; background: var(--tl-flap-bg); color: var(--tl-flap-fg); backface-visibility: hidden; }
.tl-flap-half > span { position: absolute; left: 0; right: 0; height: 1.18em; }
.tl-flap-top { top: 0; border-radius: 0.12em 0.12em 0 0; transform-origin: 50% 100%; }
.tl-flap-bottom { bottom: 0; border-radius: 0 0 0.12em 0.12em; transform-origin: 50% 0%; }
.tl-flap-bottom > span { bottom: 0; }
.tl-flap-top::after { content: ""; position: absolute; inset: auto 0 0 0; height: 1px; background: var(--tl-flap-seam); }
/* The top leaf falls under gravity, so it accelerates (ease-in); the bottom leaf lands and settles (ease-out). */
.tl-flap-leaf-top { z-index: 2; animation: tl-flap-top var(--tl-flap-ms) cubic-bezier(0.4, 0, 1, 1) forwards; }
.tl-flap-leaf-bottom { z-index: 2; transform: rotateX(90deg); animation: tl-flap-bottom var(--tl-flap-ms) cubic-bezier(0, 0, 0.2, 1) var(--tl-flap-ms) forwards; }
`;

const THEMES: Record<SplitFlapVariant, CSSProperties> = {
  dark: { "--tl-flap-bg": "oklch(0.24 0.006 260)", "--tl-flap-fg": "oklch(0.97 0.01 90)", "--tl-flap-seam": "oklch(0 0 0 / 0.7)", "--tl-flap-shadow": "inset 0 0 0 1px oklch(1 0 0 / 0.07), 0 2px 6px oklch(0 0 0 / 0.35)" } as CSSProperties,
  light: { "--tl-flap-bg": "oklch(0.96 0.018 90)", "--tl-flap-fg": "oklch(0.22 0.01 260)", "--tl-flap-seam": "oklch(0 0 0 / 0.16)", "--tl-flap-shadow": "inset 0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 3px oklch(0 0 0 / 0.12)" } as CSSProperties,
  mini: { "--tl-flap-bg": "color-mix(in oklab, currentColor 10%, var(--tl-background, Canvas))", "--tl-flap-fg": "currentColor", "--tl-flap-seam": "color-mix(in oklab, currentColor 18%, transparent)", "--tl-flap-shadow": "none" } as CSSProperties,
};

function Tile({ target, charset, speed, delay, reduced }: { target: string; charset: string; speed: number; delay: number; reduced: boolean }) {
  const [current, setCurrent] = useState(target);
  const [next, setNext] = useState(target);
  const [step, setStep] = useState(0);
  const delayedFor = useRef(target);

  useEffect(() => {
    if (reduced) return;
    // Wait for the running flip to land before starting the next one.
    if (current === target || next !== current) return;
    const from = charset.indexOf(current);
    const to = charset.indexOf(target);
    const following = from === -1 || to === -1 ? target : charset[(from + 1) % charset.length];
    // Neighbours start a beat apart, but only at the start of each new word.
    const wait = delayedFor.current === target ? 0 : delay;
    delayedFor.current = target;
    const id = setTimeout(() => {
      setNext(following);
      setStep((s) => s + 1);
    }, wait);
    return () => clearTimeout(id);
  }, [current, next, target, charset, delay, reduced]);

  useEffect(() => {
    if (next === current) return;
    // The top leaf falls, then the bottom leaf lands; after both, the new char is current.
    const id = setTimeout(() => setCurrent(next), speed * 2);
    return () => clearTimeout(id);
  }, [next, current, speed]);

  if (reduced) {
    return (
      <span className="tl-flap">
        <span className="tl-flap-half tl-flap-top"><span>{target}</span></span>
        <span className="tl-flap-half tl-flap-bottom"><span>{target}</span></span>
      </span>
    );
  }

  const flipping = next !== current;
  return (
    <span className="tl-flap">
      <span className="tl-flap-half tl-flap-top"><span>{next}</span></span>
      <span className="tl-flap-half tl-flap-bottom"><span>{current}</span></span>
      {flipping && (
        <>
          <span key={`t${step}`} className="tl-flap-half tl-flap-top tl-flap-leaf-top"><span>{current}</span></span>
          <span key={`b${step}`} className="tl-flap-half tl-flap-bottom tl-flap-leaf-bottom"><span>{next}</span></span>
        </>
      )}
    </span>
  );
}

/**
 * An airport departures board. Each tile flips through the alphabet until it reaches its letter,
 * neighbours starting a beat apart, with a top leaf that falls and a bottom leaf that lands.
 */
export function SplitFlap({
  text,
  variant = "dark",
  length,
  charset = DEFAULT_CHARSET,
  speed = 50,
  stagger = 35,
  live = false,
  as: Tag = "span",
  reducedMotion = "user",
  className,
  style,
}: SplitFlapProps) {
  const reduced = useReduced(reducedMotion);
  const upper = charset === DEFAULT_CHARSET ? text.toUpperCase() : text;
  const chars = graphemes(upper);
  const size = length ?? chars.length;
  const tiles = Array.from({ length: size }, (_, i) => chars[i] ?? " ");

  return (
    <Tag className={className} style={{ display: "inline-block", whiteSpace: "nowrap", ...THEMES[variant], "--tl-flap-ms": `${speed}ms`, ...style } as CSSProperties}>
      <style href="tl-split-flap" precedence="default">{CSS}</style>
      <SrText>
        <span aria-live={live ? "polite" : undefined}>{text}</span>
      </SrText>
      <span aria-hidden="true" style={variant === "mini" ? { fontSize: "0.8em" } : undefined}>
        {tiles.map((char, i) => (
          <Tile key={i} target={char} charset={charset} speed={speed} delay={i * stagger} reduced={reduced} />
        ))}
      </span>
    </Tag>
  );
}

export default SplitFlap;
```

Full library index: https://textlab.dev/llms.txt
