← Numbers

Flip Clock

A live clock on dark flip cards, one for the hours and one for the minutes. When the time changes, the top half of the card falls forward and the new number swings down and settles with a little bounce. Add a small seconds card to watch it flip every second.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-flip-clock to an empty element and size it with font-size. It shows the visitor’s local time.

Options

Add any of these attributes to the same element.

data-secondsAdd a small seconds card that flips every second.
data-12Use a 12-hour clock, with AM or PM on the hour card.
data-time-zoneShow another place’s time, e.g. Europe/London or Asia/Tokyo.
data-cardCard colour. Default #18181b.
data-inkDigit colour. Default #f4f4f5.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Flip Clock: a live clock on dark flip cards, one card for the hours and
    // one for the minutes. When a value changes the top half of the card falls
    // forward and the new number swings down and settles.
    // Usage: <div data-flip-clock></div>   (size it with font-size)
    // Optional: data-seconds (add a small seconds card that flips every second),
    // data-12 (12-hour clock with AM/PM), data-time-zone (an IANA zone such as
    // "Europe/London"; default the visitor's own), data-card (card colour,
    // default #18181b), data-ink (digit colour, default #f4f4f5).
    var DEFAULTS = { card: '#18181b', ink: '#f4f4f5' };

    var css =
      '.flc{--flc-card:#18181b;--flc-ink:#f4f4f5;display:inline-flex;align-items:flex-end;gap:0.1em;font-weight:700;line-height:1;' +
      'font-variant-numeric:tabular-nums;letter-spacing:-0.02em}' +
      '.flc-card{position:relative;width:1.36em;height:1.5em;perspective:5em;border-radius:0.11em;flex:none;' +
      'box-shadow:0 0.05em 0.1em rgba(0,0,0,.18),0 0.2em 0.5em rgba(0,0,0,.16)}' +
      '.flc-card>span{position:absolute;inset:0;display:grid;place-items:center;border-radius:inherit;background:var(--flc-card);color:var(--flc-ink);' +
      'backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.flc-top,.flc-ftop{clip-path:inset(0 0 50% 0)}' +
      '.flc-bot,.flc-fbot{clip-path:inset(50% 0 0 0)}' +
      '.flc-top,.flc-ftop{background-image:linear-gradient(rgba(255,255,255,.05),rgba(255,255,255,0))!important}' +
      '.flc-ftop,.flc-fbot{z-index:2;box-shadow:inset 0 0 0 999px rgba(0,0,0,var(--flc-shade,0))}' +
      '.flc-card::after{content:"";position:absolute;left:0;right:0;top:50%;height:0.025em;min-height:1px;transform:translateY(-50%);background:rgba(0,0,0,.55);z-index:3}' +
      '.flc-card.flc-sec{font-size:0.4em;margin-left:0.1em}' +
      '.flc-ampm{position:absolute;left:0.5em;top:0.45em;font-size:0.13em;font-weight:700;letter-spacing:0.08em;color:var(--flc-ink);opacity:.7;z-index:4}' +
      '.flc-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 reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function make(tag, cls, parent, text) {
      var n = document.createElement(tag);
      n.className = cls;
      if (text !== undefined) n.textContent = text;
      parent.appendChild(n);
      return n;
    }

    function two(n) { return (n < 10 ? '0' : '') + n; }

    // The time now, in the chosen zone if there is one.
    function now(zone) {
      var d = new Date();
      if (!zone) return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
      try {
        var p = {};
        new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' })
          .formatToParts(d).forEach(function (x) { p[x.type] = +x.value; });
        return { h: p.hour % 24, m: p.minute, s: p.second };
      } catch (e) {
        return { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() };
      }
    }

    function makeCard(parent, cls) {
      var card = make('span', 'flc-card' + (cls ? ' ' + cls : ''), parent);
      var parts = {};
      ['top', 'bot', 'ftop', 'fbot'].forEach(function (k) { parts[k] = make('span', 'flc-' + k, card, '00'); });
      anime.utils.set(parts.fbot, { rotateX: 90 });
      return { el: card, parts: parts, value: '00', busy: false, queued: null };
    }

    // The top half of the old number falls forward, uncovering the new one,
    // then the bottom half of the new number swings down and bounces to rest.
    function flip(card, next) {
      var p = card.parts;
      var old = card.value;
      card.value = next;
      card.busy = true;
      p.top.textContent = next;
      p.fbot.textContent = next;
      p.ftop.textContent = old;
      p.bot.textContent = old;
      anime.createTimeline({
        onComplete: function () {
          p.bot.textContent = next;
          p.ftop.textContent = next;
          anime.utils.set(p.ftop, { rotateX: 0, '--flc-shade': 0 });
          anime.utils.set(p.fbot, { rotateX: 90 });
          card.busy = false;
          if (card.queued !== null) { var q = card.queued; card.queued = null; show(card, q, true); }
        }
      })
        .add(p.ftop, { rotateX: [0, -90], '--flc-shade': [0, 0.35], duration: 300, ease: 'in(2)' })
        .add(p.fbot, { rotateX: [90, 0], '--flc-shade': [0.3, 0], duration: 480, ease: 'outBounce' });
    }

    function show(card, v, animate) {
      if (v === card.value) return;
      if (!animate) {
        card.value = v;
        ['top', 'bot', 'ftop', 'fbot'].forEach(function (k) { card.parts[k].textContent = v; });
        return;
      }
      if (card.busy) { card.queued = v; return; }
      flip(card, v);
    }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('flc');
      el.style.setProperty('--flc-card', d.card || DEFAULTS.card);
      el.style.setProperty('--flc-ink', d.ink || DEFAULTS.ink);
      el.textContent = '';
      var sr = make('span', 'flc-sr', el);
      sr.setAttribute('role', 'timer');
      var c = {
        el: el, sr: sr, zone: d.timeZone, twelve: d['12'] !== undefined,
        h: makeCard(el), m: makeCard(el),
        s: d.seconds !== undefined ? makeCard(el, 'flc-sec') : null
      };
      [c.h, c.m, c.s].forEach(function (k) { if (k) k.el.setAttribute('aria-hidden', 'true'); });
      if (c.twelve) c.ampm = make('b', 'flc-ampm', c.h.el, 'AM');
      return c;
    }

    function render(c, animate, stagger) {
      var t = now(c.zone);
      var h = c.twelve ? (t.h % 12 || 12) : t.h;
      var vals = [[c.h, two(h)], [c.m, two(t.m)]];
      if (c.s) vals.push([c.s, two(t.s)]);
      vals.forEach(function (v, i) {
        if (stagger) setTimeout(function () { show(v[0], v[1], true); }, i * stagger);
        else show(v[0], v[1], animate);
      });
      if (c.ampm) c.ampm.textContent = t.h < 12 ? 'AM' : 'PM';
      c.sr.textContent = two(h) + ':' + two(t.m) + (c.twelve ? (t.h < 12 ? ' AM' : ' PM') : '');
    }

    function start(c, animate) {
      // Arriving, the cards flip one after another from 00 to the time.
      render(c, animate, animate ? 220 : 0);
      (function tick() {
        setTimeout(function () {
          render(c, animate);
          tick();
        }, 1000 - (Date.now() % 1000) + 15);
      })();
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          start(e.target._flc, true);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-flip-clock]').forEach(function (el) {
        if (el._flc) return;
        el._flc = setup(el);
        // Reduced motion: the numbers simply change, without flipping.
        if (reduced()) start(el._flc, false);
        else io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-flip-clock style="font-size: 96px"></div>

<!-- 12-hour, with seconds, in Tokyo: -->
<div data-flip-clock data-12 data-seconds data-time-zone="Asia/Tokyo"></div>