Domphy

textFlippingBoard

A Text block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call textFlippingBoard() with no arguments for a working demo, or edit the code below live.

Implementation notes

Grid of dark (dataTone shift-17) tiles with a monospace bold uppercase glyph and a horizontal hinge seam line each, laid out in rows that word-wrap from text (own additional columns prop, since the spec didn't specify the wrap width knob) or take manual rows with a {tag}-bracket accent-tint syntax (this implementation's own concrete reading of the docs' vague "'{O}'-style" note). Each tile's flip is a chained setTimeout queue (not WAAPI, so it works in headless/test DOM too) toggling rotateX between 0/+-90deg via plain CSS transitions, using the standard flip-clock reflow trick to swap the glyph mid-rotation; total step count comes from a fixed glyph sequence (2 full cycles + distance-to-target) so 'further' targets genuinely take more steps, while a fixed per-tile time budget after a staggered start delay keeps every tile settling around the same overall duration. Optional sound synthesizes a short square-wave 'clack' via the Web Audio API (throttled) rather than an audio file, per the spec's own prop description and since this package ships no audio assets. Exact colors/tile size/font are reasonable defaults (moderate confidence) since upstream's rendered demo wasn't pixel-inspectable; the overall grid/flip-cascade mechanism has higher confidence given the explicit Vestaboard framing in the spec.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Text Flipping Board" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). An
// airport/train-station split-flap character board: a grid of dark tiles
// spells out a message, each tile rapidly cycling through random glyphs
// before mechanically settling on its target letter, staggered across the
// grid so the board resolves in a cascading ripple rather than all at once.
//
// Each tile's flip is a JS timer/queue (not a single WAAPI keyframe, since
// different tiles need a different number of intermediate stops to reach
// their own target glyph): a chained `setTimeout` sequence toggles the
// character element's own `rotateX` between `0deg` and `±90deg` via a plain
// CSS `transition`, swapping the displayed glyph at the midpoint of each
// half-step — the classic flip-clock trick: jump straight to the opposite
// `+90deg` with the transition briefly disabled, force a reflow (the same
// "read `offsetHeight` to commit a style change before re-enabling the
// transition" idiom `directionAwareHover.ts` already uses elsewhere in this
// package), then transition back to `0deg` so the new glyph appears to
// rotate INTO place. This needs no Web Animations API support at all
// (unlike this package's own `motion()` patch), so it works even in
// headless/test DOM environments with no real `Element.animate` — the same
// portability tradeoff `hyperText.ts`'s own `setInterval`-driven scramble
// loop makes elsewhere in this package.
//
// Per-tile step COUNT is derived from a fixed glyph cycling sequence: every
// tile runs a couple of full loops through the sequence plus however many
// extra steps its own target glyph sits further along that sequence — so
// "further away" glyphs genuinely take more steps, per the spec. Because
// each tile's own flip TIME budget is a fixed share of the total `duration`
// (after its own staggered start delay), a tile with more steps just flips
// through them faster (shorter per-step duration) so every tile still
// settles around the same overall moment.
//
// The optional "clack" sound is synthesized on the fly with the Web Audio
// API (a short decaying square-wave burst) rather than an audio file — the
// spec's own prop description ("boolean to enable synthesized flap sound
// effects") calls for exactly this, and this package ships no audio assets.
//
// FIDELITY NOTE (per the task's own researchNote): exact default colors,
// tile size/gap, and font could not be pixel-verified (client-rendered demo,
// only the props table/description were retrievable) — this implementation
// uses this package's own dark-tile idiom (edge-anchored `dataTone`) and a
// monospace display face, which is a reasonable default for the described
// Vestaboard-style look, not a confirmed 1:1 visual match. The `{tag}` inline
// per-tile accent syntax within `rows` is this implementation's own concrete
// reading of the docs' "`{O}`-style" note — wrap any run of characters in
// `{}` to tint just those tiles `accentColor` instead of the default color.

import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { type ThemeColor, themeColor, themeSize, themeSpacing } from "@domphy/theme";

export interface TextFlippingBoardProps {
  /** Message to spell out. Supports `\n` for explicit line breaks and is automatically
   * word-wrapped onto further rows past `columns`. Ignored when `rows` is given. Defaults to a
   * short demo message. */
  text?: string;
  /** Manual per-row content, taking precedence over `text`. Wrap a run of characters in `{}` to
   * tint just those tiles `accentColor` instead of the default color, e.g. `"WELCOME {HOME}"`. */
  rows?: string[];
  /** Maximum characters per row before `text` word-wraps onto the next row. Only used when
   * wrapping `text` (ignored when `rows` is given). Defaults to `16`. */
  columns?: number;
  /** Total ms budget for the whole board's cascading settle. Defaults to `1200`. */
  duration?: number;
  /** Plays a short synthesized mechanical "clack" on flip steps via the Web Audio API. Defaults to `false`. */
  sound?: boolean;
  /** Theme color family for `{}`-tagged accent tiles. Defaults to `"warning"` (reads as orange). */
  accentColor?: ThemeColor;
  /** Extra class name merged onto the outer board's native `class` attribute. */
  className?: string;
  /** Passthrough style merged onto the outer board container. */
  style?: StyleObject;
}

interface TileSpec {
  char: string;
  accent: boolean;
  blank: boolean;
}

const GLYPH_SEQUENCE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?'-";
const FULL_CYCLES_BEFORE_SETTLE = 2;
const MIN_STEP_DURATION_MS = 30;
const STAGGER_FRACTION_OF_DURATION = 0.4;
const SOUND_THROTTLE_MS = 35;
const DEFAULT_TEXT = "DOMPHY BLOCKS ARE HERE";

let textFlippingBoardInstanceCounter = 0;

/** Splits a row string into tiles, treating any run of characters wrapped in `{}` as
 * accent-tagged (the braces themselves are markers, not rendered as their own tiles). */
function parseTaggedRow(row: string): TileSpec[] {
  const tiles: TileSpec[] = [];
  let insideAccent = false;
  for (const character of row.toUpperCase()) {
    if (character === "{") {
      insideAccent = true;
      continue;
    }
    if (character === "}") {
      insideAccent = false;
      continue;
    }
    tiles.push({ char: character, accent: insideAccent, blank: character === " " });
  }
  return tiles;
}

/** Greedy word-wrap: explicit `\n` breaks are always honored; each resulting line is then
 * further split so no row exceeds `maxColumns` characters. */
function wrapTextIntoRows(text: string, maxColumns: number): string[] {
  const rows: string[] = [];
  for (const explicitLine of text.split("\n")) {
    const words = explicitLine.split(" ").filter((word) => word.length > 0);
    if (words.length === 0) {
      rows.push("");
      continue;
    }
    let currentRow = "";
    for (const word of words) {
      const candidate = currentRow.length === 0 ? word : `${currentRow} ${word}`;
      if (candidate.length > maxColumns && currentRow.length > 0) {
        rows.push(currentRow);
        currentRow = word;
      } else {
        currentRow = candidate;
      }
    }
    rows.push(currentRow);
  }
  return rows;
}

let sharedFlapAudioContext: AudioContext | null = null;
let lastFlapSoundAt = 0;

/** Synthesizes one short mechanical "clack" via a decaying square-wave burst. Best-effort:
 * silently no-ops on unsupported/non-browser environments or autoplay-policy rejections. */
function playFlapClackSound(): void {
  if (typeof window === "undefined") return;
  const now = typeof performance !== "undefined" ? performance.now() : Date.now();
  if (now - lastFlapSoundAt < SOUND_THROTTLE_MS) return;
  lastFlapSoundAt = now;
  try {
    const AudioContextConstructor =
      window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
    if (!AudioContextConstructor) return;
    sharedFlapAudioContext = sharedFlapAudioContext ?? new AudioContextConstructor();
    const audioContext = sharedFlapAudioContext;
    const startTime = audioContext.currentTime;
    const oscillator = audioContext.createOscillator();
    const gainNode = audioContext.createGain();
    oscillator.type = "square";
    oscillator.frequency.setValueAtTime(190, startTime);
    gainNode.gain.setValueAtTime(0.04, startTime);
    gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + 0.035);
    oscillator.connect(gainNode);
    gainNode.connect(audioContext.destination);
    oscillator.start(startTime);
    oscillator.stop(startTime + 0.035);
  } catch {
    // Best-effort only — ignore autoplay-policy rejections or unsupported environments.
  }
}

function randomGlyph(): string {
  return GLYPH_SEQUENCE.charAt(Math.floor(Math.random() * GLYPH_SEQUENCE.length));
}

/** Runs one tile's staggered scramble-then-settle flip sequence, registering every timeout it
 * schedules into `pendingTimeouts` so the board can cancel them all on unmount. */
function scheduleTileFlip(
  characterElement: HTMLElement,
  targetChar: string,
  startDelayMs: number,
  flipBudgetMs: number,
  sound: boolean,
  pendingTimeouts: Set<ReturnType<typeof setTimeout>>,
): void {
  const targetIndex = GLYPH_SEQUENCE.indexOf(targetChar);
  const distanceSteps = targetIndex === -1 ? 0 : targetIndex;
  const totalSteps = Math.max(1, FULL_CYCLES_BEFORE_SETTLE * GLYPH_SEQUENCE.length + distanceSteps);
  const halfStepMs = Math.max(MIN_STEP_DURATION_MS / 2, flipBudgetMs / totalSteps / 2);

  const startTimeout = setTimeout(() => {
    let completedSteps = 0;

    function runStep(): void {
      const isFinalStep = completedSteps === totalSteps - 1;
      const nextChar = isFinalStep ? targetChar : randomGlyph();

      characterElement.style.transitionDuration = `${halfStepMs}ms`;
      characterElement.style.transform = "rotateX(-90deg)";

      const flipDownTimeout = setTimeout(() => {
        characterElement.textContent = nextChar;
        if (sound) playFlapClackSound();
        // Jump straight to the mirrored +90deg with the transition disabled,
        // force a reflow to commit that jump, THEN restore the transition
        // and animate back to 0deg — otherwise the browser would animate
        // straight through the jump instead of appearing to flip INTO place.
        characterElement.style.transitionDuration = "0ms";
        characterElement.style.transform = "rotateX(90deg)";
        void characterElement.offsetHeight;
        characterElement.style.transitionDuration = `${halfStepMs}ms`;
        characterElement.style.transform = "rotateX(0deg)";

        const flipUpTimeout = setTimeout(() => {
          completedSteps += 1;
          if (completedSteps < totalSteps) runStep();
        }, halfStepMs);
        pendingTimeouts.add(flipUpTimeout);
      }, halfStepMs);
      pendingTimeouts.add(flipDownTimeout);
    }

    runStep();
  }, startDelayMs);
  pendingTimeouts.add(startTimeout);
}

