---
title: Split Flap
description: Airport-board letter flips.
group: Text
order: 28
new: true
---

Each letter sits in its own tile and flips through the wheel until the word settles. Set `length` on the component to lock how many tiles show. Spaces render as blank tiles, so the board width stays put.

```tsx
import { useState } from "react"
import { SplitFlap } from "@/components/motion/split-flap"
import { Button } from "@/components/ui/button"

export function Example() {
  const [play, setPlay] = useState(0)

  return (
    <>
      <SplitFlap text="ON TIME" length={9} play={play} speed={45} stagger={55} />
      <Button onClick={() => setPlay((n) => n + 1)}>Flip</Button>
    </>
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add split-flap
```

## Usage

```tsx
import { useState } from "react"
import { SplitFlap } from "@/components/motion/split-flap"
```

```tsx
const [play, setPlay] = useState(0)

<>
  <SplitFlap
    text="ON TIME"
    length={9}
    play={play}
    speed={45}
    stagger={55}
  />
  <button type="button" onClick={() => setPlay((n) => n + 1)}>
    Flip
  </button>
</>
```

## API Reference

### SplitFlap

| Prop | Type | Default |
| --- | --- | --- |
| text | string | — |
| length | number | text.length |
| play | number | 0 |
| speed | number | 45 |
| stagger | number | 55 |
| characters | string | ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 |
| className | string | — |

Also accepts the other native `div` attributes. `length` is the fixed tile count. Omit it and the board sizes to the current `text`. Shorter text pads with blank tiles. Longer text truncates. `speed` is ms between flap steps. `stagger` delays each character start.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
} from "react";
import { cn } from "@/lib/utils";

const DEFAULT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

export type SplitFlapProps = Omit<ComponentProps<"div">, "children"> & {
  /** Text to flip into. */
  text: string;
  /** Fixed tile count. Pads with blank tiles, truncates if longer. */
  length?: number;
  /** Bump to replay. */
  play?: number;
  /** Time between each flap step in ms. */
  speed?: number;
  /** Delay between characters starting in ms. */
  stagger?: number;
  /** Character wheel. Unknown glyphs snap without spinning. */
  characters?: string;
};

type CellState = {
  current: string;
  previous: string;
  flipping: boolean;
};

function normalizeChar(char: string, pool: string) {
  if (char === " ") return " ";
  const upper = char.toUpperCase();
  if (pool.includes(upper)) return upper;
  return char;
}

function fitText(text: string, length: number, pool: string) {
  const chars = Array.from(text).map((char) => normalizeChar(char, pool));
  if (chars.length === length) return chars;
  if (chars.length > length) return chars.slice(0, length);
  return [...chars, ...Array.from({ length: length - chars.length }, () => " ")];
}

function SplitFlapUnit({ current, previous, flipping }: CellState) {
  return (
    <span
      className="arctis-split-flap-unit"
      data-flipping={flipping ? "true" : "false"}
      data-blank={current === "" && previous === "" && !flipping ? "true" : "false"}
      aria-hidden="true"
    >
      <span className="arctis-split-flap-static arctis-split-flap-static-top">
        <span className="arctis-split-flap-glyph">
          {flipping ? previous : current}
        </span>
      </span>
      <span className="arctis-split-flap-static arctis-split-flap-static-bottom">
        <span className="arctis-split-flap-glyph">{current}</span>
      </span>
      {flipping ? (
        <span className="arctis-split-flap-hinge" key={`${previous}-${current}`}>
          <span className="arctis-split-flap-hinge-front">
            <span className="arctis-split-flap-glyph">{previous}</span>
          </span>
          <span className="arctis-split-flap-hinge-back">
            <span className="arctis-split-flap-glyph">{current}</span>
          </span>
        </span>
      ) : null}
      <span className="arctis-split-flap-line" />
    </span>
  );
}

function toGlyph(char: string) {
  return char === " " ? "" : char;
}

function SplitFlap({
  className,
  text,
  length,
  play = 0,
  speed = 45,
  stagger = 55,
  characters = DEFAULT_CHARS,
  style,
  ...props
}: SplitFlapProps) {
  const pool = characters.length > 0 ? characters : DEFAULT_CHARS;
  const tileCount = Math.max(length ?? Array.from(text).length, 1);
  const target = fitText(text, tileCount, pool);
  const [cells, setCells] = useState<CellState[]>(() =>
    target.map((char) => ({
      current: toGlyph(char),
      previous: toGlyph(char),
      flipping: false,
    })),
  );
  const valuesRef = useRef(target.map((char) => char));
  const countRef = useRef(tileCount);
  const runRef = useRef(0);
  const timersRef = useRef<number[]>([]);

  useEffect(() => {
    countRef.current = tileCount;
  }, [tileCount]);

  useEffect(() => {
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    timersRef.current.forEach((id) => window.clearTimeout(id));
    timersRef.current = [];
    runRef.current += 1;
    const runId = runRef.current;

    const count = countRef.current;
    const nextTarget = fitText(text, count, pool);

    while (valuesRef.current.length < count) valuesRef.current.push(" ");
    valuesRef.current = valuesRef.current.slice(0, count);

    if (reduced) {
      valuesRef.current = [...nextTarget];
      setCells(
        nextTarget.map((char) => ({
          current: toGlyph(char),
          previous: toGlyph(char),
          flipping: false,
        })),
      );
      return;
    }

    setCells(
      nextTarget.map((_, index) => {
        const current = valuesRef.current[index] ?? " ";
        return {
          current: toGlyph(current),
          previous: toGlyph(current),
          flipping: false,
        };
      }),
    );

    const stepMs = Math.max(speed, 24);
    const staggerMs = Math.max(stagger, 0);
    const flipMs = Math.min(Math.max(Math.floor(stepMs * 0.8), 20), stepMs - 4);

    function paint(
      index: number,
      previous: string,
      current: string,
      flipping: boolean,
    ) {
      valuesRef.current[index] = current === "" ? " " : current;
      setCells((latest) =>
        Array.from({ length: count }, (_, i) => {
          if (i === index) {
            return {
              current: toGlyph(current),
              previous: toGlyph(previous),
              flipping,
            };
          }
          const value = valuesRef.current[i] ?? " ";
          const existing = latest[i];
          return (
            existing ?? {
              current: toGlyph(value),
              previous: toGlyph(value),
              flipping: false,
            }
          );
        }),
      );
    }

    function flipOnce(index: number, previous: string, current: string) {
      paint(index, previous, current, true);
      const settle = window.setTimeout(() => {
        if (runRef.current !== runId) return;
        paint(index, current, current, false);
      }, flipMs);
      timersRef.current.push(settle);
    }

    nextTarget.forEach((finalChar, index) => {
      const start = window.setTimeout(() => {
        if (runRef.current !== runId) return;

        const from = valuesRef.current[index] ?? " ";
        if (from === finalChar) {
          paint(index, finalChar, finalChar, false);
          return;
        }

        if (
          finalChar === " " ||
          from === " " ||
          !pool.includes(finalChar) ||
          !pool.includes(from)
        ) {
          flipOnce(index, from, finalChar);
          return;
        }

        let cursor = pool.indexOf(from);
        const end = pool.indexOf(finalChar);
        const distance =
          ((end - cursor + pool.length) % pool.length) || pool.length;
        let step = 0;

        const tick = () => {
          if (runRef.current !== runId) return;
          step += 1;
          const previous = pool[cursor] ?? from;
          cursor = (cursor + 1) % pool.length;
          const nextChar = pool[cursor] ?? finalChar;
          flipOnce(index, previous, nextChar);

          if (step < distance) {
            const next = window.setTimeout(tick, stepMs);
            timersRef.current.push(next);
          }
        };

        tick();
      }, index * staggerMs);

      timersRef.current.push(start);
    });

    return () => {
      timersRef.current.forEach((id) => window.clearTimeout(id));
      timersRef.current = [];
    };
  }, [text, play, speed, stagger, pool, tileCount]);

  return (
    <div
      data-slot="split-flap"
      className={cn("arctis-split-flap", className)}
      style={
        {
          "--split-flap-speed": `${Math.max(Math.floor(speed * 0.8), 20)}ms`,
          ...style,
        } as CSSProperties
      }
      aria-label={text}
      {...props}
    >
      {cells.map((cell, index) => (
        <SplitFlapUnit key={index} {...cell} />
      ))}
    </div>
  );
}

export { SplitFlap, DEFAULT_CHARS as SPLIT_FLAP_DEFAULT_CHARS };
```
