Skip to content

Components / Text

Split Flap

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.

Live demo

DEPARTURES

AMS 14:05 ON TIME

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

Uses Reveal and the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.

CSS 8.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 91 · Firefox 128 · Safari 17.2; needs counter-set, property, counter-style. Elsewhere: the final text, shown at once.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import SplitFlap from '@moonarc/core/SplitFlap';
---
<SplitFlap as="h2" text="Departures" class="font-mono" />
<SplitFlap text="AMS 14:05 ON TIME" step={45} stagger={30} />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/split-flap.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy Reveal.astro and runtime.ts, and add the JS gate to your head.

SplitFlap.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>

Props

PropTypeDefaultDescription
textstringnoneThe text. Boards are uppercase, so letters are uppercased; A–Z, 0–9 and : . - / + & ? ! are in the set, anything else renders as ?.
asHTMLTag'span'Element to render.
stepnumberpreset (ui: 60)Milliseconds per character step (one flap). Unset, it follows the preset's --ma-stagger; 45–70 reads like a real board.
staggernumberpreset (40)Milliseconds between neighbouring cells starting. Unset, the preset's --ma-stagger-tight.
delaynumber0Delay in ms before the first cell.
oncebooleantruePlay once and stay; false replays on every entry.
thresholdnumber0.2Fraction visible to trigger.

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.

Why it is built this way

Replaces: SplitFlap / FlipText effects (React Bits, Aceternity) · split-flap canvas boards. See the migration table.

Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.

newly · Chrome 91 · Firefox 128 · Safari 17scroll

Random characters resolve into the real text from left to right when it scrolls into view, on load, or on hover.

every browserscrollhoverload

Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.

Chrome 116 · not Firefox · Safari 18load

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown