Split Flap Board

Split flap

A split-flap departure-board display for short status strings — each character cell hinges its top half down over the bottom on a hard-creased 3D flip, cycling through a short burst of glyphs with per-cell stagger so a text change ripples left-to-right like clattering airport signage.

Install
npx shadcn add https://design.helpmarq.com/r/split-flap-board.json
Source
registry/core/split-flap-board/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// SolariFlap — a mechanical split-flap (Solari board) display for short
// status strings. Each character cell is FOUR stacked layers, not a single
// rolling digit (that's counter-carry-ripple's territory):
//   - a static bottom plate (permanently shows the settled char's bottom
//     half, updated mid-flip)
//   - a static "under" plate behind the flap (only ever exposed by the
//     hover-peek lift; holds a plausible upcoming glyph)
//   - the flap itself: one absolutely-positioned div with two opposite
//     faces (front = outgoing glyph's top half, back = incoming glyph's
//     top half, pre-rotated 180deg) that rotates -180deg on a real 3D
//     hinge (transform-origin: bottom). Because the back face is baked
//     to rotateX(180deg), driving the parent from 0 to -180 makes the
//     back face the one facing the camera in the second half of the
//     swing — the classic double-sided flip-card trick, done with a
//     single element instead of two.
// A value change schedules each cell through a short chain of quick
// flips (3-6 steps through random charset glyphs, landing on the target
// on the last one) so it reads as clattering machinery, not a lookup.
// Per-cell start times are staggered by a random 20-40ms offset times the
// cell's index, so the ripple visibly travels left-to-right. All of this
// is direct DOM writes on refs (style.transform/transition set
// imperatively, no React state on the per-flip hot path) — only the
// settled `displayed` char lives on a plain object, not state.
//
// Hover (mouse only, this is a status display, not a control): entering
// the board pauses further step-scheduling for every cell (in-flight
// steps still finish, but no new ones start) until the pointer leaves;
// entering one cell additionally lifts that cell's flap 22deg on its
// hinge, exposing the static "under" plate behind it as a peek at an
// upcoming glyph. Cells are real <button> elements (so a plain pointer
// hover is genuinely detectable and the lift is a real, testable state
// change) but are aria-hidden and not tab-reachable: the graphic is
// decorative, the accessible surface is a single aria-live=polite
// status region that announces only the fully-settled final string
// (never the intermediate cycling glyphs), so a screen reader hears one
// clean update per value change instead of a burst of static.
//
// prefers-reduced-motion: every cell jumps straight to its target glyph,
// no cycling, no flap rotation — the split line and two-plate structure
// stay, so it still reads as a (static) flap board, just not animating.
// ---------------------------------------------------------------------------

export interface SolariFlapProps {
  /** The string to display. One cell is rendered per character. */
  value: string;
  /** Glyphs cycled through mid-flip and used for the hover-peek plate. */
  charset?: string;
  /** Cell width in px. Default 34. */
  cellWidth?: number;
  /** Cell height in px. Default 50. */
  cellHeight?: number;
  className?: string;
}

const DEFAULT_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .:-";
const FLIP_MS = 150;
const HOLD_MS = 45;
const MIN_STEPS = 3;
const MAX_STEPS = 6;
const STAGGER_MIN_MS = 20;
const STAGGER_MAX_MS = 40;
const HOVER_LIFT_DEG = 22;
const HOVER_MS = 160;
const FLIP_EASE = "cubic-bezier(0.61, 0, 0.4, 1)";
const HOVER_EASE = "cubic-bezier(0.16, 1, 0.3, 1)";

interface CellHandle {
  leaf: HTMLDivElement | null;
  front: HTMLSpanElement | null;
  back: HTMLSpanElement | null;
  bottom: HTMLSpanElement | null;
  under: HTMLSpanElement | null;
  displayed: string;
  hovering: boolean;
  token: number;
}

function pick(charset: string): string {
  return charset[Math.floor(Math.random() * charset.length)] ?? " ";
}

function makeCell(ch: string): CellHandle {
  return {
    leaf: null,
    front: null,
    back: null,
    bottom: null,
    under: null,
    displayed: ch,
    hovering: false,
    token: 0,
  };
}

export function SolariFlap({
  value,
  charset = DEFAULT_CHARSET,
  cellWidth = 34,
  cellHeight = 50,
  className = "",
}: SolariFlapProps) {
  const chars = value.split("");

  const cellsRef = useRef<CellHandle[]>(chars.map((ch) => makeCell(ch)));
  if (cellsRef.current.length !== chars.length) {
    cellsRef.current = chars.map((ch, i) => cellsRef.current[i] ?? makeCell(ch));
  }

  const reducedRef = useRef(false);
  const boardPausedRef = useRef(false);
  const prevValueRef = useRef(value);
  const finalizeTimerRef = useRef<number | undefined>(undefined);
  const [announceText, setAnnounceText] = useState(value);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onChange = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  const landCell = (cell: CellHandle, ch: string) => {
    if (cell.leaf) {
      cell.leaf.style.transition = "none";
      cell.leaf.style.transform = cell.hovering ? `rotateX(${HOVER_LIFT_DEG}deg)` : "rotateX(0deg)";
    }
    if (cell.front) cell.front.textContent = ch;
    if (cell.bottom) cell.bottom.textContent = ch;
    if (cell.under) cell.under.textContent = pick(charset);
    cell.displayed = ch;
  };

  const runStep = (cell: CellHandle, targetCh: string, reduced: boolean) => {
    if (reduced || !cell.leaf || !cell.front || !cell.back) {
      landCell(cell, targetCh);
      return;
    }
    cell.back.textContent = targetCh;
    cell.leaf.style.transition = "none";
    cell.leaf.style.transform = "rotateX(0deg)";
    void cell.leaf.offsetHeight; // force reflow so the next transition applies
    cell.leaf.style.transition = `transform ${FLIP_MS}ms ${FLIP_EASE}`;
    cell.leaf.style.transform = "rotateX(-180deg)";
    window.setTimeout(() => {
      if (cell.bottom) cell.bottom.textContent = targetCh;
    }, FLIP_MS / 2);
    window.setTimeout(() => {
      landCell(cell, targetCh);
    }, FLIP_MS);
  };

  const scheduleCell = (index: number, targetCh: string) => {
    const cell = cellsRef.current[index];
    if (!cell) return;
    const myToken = ++cell.token;
    if (reducedRef.current) {
      runStep(cell, targetCh, true);
      return;
    }
    const steps = MIN_STEPS + Math.floor(Math.random() * (MAX_STEPS - MIN_STEPS + 1));
    const stagger = (STAGGER_MIN_MS + Math.random() * (STAGGER_MAX_MS - STAGGER_MIN_MS)) * (index + 1);
    let i = 0;
    const next = () => {
      if (cell.token !== myToken) return;
      if (boardPausedRef.current) {
        window.setTimeout(next, 90);
        return;
      }
      const isLast = i === steps - 1;
      runStep(cell, isLast ? targetCh : pick(charset), false);
      i++;
      if (i < steps) window.setTimeout(next, FLIP_MS + HOLD_MS);
    };
    window.setTimeout(next, stagger);
  };

  useEffect(() => {
    const prev = prevValueRef.current;
    prevValueRef.current = value;
    if (prev === value) return;

    chars.forEach((ch, i) => {
      if (cellsRef.current[i] && cellsRef.current[i]!.displayed !== ch) {
        scheduleCell(i, ch);
      }
    });

    window.clearTimeout(finalizeTimerRef.current);
    const settleDelay = reducedRef.current
      ? 0
      : STAGGER_MAX_MS * chars.length + (FLIP_MS + HOLD_MS) * MAX_STEPS + 120;
    finalizeTimerRef.current = window.setTimeout(() => setAnnounceText(value), settleDelay);
    return () => window.clearTimeout(finalizeTimerRef.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [value]);

  const handleBoardEnter = () => {
    boardPausedRef.current = true;
  };
  const handleBoardLeave = () => {
    boardPausedRef.current = false;
  };

  const setHover = (cell: CellHandle, hovering: boolean) => {
    if (reducedRef.current) return;
    cell.hovering = hovering;
    if (!cell.leaf) return;
    cell.leaf.style.transition = `transform ${HOVER_MS}ms ${HOVER_EASE}, box-shadow ${HOVER_MS}ms ${HOVER_EASE}`;
    cell.leaf.style.transform = hovering ? `rotateX(${HOVER_LIFT_DEG}deg)` : "rotateX(0deg)";
    // The rotation alone is a 22deg tilt on a ~25px leaf; a growing
    // cast shadow underneath is the legible "lifted off the plate" cue a
    // real gap between flap and plate would throw.
    cell.leaf.style.boxShadow = hovering ? "0 5px 8px -1px rgba(0,0,0,0.55)" : "none";
  };

  return (
    <div
      className={`ns-sf-board ${className}`}
      onPointerEnter={handleBoardEnter}
      onPointerLeave={handleBoardLeave}
    >
      <style>{CSS}</style>
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announceText}
      </span>
      <div className="ns-sf-row">
        {chars.map((ch, i) => (
          <button
            key={i}
            type="button"
            tabIndex={-1}
            aria-hidden="true"
            className="ns-sf-cell"
            style={
              {
                "--sf-w": `${cellWidth}px`,
                "--sf-h": `${cellHeight}px`,
              } as React.CSSProperties
            }
            onPointerEnter={() => setHover(cellsRef.current[i]!, true)}
            onPointerLeave={() => setHover(cellsRef.current[i]!, false)}
          >
            <span
              className="ns-sf-plate ns-sf-under"
              ref={(el) => {
                if (cellsRef.current[i]) cellsRef.current[i]!.under = el;
              }}
            >
              {ch}
            </span>
            <span
              className="ns-sf-plate ns-sf-bottom"
              ref={(el) => {
                if (cellsRef.current[i]) cellsRef.current[i]!.bottom = el;
              }}
            >
              {ch}
            </span>
            <div className="ns-sf-leaf-clip">
              <div
                className="ns-sf-leaf"
                ref={(el) => {
                  if (cellsRef.current[i]) cellsRef.current[i]!.leaf = el;
                }}
              >
                <span
                  className="ns-sf-face ns-sf-face-front"
                  ref={(el) => {
                    if (cellsRef.current[i]) cellsRef.current[i]!.front = el;
                  }}
                >
                  {ch}
                </span>
                <span
                  className="ns-sf-face ns-sf-face-back"
                  ref={(el) => {
                    if (cellsRef.current[i]) cellsRef.current[i]!.back = el;
                  }}
                >
                  {ch}
                </span>
              </div>
            </div>
          </button>
        ))}
      </div>
    </div>
  );
}

const CSS = `
.ns-sf-board{display:inline-block;}
.ns-sf-row{display:flex;gap:3px;}
.ns-sf-cell{
  position:relative;
  width:var(--sf-w);
  height:var(--sf-h);
  padding:0;
  border:1px solid var(--border);
  border-radius:4px;
  background:var(--foreground);
  overflow:hidden;
  perspective:70px;
  cursor:default;
  -webkit-tap-highlight-color:transparent;
}
.ns-sf-plate{
  position:absolute;
  left:0;
  width:100%;
  height:50%;
  overflow:hidden;
  display:flex;
  justify-content:center;
  font-family:var(--font-geist-mono, ui-monospace, monospace);
  font-size:calc(var(--sf-h) * 0.52);
  line-height:var(--sf-h);
  color:var(--background);
}
.ns-sf-under{ top:0; align-items:flex-start; }
.ns-sf-bottom{
  bottom:0;
  align-items:flex-end;
  border-top:1px solid var(--background);
  box-shadow:inset 0 5px 6px -3px color-mix(in srgb, var(--background) 60%, transparent);
}
.ns-sf-leaf-clip{
  position:absolute;
  top:0;
  left:0;
  width:100%;
  height:50%;
  overflow:hidden;
  z-index:2;
}
.ns-sf-leaf{
  position:absolute;
  top:0;
  left:0;
  width:100%;
  height:100%;
  transform-style:preserve-3d;
  transform-origin:bottom center;
  transform:rotateX(0deg);
}
.ns-sf-face{
  position:absolute;
  top:0;
  left:0;
  width:100%;
  height:var(--sf-h);
  display:flex;
  align-items:center;
  justify-content:center;
  font-family:var(--font-geist-mono, ui-monospace, monospace);
  font-size:calc(var(--sf-h) * 0.52);
  color:var(--background);
  backface-visibility:hidden;
  -webkit-backface-visibility:hidden;
}
.ns-sf-face-front{ box-shadow:inset 0 -5px 6px -2px color-mix(in srgb, var(--background) 50%, transparent); }
.ns-sf-face-back{ transform:rotateX(180deg); box-shadow:inset 0 5px 6px -2px color-mix(in srgb, var(--background) 50%, transparent); }
@media (prefers-reduced-motion: reduce){
  .ns-sf-leaf{ transition:none !important; }
}
`;
Build spec

Build a Solari-style split-flap departure-board display, driven by a `value: string` prop (one character cell per string index) plus an optional `charset` prop (default `A-Z0-9 .:-`) used both for the mid-flip cycling glyphs and the hover-peek plate. Each cell is FOUR stacked absolutely-positioned layers inside a `perspective`-bearing `<button type="button" tabIndex={-1} aria-hidden="true">` (a real, hoverable element for interaction purposes, but excluded from the accessibility tree and the tab order because the component's real interface is a live-region announcement, not a control): (1) a static top 'under' plate, only ever exposed by the hover-peek lift, holding a plausible upcoming glyph; (2) a static bottom plate showing the settled character's bottom half, height 50%, overflow hidden, vertically anchored so only the lower half of a full-height glyph shows, with a 1px var(--background) top border plus an inset box-shadow to read as a hairline crease with real depth; (3) the flap itself — one div, height 50%, `transform-style: preserve-3d`, `transform-origin: bottom center`, containing exactly two child spans sized to the FULL cell height (so the clipping 50%-height flap only ever shows the upper half of whichever face is centered): a front face at `rotateX(0deg)` showing the outgoing glyph's top half, and a back face pre-rotated `rotateX(180deg)` in CSS showing the incoming glyph's top half, both `backface-visibility: hidden`. Rotating the flap's own transform from `rotateX(0deg)` to `rotateX(-180deg)` makes the front face visible for the first half of the sweep and the back face visible for the second half — a single element does the double-sided flip-card trick, no second layer needed. Cell plates use `background: var(--foreground)` with glyph `color: var(--background)` — an inverted ink chip that reads as a genuinely dark plate in light mode and a bright, high-contrast placard in dark mode, monochrome throughout, no gradients, hairline `var(--border)` cell outline. On every `value` change, diff old vs new per character index; for each changed cell, schedule a short chain of 3-6 quick flip steps (150ms rotation each + 45ms hold, `cubic-bezier(0.61,0,0.4,1)`), each step landing on a random `charset` glyph except the final step which lands on the target character — this reads as clattering machinery cycling through possibilities, not a lookup swap. Stagger each cell's chain start by `(20 + random*20)ms * (index+1)` so the change visibly ripples left to right. Mid-flip, update the static bottom plate's text at the halfway point of the rotation (matching the moment the flap is edge-on and invisible) rather than at the step's start, so the bottom half changes in sync with the flap passing vertical, exactly like the physical mechanism. All of this is direct-DOM `style.transform`/`style.transition` writes via refs on the flap element and text-content writes on the plate spans — nothing touches React state on the per-flip hot path; only the fully-settled character per cell lives in a plain ref object. Hover: entering the board's outer wrapper sets a paused flag that blocks any NEW step in a chain from starting (an in-flight step still finishes) until the pointer leaves, i.e. hovering the board freezes further clattering at the next natural step boundary. Entering an individual cell additionally rotates that cell's flap to `rotateX(22deg)` over 160ms (`cubic-bezier(0.16,1,0.3,1)`) — lifting its bottom-hinged edge up and back, which, given the perspective on the cell, visibly exposes the static 'under' plate sitting behind it (a plausible upcoming glyph, refreshed to a new random charset pick every time a flip lands) — and returns to flat on pointer-leave. Accessibility: the ENTIRE visual board is `aria-hidden`; the only accessible surface is one `role=status aria-live=polite aria-atomic=true` sr-only span holding the value. It updates ONLY once cycling has fully settled (a debounce timer sized to the worst-case stagger+chain-length for the current cell count, cleared and restarted on every new value change) so a screen reader hears exactly one clean announcement of the final string per change, never the intermediate cycling glyphs. `prefers-reduced-motion: reduce` (checked via `matchMedia` with a change listener) skips cycling and the flap rotation entirely: every cell jumps straight to its target glyph on both plates and the flap's front face, no transition, while the crease line and two-plate structure stay in place so it still visually reads as a (static) flap board. Zero dependencies, no SVG, no canvas — pure CSS 3D transforms on plain divs/spans/buttons.

Tags
split-flapdeparture-boardstatusaria-livemechanicaltypography3d-transformsolari