← Numbers

Split-flap Display

Every character flips through the alphabet on its own little flaps until it lands, like an old airport departure board. Use it for times, flight codes, prices or scores.

LONDON 09:45

How to use it

  1. Paste the snippet just before </head>.
  2. Put the text in an element with data-split-flap. It flips in when scrolled into view.

Options

Add any of these attributes to the same element.

data-values="A|B|C"Several values to flip through in turn.
data-holdHow long each value stays. Default 2400 ms.
data-speedTime per flap. Default 70 ms.
data-loopWith a single value: clear and flip in again every few seconds.

Letters show as capitals. It flips through space, A–Z, 0–9 and : . , - / ' ! ? &; anything else jumps straight into place.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Split-flap Display: each character flips through the alphabet on its own
    // little flaps until it lands, like an airport departure board.
    // Usage: <p data-split-flap>GATE 12</p>
    //    or: <p data-split-flap data-values="09:45|12:30|18:05">09:45</p>
    // Optional: data-values (several values to flip through in turn), data-hold
    // (ms each value stays, default 2400), data-speed (ms per flap, default 70),
    // data-loop (with a single value: flip from blank again every few seconds).
    // Letters are shown in capitals. Characters it can flip through:
    // space A–Z 0–9 and : . , - / ' ! ? &
    var DEFAULTS = { hold: 2400, speed: 70 };
    var CHARS = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.,-/'!?&";

    var css =
      '.sfx{display:inline-flex;gap:0.08em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;line-height:1}' +
      '.sfx-cell{position:relative;display:inline-block;width:0.78em;height:1.25em;perspective:3em;border-radius:0.1em;background:#18181b;color:#fafafa;box-shadow:0 0.06em 0.12em rgba(0,0,0,.35)}' +
      '.sfx-cell>span{position:absolute;inset:0;display:grid;place-items:center;border-radius:inherit;background:inherit;backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.sfx-top,.sfx-ftop{clip-path:inset(0 0 50% 0)}' +
      '.sfx-bot,.sfx-fbot{clip-path:inset(50% 0 0 0)}' +
      '.sfx-ftop{background:#222227!important}' +
      '.sfx-cell::after{content:"";position:absolute;left:0;right:0;top:50%;height:1px;margin-top:-0.5px;background:rgba(0,0,0,.6);z-index:3}' +
      '.sfx-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function idx(c) { var i = CHARS.indexOf(c); return i < 0 ? -1 : i; }

    function makeCell(parent, ch) {
      var cell = document.createElement('span');
      cell.className = 'sfx-cell';
      cell.setAttribute('aria-hidden', 'true');
      var parts = {};
      ['top', 'bot', 'ftop', 'fbot'].forEach(function (k) {
        var s = document.createElement('span');
        s.className = 'sfx-' + k;
        s.textContent = ch;
        cell.appendChild(s);
        parts[k] = s;
      });
      parts.ftop.style.zIndex = 2;
      parts.fbot.style.zIndex = 2;
      anime.utils.set(parts.fbot, { rotateX: 90 });
      parent.appendChild(cell);
      return { el: cell, parts: parts, ch: ch, busy: false };
    }

    function show(cell, ch) {
      cell.ch = ch;
      ['top', 'bot', 'ftop', 'fbot'].forEach(function (k) { cell.parts[k].textContent = ch; });
    }

    // One flap: the top half of the old character falls forward, revealing the
    // new one behind it, and the bottom half of the new one swings down.
    function flap(cell, next, speed, done) {
      var p = cell.parts;
      p.top.textContent = next;
      p.fbot.textContent = next;
      anime.animate(p.ftop, {
        rotateX: [0, -90], duration: speed / 2, ease: 'in(2)',
        onComplete: function () {
          anime.animate(p.fbot, {
            rotateX: [90, 0], duration: speed / 2, ease: 'out(2)',
            onComplete: function () {
              show(cell, next);
              anime.utils.set(p.ftop, { rotateX: 0 });
              anime.utils.set(p.fbot, { rotateX: 90 });
              done();
            }
          });
        }
      });
    }

    function flipTo(cell, target, speed, delay) {
      cell.target = target;
      if (cell.busy) return;
      cell.busy = true;
      setTimeout(function step() {
        if (cell.ch === cell.target) { cell.busy = false; return; }
        var i = idx(cell.ch), t = idx(cell.target);
        // Unknown characters (and anything after a full lap) jump straight there.
        var next = i < 0 || t < 0 ? cell.target : CHARS[(i + 1) % CHARS.length];
        flap(cell, next, speed, step);
      }, delay);
    }

    function build(el) {
      var d = el.dataset;
      var values = (d.values ? d.values.split('|') : [el.textContent.trim()]).map(function (v) { return v.toUpperCase(); });
      var len = Math.max.apply(null, values.map(function (v) { return v.length; }));
      values = values.map(function (v) { return v + ' '.repeat(len - v.length); });
      el.classList.add('sfx');
      el.textContent = '';
      var sr = document.createElement('span');
      sr.className = 'sfx-sr';
      el.appendChild(sr);
      var cells = [];
      for (var i = 0; i < len; i++) cells.push(makeCell(el, ' '));
      return { el: el, sr: sr, cells: cells, values: values };
    }

    function setValue(b, v, animate) {
      var speed = +b.el.dataset.speed || DEFAULTS.speed;
      b.sr.textContent = v.trim();
      b.cells.forEach(function (c, i) {
        if (animate) flipTo(c, v[i], speed, i * 40);
        else show(c, v[i]);
      });
    }

    function run(b) {
      var d = b.el.dataset;
      var hold = +d.hold || DEFAULTS.hold;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var i = 0;
      setValue(b, b.values[0], true);
      if (b.values.length < 2 && !loop) return;
      var blank = ' '.repeat(b.values[0].length);
      var showingBlank = false;
      setInterval(function () {
        if (b.values.length > 1) { i = (i + 1) % b.values.length; setValue(b, b.values[i], true); return; }
        // A single looping value: clear to blank, then flip back in.
        showingBlank = !showingBlank;
        setValue(b, showingBlank ? blank : b.values[0], !showingBlank);
      }, hold + 1800);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          run(e.target._sfx);
        });
      }, { threshold: 0.5 });
      document.querySelectorAll('[data-split-flap]').forEach(function (el) {
        if (el._sfx) return;
        var b = (el._sfx = build(el));
        if (reduced) { setValue(b, b.values[0], false); return; }
        b.sr.textContent = b.values[0].trim();
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p>Next departure: <span data-split-flap>09:45</span></p>

<!-- cycle through values: -->
<p data-split-flap data-values="LONDON|PARIS|TOKYO">LONDON</p>