function tileElement(
  tile: TileSpec,
  tileKey: string,
  startDelayMs: number,
  flipBudgetMs: number,
  sound: boolean,
  accentColor: ThemeColor,
  pendingTimeouts: Set<ReturnType<typeof setTimeout>>,
): DomphyElement<"div"> {
  const initialGlyph = tile.blank ? null : randomGlyph();

  // `_doctorDisable` is a doctor-only annotation not present in core's
  // strict `PartialElement` type — build through an untyped literal, then
  // assert, so the excess-property check doesn't fire (mirrors
  // `dottedGlowBackground.ts`/`flickeringGrid.ts`).
  const characterElement = {
    div: initialGlyph,
    // Bold, uppercase, fixed-width glyph display — `@domphy/theme` has no
    // font-family token (AGENTS.md: "fontFamily -> remove entirely, theme
    // owns the font stack"), so the monospace face is a narrow, documented
    // exception, same as `evervaultCard.ts`'s character grid. Also exempt
    // from missing-color: this glyph inherits its `color` from the tile
    // wrapper's own `dataTone`-contract color one level up, by design.
    _doctorDisable: ["inline-typography", "missing-color"],
    style: {
      fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
      fontWeight: 700,
      fontSize: (listener) => themeSize(listener, "increase-1"),
      lineHeight: "1",
      transformOrigin: "center",
      transitionProperty: "transform",
      transitionTimingFunction: "linear",
      willChange: "transform",
    } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (tile.blank) return;
      const domElement = node.domElement as HTMLElement;
      scheduleTileFlip(domElement, tile.char, startDelayMs, flipBudgetMs, sound, pendingTimeouts);
    },
  } as DomphyElement<"div">;

  const seamLine = {
    div: null,
    ariaHidden: "true",
    // Decorative hinge seam line with no text of its own — exempt from the
    // missing-color contract, matching this package's other purely
    // decorative accent lines (e.g. `heroHighlight.ts`'s marker bar). Also
    // exempt from tone-background-inherit: the seam's fixed accent tint is
    // intentional, not a surface (same reasoning `glowingStars.ts`/
    // `shootingStars.ts` document for their own decorative accents).
    _doctorDisable: ["missing-color", "tone-background-inherit"],
    style: {
      position: "absolute",
      insetInline: 0,
      top: "50%",
      height: "1px",
      opacity: 0.6,
      backgroundColor: (listener) => themeColor(listener, "shift-14"),
    } as StyleObject,
  } as DomphyElement<"div">;

  return {
    div: [characterElement, seamLine],
    _key: tileKey,
    // Edge-anchored dark tile surface — this package's standard convention
    // for a small elevated dark card (see `evervaultCard.ts`/`spotlightDual.ts`).
    dataTone: "shift-17",
    style: {
      position: "relative",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      overflow: "hidden",
      perspective: "300px",
      width: themeSpacing(9),
      height: themeSpacing(11),
      borderRadius: themeSpacing(1),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-11", tile.accent ? accentColor : "neutral"),
    } as StyleObject,
  };
}

/**
 * An airport/train-station split-flap character board: a grid of dark tiles
 * spells out a message, each tile scrambling through random glyphs before
 * mechanically settling on its target letter, staggered across the grid so
 * the board resolves in a cascading ripple. Call with no arguments for a
 * working demo — a short two-row message.
 */
function textFlippingBoard(props: TextFlippingBoardProps = {}): DomphyElement<"div"> {
  const durationMs = Math.max(200, props.duration ?? 1200);
  const sound = props.sound ?? false;
  const accentColor = props.accentColor ?? "warning";
  const columns = Math.max(4, Math.round(props.columns ?? 16));

  const rowStrings = props.rows && props.rows.length > 0 ? props.rows : wrapTextIntoRows((props.text ?? DEFAULT_TEXT).toUpperCase(), columns);

  const rows: TileSpec[][] = rowStrings.map(parseTaggedRow);
  const totalTiles = rows.reduce((sum, row) => sum + row.length, 0);
  const staggerWindowMs = durationMs * STAGGER_FRACTION_OF_DURATION;
  const perTileStaggerMs = totalTiles > 1 ? staggerWindowMs / (totalTiles - 1) : 0;
  const flipBudgetMs = Math.max(durationMs - staggerWindowMs, durationMs * 0.3);

  const instanceId = ++textFlippingBoardInstanceCounter;
  const pendingTimeouts = new Set<ReturnType<typeof setTimeout>>();
  let tileSequenceIndex = 0;

  const rowElements: DomphyElement<"div">[] = rows.map((row, rowIndex) => ({
    div: row.map((tile, columnIndex) => {
      const startDelayMs = tileSequenceIndex * perTileStaggerMs;
      tileSequenceIndex += 1;
      return tileElement(tile, `tile-${instanceId}-${rowIndex}-${columnIndex}`, startDelayMs, flipBudgetMs, sound, accentColor, pendingTimeouts);
    }),
    _key: `row-${instanceId}-${rowIndex}`,
    style: { display: "flex", justifyContent: "center", gap: themeSpacing(1) } as StyleObject,
  }));

  return {
    div: rowElements,
    class: props.className,
    style: {
      display: "flex",
      flexDirection: "column",
      alignItems: "center",
      gap: themeSpacing(1),
      ...(props.style ?? {}),
    } as StyleObject,
    _onRemove: () => {
      for (const timeoutId of pendingTimeouts) clearTimeout(timeoutId);
      pendingTimeouts.clear();
    },
  } as DomphyElement<"div">;
}

export { textFlippingBoard };

← Back to Aceternity UI catalog