Live demo, running in your browser

Split-flap board

A dark airport departure board whose six cream split-flap tiles clatter through the alphabet to spell ARRIVE, then ON TIME, flipping between the two every few seconds while a blinking amber status light tracks the update.

Code: MIT license.

<!doctype html>
<html lang="en">
  <head
    ><script>
      /* 6to9 pattern harness */ (function () {
        var parentPaused = false,
          hidden = document.hidden,
          p = (window.__pattern = { paused: false, reducedMotion: false, maxDpr: 2 });
        function sync() {
          p.paused = parentPaused || hidden;
        }
        sync();
        try {
          p.reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
        } catch (e) {}
        document.addEventListener("visibilitychange", function () {
          hidden = document.hidden;
          sync();
        });
        addEventListener("message", function (e) {
          var d = e.data || {};
          if (d.pattern === "pause") {
            parentPaused = true;
            sync();
          }
          if (d.pattern === "play") {
            parentPaused = false;
            sync();
          }
        });
      })();
    </script>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Halcyon Air - Live Board</title>
    <style>
      :root {
        --tw: min(calc((100vw - 90px) / 6.55), 168px);
        --th: calc(var(--tw) * 1.36);
        --tile-top: linear-gradient(#f5eedf, #e8dfca);
        --tile-bottom: linear-gradient(#e2d8c2, #eee6d3);
        --ink: #1c1a17;
        --amber: #ffb020;
      }
      @media (max-width: 600px) {
        :root {
          --th: calc(var(--tw) * 1.62);
        }
      }
      * {
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        height: 100%;
        background: #0e0d0c;
        color: #efe7d6;
        font-family:
          system-ui,
          -apple-system,
          "Segoe UI",
          Roboto,
          "Helvetica Neue",
          Arial,
          sans-serif;
      }
      .hero {
        position: relative;
        width: 100vw;
        height: 100vh;
        overflow: hidden;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: clamp(16px, 3vh, 32px);
        padding: 20px 16px;
        background: radial-gradient(ellipse at 50% 35%, #2a2620 0%, #151311 55%, #0b0a09 100%);
      }
      .status {
        display: flex;
        align-items: center;
        gap: 12px;
        font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        font-size: 12px;
        letter-spacing: 0.22em;
        text-transform: uppercase;
        color: #b7ab95;
      }
      .light {
        width: 11px;
        height: 11px;
        border-radius: 50%;
        background: var(--amber);
        box-shadow:
          0 0 10px 2px rgba(255, 176, 32, 0.75),
          0 0 28px 6px rgba(255, 176, 32, 0.35);
      }
      .light.busy {
        animation: blink 0.5s steps(2, jump-none) infinite;
      }
      @keyframes blink {
        0% {
          opacity: 1;
        }
        100% {
          opacity: 0.25;
        }
      }
      .board {
        display: flex;
        padding: clamp(12px, 2.2vw, 28px);
        gap: clamp(4px, 0.6vw, 9px);
        background: #0a0a0a;
        border-radius: 18px;
        box-shadow:
          0 50px 90px rgba(0, 0, 0, 0.65),
          0 8px 0 #050505,
          inset 0 0 0 1px #28251f,
          inset 0 2px 0 rgba(255, 255, 255, 0.05);
      }
      .tile {
        position: relative;
        width: var(--tw);
        height: var(--th);
        margin-right: 0;
        border-radius: 8px;
        transition: margin-right 0.7s cubic-bezier(0.6, 0, 0.2, 1);
        font-family: "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;
        font-weight: 800;
        font-size: min(calc(var(--th) * 0.74), var(--tw));
        color: var(--ink);
        user-select: none;
      }
      .board.spaced .tile:nth-child(2) {
        margin-right: calc(var(--tw) * 0.55);
      }
      .half {
        position: absolute;
        left: 0;
        right: 0;
        height: 50%;
        overflow: hidden;
      }
      .half.top {
        top: 0;
        background: var(--tile-top);
        border-radius: 8px 8px 0 0;
      }
      .half.bottom {
        bottom: 0;
        background: var(--tile-bottom);
        border-radius: 0 0 8px 8px;
      }
      .half span {
        position: absolute;
        left: 0;
        right: 0;
        height: 200%;
        display: flex;
        align-items: center;
        justify-content: center;
        line-height: 1;
      }
      .half.top span {
        top: 0;
      }
      .half.bottom span {
        bottom: 0;
      }
      .flap {
        visibility: hidden;
        backface-visibility: hidden;
        will-change: transform;
        z-index: 2;
      }
      .flap.top {
        transform-origin: 50% 100%;
      }
      .flap.bottom {
        transform-origin: 50% 0;
        transform: perspective(600px) rotateX(90deg);
      }
      .hinge {
        position: absolute;
        left: 0;
        right: 0;
        top: calc(50% - 1px);
        height: 2px;
        background: #0a0908;
        box-shadow: 0 1px 0 rgba(255, 255, 255, 0.18);
        z-index: 5;
      }
      .hinge::before,
      .hinge::after {
        content: "";
        position: absolute;
        top: -4px;
        width: 4px;
        height: 10px;
        background: #0a0908;
      }
      .hinge::before {
        left: -1px;
        border-radius: 0 3px 3px 0;
      }
      .hinge::after {
        right: -1px;
        border-radius: 3px 0 0 3px;
      }
      .copy {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: clamp(14px, 2.4vh, 24px);
        text-align: center;
        max-width: 520px;
      }
      .subline {
        margin: 0;
        font-size: clamp(15px, 1.5vw, 19px);
        line-height: 1.5;
        color: #cfc5b1;
      }
      .cta {
        display: inline-flex;
        align-items: center;
        gap: 10px;
        padding: 15px 26px;
        border: 0;
        border-radius: 999px;
        background: #efe7d6;
        color: #1c1a17;
        font: inherit;
        font-weight: 700;
        font-size: 16px;
        cursor: pointer;
        box-shadow:
          0 4px 0 #a99f8b,
          0 14px 30px rgba(0, 0, 0, 0.45);
        transition:
          transform 0.15s ease,
          box-shadow 0.15s ease;
      }
      .cta:hover {
        transform: translateY(-2px);
        box-shadow:
          0 6px 0 #a99f8b,
          0 18px 34px rgba(0, 0, 0, 0.5);
      }
      .cta:active {
        transform: translateY(3px);
        box-shadow:
          0 1px 0 #a99f8b,
          0 6px 12px rgba(0, 0, 0, 0.4);
      }
      .cta svg {
        width: 18px;
        height: 18px;
      }
      @media (max-width: 600px) {
        .status {
          font-size: 11px;
          letter-spacing: 0.16em;
        }
      }
    </style>
  </head>
  <body>
    <main class="hero">
      <div class="status">
        <span class="light busy" id="light"></span>
        <span id="statusText">Updating board</span>
      </div>
      <h1 class="board" id="board" role="img" aria-label="ARRIVE" style="margin: 0"></h1>
      <div class="copy">
        <p class="subline">Live gate times for Halcyon Air. Calm, clear and always current.</p>
        <button class="cta" type="button">
          Track a flight
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2.4"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <path d="M5 12h14" />
            <path d="M13 6l6 6-6 6" />
          </svg>
        </button>
      </div>
    </main>
    <script>
      const P = window.__pattern || { paused: false, reducedMotion: false, maxDpr: 2 };
      const state = function () {
        return window.__pattern || P;
      };
      const WORDS = [
        { text: "ARRIVE", label: "ARRIVE", status: "Gate 07 / Landed" },
        { text: "ONTIME", label: "ON TIME", status: "Gate 07 / On time" },
      ];
      const ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
      const board = document.getElementById("board");
      const light = document.getElementById("light");
      const statusText = document.getElementById("statusText");
      const tiles = [];

      function makeHalf(cls) {
        const half = document.createElement("div");
        half.className = "half " + cls;
        const span = document.createElement("span");
        half.appendChild(span);
        return { el: half, span: span };
      }

      function makeTile() {
        const el = document.createElement("div");
        el.className = "tile";
        el.setAttribute("aria-hidden", "true");
        const top = makeHalf("top");
        const bottom = makeHalf("bottom");
        const flapTop = makeHalf("top flap");
        const flapBottom = makeHalf("bottom flap");
        const hinge = document.createElement("div");
        hinge.className = "hinge";
        el.appendChild(top.el);
        el.appendChild(bottom.el);
        el.appendChild(flapTop.el);
        el.appendChild(flapBottom.el);
        el.appendChild(hinge);
        return {
          el: el,
          top: top.span,
          bottom: bottom.span,
          flapTop: flapTop.el,
          flapTopSpan: flapTop.span,
          flapBottom: flapBottom.el,
          flapBottomSpan: flapBottom.span,
          char: " ",
        };
      }

      function setChar(tile, ch) {
        tile.char = ch;
        tile.top.textContent = ch;
        tile.bottom.textContent = ch;
      }

      for (let i = 0; i < 6; i++) {
        const tile = makeTile();
        tiles.push(tile);
        board.appendChild(tile.el);
        setChar(tile, " ");
      }

      function setStatus(text, busy) {
        statusText.textContent = text;
        light.classList.toggle("busy", busy);
      }

      // Time that only advances while the page is not paused.
      function sleep(ms) {
        return new Promise(function (resolve) {
          let remaining = ms;
          let last = performance.now();
          function tick(now) {
            if (state().paused) {
              last = now;
            } else {
              remaining -= now - last;
              last = now;
            }
            if (remaining <= 0) {
              resolve();
            } else {
              requestAnimationFrame(tick);
            }
          }
          requestAnimationFrame(tick);
        });
      }

      function gate() {
        return new Promise(function (resolve) {
          function check() {
            if (state().paused) {
              requestAnimationFrame(check);
            } else {
              resolve();
            }
          }
          check();
        });
      }

      // Freeze or resume every running Web Animation when paused flips.
      let wasPaused = false;
      function watchPause() {
        const now = !!state().paused;
        if (now !== wasPaused) {
          wasPaused = now;
          document.getAnimations().forEach(function (a) {
            if (now) {
              a.pause();
            } else {
              a.play();
            }
          });
          light.style.animationPlayState = now ? "paused" : "running";
        }
        requestAnimationFrame(watchPause);
      }
      requestAnimationFrame(watchPause);

      // One flap: the old top half falls, then the new bottom half lands.
      async function flip(tile, ch, dur) {
        await gate();
        tile.flapTopSpan.textContent = tile.char;
        tile.bottom.textContent = tile.char;
        tile.top.textContent = ch;
        tile.flapBottomSpan.textContent = ch;
        tile.flapTop.style.visibility = "visible";
        const fall = tile.flapTop.animate(
          [
            { transform: "perspective(600px) rotateX(0deg)", filter: "brightness(1)" },
            { transform: "perspective(600px) rotateX(-90deg)", filter: "brightness(0.55)" },
          ],
          { duration: dur * 0.5, easing: "cubic-bezier(0.5, 0, 1, 0.8)", fill: "forwards" },
        );
        await fall.finished;
        tile.flapTop.style.visibility = "hidden";
        fall.cancel();
        tile.flapBottom.style.visibility = "visible";
        const land = tile.flapBottom.animate(
          [
            { transform: "perspective(600px) rotateX(90deg)", filter: "brightness(0.7)" },
            { transform: "perspective(600px) rotateX(0deg)", filter: "brightness(1)" },
          ],
          { duration: dur * 0.5, easing: "cubic-bezier(0.2, 0.6, 0.4, 1)", fill: "forwards" },
        );
        await land.finished;
        tile.bottom.textContent = ch;
        tile.flapBottom.style.visibility = "hidden";
        land.cancel();
        tile.char = ch;
      }

      // Clatter through the alphabet and land on the target letter.
      async function rollTo(tile, index, target) {
        const flips = 9 + index * 5 + Math.floor(Math.random() * 3);
        const dur = 64 + index * 6;
        const targetIndex = ALPHA.indexOf(target);
        for (let k = 0; k < flips; k++) {
          const ch = ALPHA[(targetIndex - (flips - 1 - k) + 2600) % 26];
          await flip(tile, ch, dur);
        }
        await gate();
        tile.el.animate(
          [
            { transform: "translateY(0) scaleY(1)" },
            { transform: "translateY(3px) scaleY(0.985)" },
            { transform: "translateY(0) scaleY(1)" },
          ],
          { duration: 150, easing: "ease-out" },
        );
      }

      function showStatic(word) {
        tiles.forEach(function (tile, i) {
          setChar(tile, word.text[i]);
        });
        board.setAttribute("aria-label", word.label);
        board.classList.toggle("spaced", word.text === "ONTIME");
        setStatus(word.status, false);
      }

      async function run() {
        let w = 0;
        while (true) {
          const word = WORDS[w];
          setStatus("Updating board", true);
          board.classList.toggle("spaced", w === 1);
          await Promise.all(
            tiles.map(function (tile, i) {
              return sleep(i * 60).then(function () {
                return rollTo(tile, i, word.text[i]);
              });
            }),
          );
          board.setAttribute("aria-label", word.label);
          setStatus(word.status, false);
          await sleep(3000);
          w = 1 - w;
        }
      }

      try {
        if (state().reducedMotion || typeof Element.prototype.animate !== "function") {
          showStatic(WORDS[0]);
        } else {
          run().catch(function () {
            showStatic(WORDS[0]);
          });
        }
      } catch (e) {
        showStatic(WORDS[0]);
      }
    </script>
  </body>
</html>

Made by 6to9. Back to all motion patterns.