← Numbers

Analogue Clock

A clean clock face whose hands sweep smoothly through the real time. When it scrolls into view the hands spin round from twelve to the time now, then the red second hand glides on. Set a time zone and a label to make a row of world clocks.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-analogue-clock to an empty element and give it a width. The hands take the text colour.

Options

Add any of these attributes to the same element.

data-time-zoneShow another place’s time, e.g. Europe/London. Default the visitor’s own.
data-labelSmall text on the face, such as a city name.
data-numeralsShow 12, 3, 6 and 9. data-numerals="all" shows every hour.
data-tickThe second hand ticks with a little spring instead of sweeping.
data-colorSecond hand colour. Default #e5484d.
data-faceFace colour. Default white.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Analogue Clock: a clock face whose hands sweep smoothly through the real
    // time. When it scrolls into view the hands spin round from twelve to the
    // time now, then the second hand glides on without stopping.
    // Usage: <div data-analogue-clock style="width: 200px"></div>
    // Optional: data-time-zone (an IANA zone such as "Asia/Tokyo"; default the
    // visitor's own), data-label (small text on the face, e.g. a city),
    // data-numerals (show 12, 3, 6 and 9; "all" for every hour), data-tick (the
    // second hand ticks with a little spring instead of sweeping),
    // data-color (second hand, default #e5484d), data-face (face colour,
    // default #fff). The hands take the text colour.
    var DEFAULTS = { color: '#e5484d', face: '#fff', spin: 1400 };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      ':where(.alc){width:12em}' +
      '.alc{display:inline-block;aspect-ratio:1;position:relative}' +
      '.alc>svg{display:block;width:100%;height:100%;overflow:visible}' +
      '.alc-face{fill:var(--alc-face,#fff);stroke:currentColor;stroke-opacity:.1;stroke-width:1}' +
      '.alc-rim{fill:none;stroke:currentColor;stroke-opacity:.06;stroke-width:6}' +
      '.alc-tick{stroke:currentColor;stroke-opacity:.28;stroke-width:1;stroke-linecap:round}' +
      '.alc-tick.alc-hr{stroke-opacity:.8;stroke-width:2.6}' +
      '.alc-num{fill:currentColor;font:600 15px/1 system-ui,sans-serif;text-anchor:middle;dominant-baseline:central;opacity:.85}' +
      '.alc-label{fill:currentColor;font:700 8.5px/1 system-ui,sans-serif;letter-spacing:.14em;text-anchor:middle;opacity:.5}' +
      '.alc-hand{stroke:currentColor;stroke-linecap:round}' +
      '.alc-sec{stroke:var(--alc-color,#e5484d);stroke-width:1.6}' +
      '.alc-cap{fill:var(--alc-color,#e5484d)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function svg(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      parent.appendChild(n);
      return n;
    }

    // Hours, minutes, seconds and milliseconds now, in the chosen zone.
    function now(zone) {
      var d = new Date();
      var t = { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds(), ms: d.getMilliseconds() };
      if (zone) {
        try {
          new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' })
            .formatToParts(d).forEach(function (x) {
              if (x.type === 'hour') t.h = +x.value % 24;
              if (x.type === 'minute') t.m = +x.value;
              if (x.type === 'second') t.s = +x.value;
            });
        } catch (e) { /* unknown zone: local time */ }
      }
      return t;
    }

    // A damped spring from 0 to 1 over the first quarter of a second.
    function spring(f) {
      var t = Math.min(1, f / 0.28);
      return t >= 1 ? 1 : 1 - Math.cos(t * Math.PI * 2.5) * Math.exp(-5 * t);
    }

    function angles(c) {
      var t = now(c.zone);
      var f = t.ms / 1000;
      var sec = c.tick ? (t.s - 1 + spring(f)) : t.s + f;
      var min = t.m + (t.s + f) / 60;
      var hr = (t.h % 12) + min / 60;
      return { h: hr * 30, m: min * 6, s: sec * 6 };
    }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('alc');
      el.style.setProperty('--alc-color', d.color || DEFAULTS.color);
      el.style.setProperty('--alc-face', d.face || DEFAULTS.face);
      el.textContent = '';
      el.setAttribute('role', 'img');
      var s = svg('svg', { viewBox: '0 0 200 200', 'aria-hidden': 'true' }, el);
      svg('circle', { cx: 100, cy: 100, r: 97, 'class': 'alc-face' }, s);
      svg('circle', { cx: 100, cy: 100, r: 94, 'class': 'alc-rim' }, s);
      for (var i = 0; i < 60; i++) {
        var hr = i % 5 === 0;
        var a = i * 6 * Math.PI / 180;
        var r1 = hr ? 76 : 82, r2 = 87;
        svg('line', {
          x1: (100 + Math.sin(a) * r1).toFixed(2), y1: (100 - Math.cos(a) * r1).toFixed(2),
          x2: (100 + Math.sin(a) * r2).toFixed(2), y2: (100 - Math.cos(a) * r2).toFixed(2),
          'class': 'alc-tick' + (hr ? ' alc-hr' : '')
        }, s);
      }
      if (d.numerals !== undefined) {
        var all = d.numerals === 'all';
        for (var n = 1; n <= 12; n++) {
          if (!all && n % 3) continue;
          var b = n * 30 * Math.PI / 180;
          svg('text', { x: (100 + Math.sin(b) * 63).toFixed(2), y: (100 - Math.cos(b) * 63).toFixed(2), 'class': 'alc-num' }, s).textContent = n;
        }
      }
      if (d.label) svg('text', { x: 100, y: 138, 'class': 'alc-label' }, s).textContent = d.label.toUpperCase();
      var c = {
        el: el, zone: d.timeZone, tick: d.tick !== undefined, label: d.label, q: 1, raf: 0, visible: false,
        h: svg('line', { x1: 100, y1: 112, x2: 100, y2: 52, 'class': 'alc-hand', 'stroke-width': 6 }, s),
        m: svg('line', { x1: 100, y1: 114, x2: 100, y2: 26, 'class': 'alc-hand', 'stroke-width': 3.6 }, s),
        s: svg('line', { x1: 100, y1: 122, x2: 100, y2: 18, 'class': 'alc-hand alc-sec' }, s)
      };
      svg('circle', { cx: 100, cy: 100, r: 4.6, 'class': 'alc-cap' }, s);
      svg('circle', { cx: 100, cy: 100, r: 1.6, fill: '#fff' }, s);
      return c;
    }

    function draw(c) {
      var a = angles(c);
      c.h.setAttribute('transform', 'rotate(' + (a.h * c.q).toFixed(2) + ' 100 100)');
      c.m.setAttribute('transform', 'rotate(' + (a.m * c.q).toFixed(2) + ' 100 100)');
      c.s.setAttribute('transform', 'rotate(' + (a.s * c.q).toFixed(2) + ' 100 100)');
      var t = now(c.zone);
      var label = (t.h % 12 || 12) + ':' + (t.m < 10 ? '0' : '') + t.m + (t.h < 12 ? ' am' : ' pm');
      if (label !== c.said) { c.said = label; c.el.setAttribute('aria-label', (c.label ? c.label + ', ' : '') + label); }
    }

    function loop(c) {
      if (c.raf) return;
      (function frame() {
        if (!c.visible) { c.raf = 0; return; }
        draw(c);
        c.raf = requestAnimationFrame(frame);
      })();
    }

    function init() {
      var still = reduced();
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._alc;
          c.visible = e.isIntersecting;
          if (!c.visible) return;
          if (!c.spun) {
            // First time in view: the hands spin round from twelve to the time.
            c.spun = true;
            c.q = 0;
            anime.animate(c, { q: 1, duration: DEFAULTS.spin, ease: 'inOut(3)' });
          }
          loop(c);
        });
      }, { threshold: 0.1 });
      document.querySelectorAll('[data-analogue-clock]').forEach(function (el) {
        if (el._alc) return;
        var c = el._alc = setup(el);
        draw(c);
        // Reduced motion: the hands move once a second, without sweeping.
        if (still) setInterval(function () { draw(c); }, 1000);
        else { c.q = 0; draw(c); io.observe(el); }
      });
    }

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

<!-- a world clock: -->
<div data-analogue-clock data-time-zone="Asia/Tokyo" data-label="Tokyo" style="width: 160px"></div>