# Split Flap (Moonarc)

A departures board: every character spins through the alphabet and locks, one cell after another, when the board scrolls into view. Each glyph is a CSS counter on a registered integer that transitions from blank to the letter, and a flap turns once per step. Composes Reveal; no script of its own. The server HTML already reads the final text.

- Import: `import SplitFlap from '@moonarc/core/SplitFlap'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/split-flap.json`
- Tier A · category text · trigger scroll
- Readout: `<SplitFlap text="DEPARTURES">`
- Browser support: newly (Chrome 91 · Firefox 128 · Safari 17.2); elsewhere: the final text, shown at once
- Measured cost: 0 B JS of its own · uses Reveal + runtime (with dependencies 3.0 kB raw; CSS 8.8 kB raw)
- Page: https://moonarc.dev/components/split-flap/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The text. Boards are uppercase, so letters are uppercased; A–Z, 0–9 and : . - / + & ? ! are in the set, anything else renders as ?. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `step` | `number` | `preset (ui: 60)` | Milliseconds per character step (one flap). Unset, it follows the preset's --ma-stagger; 45–70 reads like a real board. |
| `stagger` | `number` | `preset (40)` | Milliseconds between neighbouring cells starting. Unset, the preset's --ma-stagger-tight. |
| `delay` | `number` | `0` | Delay in ms before the first cell. |
| `once` | `boolean` | `true` | Play once and stay; false replays on every entry. |
| `threshold` | `number` | `0.2` | Fraction visible to trigger. |

## Usage

```astro
<SplitFlap as="h2" text="Departures" class="font-mono" />
<SplitFlap text="AMS 14:05 ON TIME" step={45} stagger={30} />
```

## Reduced motion

The final text appears at once; nothing spins.

## With ClientRouter

Inherits Reveal: rebinds after every navigation, so the board replays on a fresh page.

## Craft

- The letter is content: counter() on a registered <integer> with a fixed-system @counter-style whose symbols are the charset. Integers interpolate in steps, so a linear transition from 0 to 19 rewrites the glyph nineteen times. The spin is the browser stepping through the alphabet, not a sprite.
- Each cell's transition lasts index × step, so A locks first and Z last: cells lock one by one in the order a real board does, and the flap animation runs exactly index times at the same step, so the turn and the letter change stay in sync.
- One flap: the top half turning down over the hinge on an ease-in is what the eye reads; the bottom half stays put. Perspective on the cell keeps the turn from looking like a squash.
- Blank at index 0, so every cell starts empty and the board fills in. The word is legible at the moment the last cell locks, never before.
- A visually hidden span (.ma-sr) carries the string; the cells are aria-hidden and the counters are decoration.
- One cell per grapheme, and the board takes dir="auto" from its own text: the cells are a flex row, which follows the page's direction, and on a right-to-left page it spelled the word backwards.

## Replaces

- SplitFlap / FlipText effects (React Bits, Aceternity)
- split-flap canvas boards

## Source

```astro
---
/**
 * SplitFlap — a departures board. Every character is a cell whose letter is
 * a CSS counter on a registered integer: the integer transitions from blank
 * to the target letter's index in the charset, so the glyph is rewritten one
 * step at a time until it locks, and a flap on the cell turns once per step.
 * Composes Reveal for the scroll trigger; zero script of its own. The server
 * HTML carries the final text (.ma-sr + counters at their targets).
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';

interface Props extends HTMLAttributes<'span'> {
  /** The text. Boards are uppercase: letters are uppercased, characters outside the set render as ?. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Milliseconds per character step (one flap). Unset: the preset's --ma-stagger. */
  step?: number;
  /** Milliseconds between neighbouring cells starting. Unset: the preset's --ma-stagger-tight. */
  stagger?: number;
  /** Delay in ms before the first cell. */
  delay?: number;
  /** Play once and stay. */
  once?: boolean;
  /** Fraction visible to trigger. */
  threshold?: number;
}

const { text, as = 'span', step, stagger, delay, once, threshold, class: className, style, ...rest } = Astro.props;

// Index 0 is blank; the @counter-style below lists the same symbols in the same order.
const SET = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/+&?!';
// one cell per grapheme, not per code point: an emoji with a skin tone is one ? on the board, not two
const cells = Array.from(new Intl.Segmenter().segment(text), (g) => g.segment).map((c) => {
  const i = SET.indexOf(c.toUpperCase());
  return { c, to: c === ' ' ? -1 : i >= 0 ? i : SET.indexOf('?') };
});
const vars = [step !== undefined && `--ma-flap-step:${step}ms`, stagger !== undefined && `--ma-flap-stagger:${stagger}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// dir="auto": the board is a flex row, which follows the page's direction, so on a right-to-left page it spelled its
// text back to front. The board takes its direction from its own text instead.
---

