> ## Documentation Index
> Fetch the complete documentation index at: https://hyperframes.heygen.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Split-Flap Board

> Solari-style departure board: every cell walks its drum forward through the intervening characters of a fixed alphabet until it lands on its target, so the text resolves as a mechanical cascade instead of a fade.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="split-flap-board preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/blocks/split-flap-board.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/split-flap-board.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add split-flap-board" />

That writes one file: `compositions/split-flap-board.html`.

## Add it to your video

It runs for 3.5 seconds at 1920×1080. Paste this into your composition:

```html index.html theme={null}
<div
  data-composition-id="split-flap-board"
  data-composition-src="compositions/split-flap-board.html"
  data-start="0"
  data-duration="3.5"
  data-track-index="1"
  data-width="1920"
  data-height="1080"
></div>
```

Move it in time with `data-start`. Put it on a different timeline row with
`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.

## Variables

Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:

| Variable       | Default                                    | Accepts                   | What it does |
| -------------- | ------------------------------------------ | ------------------------- | ------------ |
| `boardText`    | `NOW BOARDING`                             | string                    |              |
| `flapAlphabet` | ` ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,'` | string                    |              |
| `flipDuration` | `0.09`                                     | 0.03s to 1.2s, step 0.01s |              |
| `cellStagger`  | `0.05`                                     | 0s to 0.5s, step 0.01s    |              |
| `cellCount`    | `12`                                       | 1 to 24, step 1           |              |

Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:

```html wrap theme={null}
<div
  data-composition-id="split-flap-board"
  data-composition-src="compositions/split-flap-board.html"
  data-variable-values='{"boardText":"NOW BOARDING","flapAlphabet":" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,'","flipDuration":0.09,"cellStagger":0.05,"cellCount":12}'
></div>
```

## Source

<Accordion title={`split-flap-board.html`}>
  ```html theme={null}
  <!doctype html>
  <html
    lang="en"
    data-composition-variables='[
      {"id":"boardText","type":"string","label":"Board text","default":"NOW BOARDING","maxLength":24},
      {"id":"flapAlphabet","type":"string","label":"Flap alphabet (drum order)","default":" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,&#39;","maxLength":64},
      {"id":"flipDuration","type":"number","label":"Seconds per flap","default":0.09,"min":0.03,"max":1.2,"step":0.01,"unit":"s"},
      {"id":"cellStagger","type":"number","label":"Per-cell start stagger","default":0.05,"min":0,"max":0.5,"step":0.01,"unit":"s"},
      {"id":"cellCount","type":"number","label":"Cells on the board","default":12,"min":1,"max":24,"step":1}
    ]'
  >
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=1920, height=1080" />
      <title>Split-Flap Board</title>
      <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
      <style>
        *,
        *::before,
        *::after {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
        }
        html,
        body {
          width: 1920px;
          height: 1080px;
          overflow: hidden;
          background: transparent;
        }
      </style>
    </head>
    <body>
      <div
        id="sf-root"
        data-composition-id="split-flap-board"
        data-start="0"
        data-duration="3.5"
        data-fps="30"
        data-width="1920"
        data-height="1080"
        style="position: relative; width: 1920px; height: 1080px; overflow: hidden"
      >
        <style>
          /* The root's own box lives in an inline style, not a rule: every
             sub-composition rule is rewritten to
             `[data-composition-id="split-flap-board"] <selector>` at render,
             which can never match the root element itself. */
          #sf-bg {
            position: absolute;
            inset: 0;
            background:
              radial-gradient(120% 90% at 50% 42%, #23242a 0%, #121317 62%, #0a0a0c 100%), #0a0a0c;
          }
          #sf-board {
            position: absolute;
            inset: 0;
            display: flex;
            align-items: center;
            justify-content: center;
          }
          .sf-cell {
            position: relative;
            flex: 0 0 auto;
            perspective: 900px;
            border-radius: 9px;
            background: #0c0c0e;
            box-shadow:
              0 18px 34px rgba(0, 0, 0, 0.55),
              0 0 0 1px rgba(255, 255, 255, 0.05);
          }
          .sf-face {
            position: absolute;
            left: 0;
            width: 100%;
            height: 50%;
            overflow: hidden;
            background: linear-gradient(180deg, #26272c 0%, #1a1b1f 100%);
          }
          .sf-top,
          .sf-leaf-a {
            top: 0;
            border-radius: 9px 9px 0 0;
          }
          .sf-bot,
          .sf-leaf-b {
            top: 50%;
            border-radius: 0 0 9px 9px;
            background: linear-gradient(180deg, #191a1e 0%, #232429 100%);
          }
          .sf-leaf-a {
            transform-origin: 50% 100%;
            z-index: 3;
            backface-visibility: hidden;
          }
          .sf-leaf-b {
            transform-origin: 50% 0%;
            z-index: 3;
            backface-visibility: hidden;
          }
          .sf-glyph {
            position: absolute;
            left: 0;
            width: 100%;
            display: block;
            text-align: center;
            font-family: ui-monospace, monospace;
            font-weight: 700;
            color: #f4efe2;
            white-space: pre;
          }
          .sf-seam {
            position: absolute;
            left: 0;
            top: 50%;
            width: 100%;
            height: 3px;
            margin-top: -1.5px;
            background: #050506;
            z-index: 4;
          }
        </style>

        <div id="sf-bg"></div>
        <div id="sf-board" class="clip" data-start="0" data-duration="3.5" data-track-index="0"></div>

        <script>
          (function () {
            window.__timelines = window.__timelines || {};

            // ---------------------------------------------------------------
            // Reference numbers (see report / research entry F#1)
            //
            // Alphabet: the 40-position drum from the scottbez1/splitflap
            // project (Apache-2.0, README read) — blank, A-Z, 0-9, then `.,'`.
            // Drum order is what makes the cascade read as a real board: a cell
            // never jumps, it walks the drum forward through every intervening
            // character, so letters resolve early and digits/punctuation late.
            //
            // Flip duration: the only published-ish figure is a DIY firmware
            // proxy (28BYJ-48 stepper, ~83.59 steps/flap, a=500 steps/s^2 ->
            // triangular profile -> ~1.16 s/flap). That is a hobby build, NOT a
            // Solari spec, and real boards audibly flutter at tens of ms per
            // flap. The default below sits in that observed band; the DIY proxy
            // is still reachable at the top of the `flipDuration` range.
            //
            // Flap fall: constant angular acceleration through 180 degrees,
            // theta(p) = 180 * p^2. The flap therefore hangs near the top and
            // slaps down — no ease-out, because a falling leaf does not
            // decelerate. The two CSS leaves are the two faces of that one flap
            // (hand-off at theta = 90, i.e. p = 0.707).
            // ---------------------------------------------------------------
            var DEFAULT_ALPHABET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,'";
            var DEG = Math.PI / 180;

            function num(value, min, max, fallback) {
              var n = typeof value === "number" ? value : parseFloat(value);
              if (!isFinite(n)) return fallback;
              return Math.min(max, Math.max(min, n));
            }

            var vars =
              window.__hyperframes && window.__hyperframes.getVariables
                ? window.__hyperframes.getVariables()
                : {};

            var alphabet = String(vars.flapAlphabet || DEFAULT_ALPHABET);
            if (alphabet.length < 2) alphabet = DEFAULT_ALPHABET;
            var N = alphabet.length;

            var flipDur = num(vars.flipDuration, 0.03, 1.2, 0.09);
            var stagger = num(vars.cellStagger, 0, 0.5, 0.05);
            var cellCount = Math.round(num(vars.cellCount, 1, 24, 12));

            var text = String(vars.boardText == null ? "NOW BOARDING" : vars.boardText)
              .toUpperCase()
              .slice(0, cellCount);
            // Centre the string across the board; unused cells stay on the
            // drum's first position (blank in the default alphabet).
            var lead = Math.floor((cellCount - text.length) / 2);
            var targets = [];
            for (var i = 0; i < cellCount; i++) {
              var ch =
                i >= lead && i < lead + text.length ? text.charAt(i - lead) : alphabet.charAt(0);
              var g = alphabet.indexOf(ch);
              targets.push(g < 0 ? 0 : g);
            }

            // Geometry: fit `cellCount` cells across 1920 with a 120px margin.
            var GAP = 10;
            var cw = Math.min(130, Math.floor((1920 - 240 - (cellCount - 1) * GAP) / cellCount));
            var chh = Math.round(cw * 1.45);

            var board = document.getElementById("sf-board");
            board.style.gap = GAP + "px";

            function makeFace(cls, glyphTop) {
              var face = document.createElement("div");
              face.className = "sf-face " + cls;
              var glyph = document.createElement("span");
              glyph.className = "sf-glyph";
              glyph.style.height = chh + "px";
              glyph.style.lineHeight = chh + "px";
              glyph.style.fontSize = Math.round(cw * 0.78) + "px";
              glyph.style.top = glyphTop + "px";
              face.appendChild(glyph);
              return face;
            }

            var cells = [];
            for (var c = 0; c < cellCount; c++) {
              var cell = document.createElement("div");
              cell.className = "sf-cell";
              cell.id = "sf-cell-" + c;
              cell.style.width = cw + "px";
              cell.style.height = chh + "px";
              // The mechanism IS clipped, stacked half-glyphs: each face holds a
              // whole character and shows half of it, and the falling leaf sits
              // on top of the face behind it. Declared intentional so the layout
              // auditor reports real regressions instead of the design.
              cell.setAttribute("data-layout-allow-overlap", "");
              cell.setAttribute("data-layout-allow-occlusion", "");
              cell.setAttribute("data-layout-allow-overflow", "");

              var top = makeFace("sf-top", 0);
              var bot = makeFace("sf-bot", -Math.round(chh / 2));
              var leafA = makeFace("sf-leaf-a", 0);
              var leafB = makeFace("sf-leaf-b", -Math.round(chh / 2));

              var seam = document.createElement("div");
              seam.className = "sf-seam";

              cell.appendChild(top);
              cell.appendChild(bot);
              cell.appendChild(leafA);
              cell.appendChild(leafB);
              cell.appendChild(seam);
              board.appendChild(cell);

              cells.push({
                // steps is unidirectional: the drum only ever spins forward,
                // start position is always the drum's first flap (index 0).
                steps: targets[c],
                top: top.firstChild,
                bot: bot.firstChild,
                leafA: leafA,
                leafB: leafB,
                glyphA: leafA.firstChild,
                glyphB: leafB.firstChild,
              });
            }

            // A face that is not in use must be wiped, not merely hidden.
            // Leaving a stale transform/filter/glyph on an opacity-0 leaf makes
            // the DOM (and, via layer promotion, the rasterised pixels) depend
            // on which frames were visited before this one — the exact failure
            // seeking is supposed to be immune to.
            function clearLeaf(leaf, glyph) {
              leaf.style.opacity = "0";
              leaf.style.transform = "";
              leaf.style.filter = "";
              glyph.textContent = "";
            }

            function showLeaf(leaf, glyph, char, angle, shade) {
              glyph.textContent = char;
              leaf.style.opacity = "1";
              leaf.style.transform = "rotateX(" + angle.toFixed(2) + "deg)";
              leaf.style.filter = "brightness(" + shade + ")";
            }

            // Pure function of absolute composition time. No accumulator, no
            // dependence on the previous frame — seeking to any t reconstructs
            // the whole board from t alone.
            function paint(t) {
              for (var i = 0; i < cells.length; i++) {
                var cell = cells[i];
                var tLocal = t - i * stagger;
                var raw = tLocal <= 0 ? 0 : tLocal / flipDur;
                var count = Math.floor(raw);
                var p = raw - count;
                if (count >= cell.steps) {
                  count = cell.steps;
                  p = 0;
                }

                var outgoing = alphabet.charAt(count % N);
                var incoming = alphabet.charAt((count + 1) % N);
                var flipping = count < cell.steps && p > 0;

                if (!flipping) {
                  cell.top.textContent = outgoing;
                  cell.bot.textContent = outgoing;
                  clearLeaf(cell.leafA, cell.glyphA);
                  clearLeaf(cell.leafB, cell.glyphB);
                  continue;
                }

                // The new character is already seated behind the falling flap:
                // its top half shows, its bottom half is still hidden by the
                // outgoing flap lying in the lower slot.
                cell.top.textContent = incoming;
                cell.bot.textContent = outgoing;

                var theta = 180 * p * p;
                var shade = (1 - 0.5 * Math.sin(theta * DEG)).toFixed(3);
                if (theta < 90) {
                  showLeaf(cell.leafA, cell.glyphA, outgoing, -theta, shade);
                  clearLeaf(cell.leafB, cell.glyphB);
                } else {
                  clearLeaf(cell.leafA, cell.glyphA);
                  showLeaf(cell.leafB, cell.glyphB, incoming, 180 - theta, shade);
                }
              }
            }

            var duration = parseFloat(document.getElementById("sf-root").dataset.duration) || 8;

            // GSAP suppresses events on seek(), so onUpdate callbacks never fire
            // during a render. A property setter on the tweened driver always
            // runs, because GSAP writes the value itself.
            var driverTime = 0;
            var driver = {};
            Object.defineProperty(driver, "t", {
              configurable: true,
              get: function () {
                return driverTime;
              },
              set: function (value) {
                driverTime = value;
                paint(value);
              },
            });

            var tl = gsap.timeline({ paused: true });
            tl.to(driver, { t: duration, duration: duration, ease: "none" }, 0);

            paint(0);

            window.__timelines["split-flap-board"] = tl;
          })();
        </script>
      </div>
    </body>
  </html>
  ```
</Accordion>

Tagged `text` `kinetic` `mechanical` `split-flap` `solari` `departure-board` `reveal`.

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [Motion that reads premium](/prompting/motion.md)
- [Verified example prompts](/prompting/examples.md)
- [Scanline Board Background](/catalog/blocks/yt-lcd-background.md)
