Split-Flap Board

A departure board in CSS 3D: every flap turns forward through its drum until it lands on its letter, four half-cards and two hinged leaves per character.

CSS 3DrotateXbackface-visibilitykeyframesstagger3D CSS · 4 files · 699 lines
CSS 3D
ESTABLISH LINK / NETOUN.COM/LABS

<SplitFlapBoard flipMs={70} /> · advanceBoard() every 70 ms, 30 ms a column

man split-flap

A line ref opens it in the source below

Name

split-flap — a departure board where every character is four half-cards on one hinge.

How it works

  1. A drum, not a font. Each flap owns a drum of 42 glyphs (blank, A–Z, 0–9, . / - _ ❯) that only turns forward: its step count is its distance to the target on that drum, wrapping past the end.

  2. Four half-cards. Two static halves show the new glyph on top and the old one below; two leaves hinged on the middle line carry the halves in motion, each with backface-visibility: hidden, so a leaf seen edge-on disappears.

  3. Half a flip each. The top leaf falls from 0 to −90° in the first half of the flip time, the bottom leaf lands from 90° to 0 in the second. Every step swaps the flap between two identical pairs of keyframes, and a new animation name restarts the animation.

  4. A ripple, not a wall. Columns start one stagger apart and the second line two columns later, so a new message ripples across the board.

Cost

  1. Transforms only. Only the two leaves and a shade animate, on transform and opacity; a flap re-renders only when its own glyph changes (memo).

  2. Stops when it lands. One timer for the whole board, and none once every flap shows its target; under prefers-reduced-motion the letters are set at once.

See also

  • computer-3d — the same CSS 3D, twelve faces instead of four.
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { memo } from "react";
import { BOARD_COLS, DRUM, type BoardState } from "./split-flap-drum";
import * as styles from "./split-flap-board.css";

// The board's terminal marks print in gold, like the site's `_❯`.
const isMark = (glyph: string) => glyph === "_" || glyph === "❯";

interface HalfProps {
  glyph: string;
  className: string;
  /** Which half of the glyph this card shows. */
  half: "top" | "bottom";
}

function Half({ glyph, className, half }: HalfProps) {
  return (
    <span className={className}>
      <span className={styles.glyph} data-half={half} data-mark={isMark(glyph) || undefined}>
        {glyph}
      </span>
    </span>
  );
}

interface FlapProps {
  current: number;
  previous: number;
  flips: number;
  /** Steps from where the message started to its target, printed under the flap in xray. */
  steps?: number;
  moving: boolean;
}

/**
 * One character: four half-cards. The static halves show the new glyph on top and the old one
 * below; the two leaves, hinged on the middle line, carry the old top down and the new bottom
 * in. Each step flips the parity, which swaps the leaves to an identical pair of keyframes and
 * so restarts them.
 */
const Flap = memo(function Flap({ current, previous, flips, steps, moving }: FlapProps) {
  const next = DRUM[current];
  const old = DRUM[previous];
  const parity = flips === 0 ? undefined : flips % 2 === 0 ? "b" : "a";

  return (
    <span className={styles.cell}>
      <span className={styles.flap} data-parity={parity}>
        <Half glyph={next} half="top" className={styles.staticTop} />
        <span className={styles.staticBottom}>
          <span className={styles.glyph} data-half="bottom" data-mark={isMark(old) || undefined}>
            {old}
          </span>
          <span className={styles.shade} />
        </span>
        <Half glyph={old} half="top" className={styles.leafTop} />
        <Half glyph={next} half="bottom" className={styles.leafBottom} />
        <span className={styles.hinge} />
      </span>
      {steps !== undefined && (
        <span className={styles.steps} data-moving={moving || undefined}>
          {String(steps).padStart(2, "0")}
        </span>
      )}
    </span>
  );
});

export interface SplitFlapBoardProps {
  state: BoardState;
  targets: readonly number[];
  flipMs: number;
  /** Per flap, steps from the message's start to its target: shown under the flaps (xray). */
  steps?: readonly number[];
  /** The lines as they read once landed, for assistive tech (the flaps are decorative). */
  label: string;
}

/** A departure board in CSS 3D: rows of flaps, sized on the board's own width. */
export function SplitFlapBoard({ state, targets, flipMs, steps, label }: SplitFlapBoardProps) {
  const rows = Math.ceil(state.current.length / BOARD_COLS);

  return (
    <div
      className={styles.board}
      style={assignInlineVars({ [styles.flipDuration]: `${flipMs}ms` })}
    >
      <span className={styles.srOnly}>{label}</span>
      {Array.from({ length: rows }, (_, row) => (
        <span key={row} className={styles.row} aria-hidden="true">
          {state.current.slice(row * BOARD_COLS, (row + 1) * BOARD_COLS).map((current, col) => {
            const index = row * BOARD_COLS + col;
            return (
              <Flap
                // A flap is its position on the board.
                // oxlint-disable-next-line react/no-array-index-key
                key={index}
                current={current}
                previous={state.previous[index]}
                flips={state.flips[index]}
                steps={steps?.[index]}
                moving={current !== targets[index]}
              />
            );
          })}
        </span>
      ))}
    </div>
  );
}
split-flap-board.component.tsx · 110 lines · 3.7K · tsx_Source_