<Reveal as={as} from="none" delay={delay} once={once} threshold={threshold} class={['ma-flap', className].filter(Boolean).join(' ')} dir="auto" style={vars || undefined} {...rest}>
  {cells.map(({ to }, i) => (to < 0 ? <span class="ma-flap__gap" aria-hidden="true"></span> : <span class="ma-flap__cell" aria-hidden="true" style={`--ma-flap-to:${to};--ma-i:${i}`}><b class="ma-flap__top"></b><b class="ma-flap__bot"></b><b class="ma-flap__flap"></b></span>))}
  <span class="ma-sr">{text}</span>
</Reveal>

<style is:global>
  @property --ma-flap-n {
    syntax: '<integer>';
    inherits: false;
    initial-value: 0;
  }

  @layer components {
    /* the charset, in the same order as the server's SET — value 0 is the blank */
    @counter-style ma-flap-set {
      system: fixed 0;
      symbols: ' ' 'A' 'B' 'C' 'D' 'E' 'F' 'G' 'H' 'I' 'J' 'K' 'L' 'M' 'N' 'O' 'P' 'Q' 'R' 'S' 'T' 'U' 'V' 'W' 'X' 'Y' 'Z' '0' '1' '2' '3' '4' '5' '6' '7' '8' '9' ':' '.' '-' '/' '+' '&' '?' '!';
    }
    :where(.ma-flap) {
      display: inline-flex;
      flex-wrap: wrap;
      gap: 0.08em;
      line-height: 1;
      font-variant-numeric: tabular-nums;
    }
    :where(.ma-flap__gap) {
      width: 0.5em;
    }
    :where(.ma-flap__cell) {
      position: relative;
      display: inline-grid;
      min-width: 1em;
      height: 1.3em;
      padding-inline: 0.1em;
      perspective: 6em;
      --ma-flap-n: var(--ma-flap-to, 0);
      /* counter-set, not counter-reset: WebKit paints a stale glyph for siblings when a counter-reset value changes through a transition (found in 7B) */
      counter-set: ma-flap var(--ma-flap-n);
      /* linear on purpose: an integer ramp, so every character step takes the same time */
      transition: --ma-flap-n calc(var(--ma-flap-to, 0) * var(--ma-flap-step, var(--ma-stagger))) linear calc(var(--ma-r-delay, 0ms) + var(--ma-i, 0) * var(--ma-flap-stagger, var(--ma-stagger-tight)));
    }
    /* the hinge */
    :where(.ma-flap__cell)::after {
      content: '';
      position: absolute;
      inset: 50% 0 auto 0;
      height: 1px;
      background: var(--ma-glow);
      pointer-events: none;
    }
    :where(.ma-flap__top),
    :where(.ma-flap__bot),
    :where(.ma-flap__flap) {
      grid-area: 1 / 1;
      display: grid;
      place-items: center;
      font-weight: inherit;
      border-radius: 0.12em;
      background: var(--ma-edge);
    }
    :where(.ma-flap__top)::before,
    :where(.ma-flap__bot)::before,
    :where(.ma-flap__flap)::before {
      content: counter(ma-flap, ma-flap-set);
      white-space: pre;
    }
    :where(.ma-flap__top),
    :where(.ma-flap__flap) {
      clip-path: inset(0 0 50% 0);
    }
    :where(.ma-flap__bot) {
      clip-path: inset(50% 0 0 0);
    }
    :where(.ma-flap__flap) {
      transform-origin: 50% 50%;
      backface-visibility: hidden;
    }
    /* Under the JS gate the cells start blank and step to their letter once Reveal marks the board in view; the flap turns once per step. */
    :where([data-ma-js] .ma-flap:not(.ma-in) .ma-flap__cell) {
      --ma-flap-n: 0;
      transition: none;
    }
    :where(.ma-flap.ma-in .ma-flap__flap) {
      animation: ma-flap-turn var(--ma-flap-step, var(--ma-stagger)) var(--ma-ease-in) calc(var(--ma-r-delay, 0ms) + var(--ma-i, 0) * var(--ma-flap-stagger, var(--ma-stagger-tight))) var(--ma-flap-to, 0);
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-flap__cell) {
        transition: none;
      }
      :where(.ma-flap.ma-in .ma-flap__flap) {
        animation: none;
      }
    }
    /* Print: every cell at its letter. A board printed before it scrolled into view was a row of blank cells. */
    @media print {
      :where(.ma-flap .ma-flap__cell) {
        --ma-flap-n: var(--ma-flap-to, 0);
        transition: none;
      }
      :where(.ma-flap .ma-flap__flap) {
        animation: none;
      }
    }
  }

  @keyframes ma-flap-turn {
    from {
      transform: rotateX(0);
    }
    to {
      transform: rotateX(-90deg);
    }
  }
</style>

```
