← Numbers

Circular Countdown

A ring drains smoothly round the face while the seconds left count down in the middle, like a quiz timer. It turns amber at half time and red near the end, the last five seconds pulse, and at zero it gives a little shake.

Which planet is closest to the Sun?

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-circular-countdown="30" (the number of seconds) to an empty element and give it a width. It starts when scrolled into view.

Options

Add any of these attributes to the same element.

data-loopRefill the ring and go again after a short pause.
data-labelText under the number. Default seconds.
data-doneText shown at zero. Default Time’s up.
data-colorsThree colours: plenty of time, half time, nearly out. Default #22c55e,#f59e0b,#ef4444.
data-trackColour of the empty ring.

At zero it fires a circular-countdown:done event on the element. Call el._ccd.restart() to run it again.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Circular Countdown: a ring drains smoothly round the clock face while the
    // seconds left count down in the middle, like a quiz timer. It turns amber
    // at half time and red near the end, the last few seconds pulse, and at
    // zero it gives a little shake.
    // Usage: <div data-circular-countdown="30"></div>   (seconds; size it with width)
    // Optional: data-loop (refill and go again after a pause), data-label (text
    // under the number, default "seconds"), data-done (text at zero, default
    // "Time's up"), data-colors (three colours: plenty, half, nearly out;
    // default "#22c55e,#f59e0b,#ef4444"), data-track (empty ring colour).
    // It fires "circular-countdown:done" on the element at zero.
    // Script use: el._ccd.restart()
    var DEFAULTS = { seconds: 30, label: 'seconds', done: 'Time’s up', colors: '#22c55e,#f59e0b,#ef4444', track: 'rgba(15,15,30,.08)', hold: 1600 };
    var NS = 'http://www.w3.org/2000/svg';
    var R = 44, C = 2 * Math.PI * R;

    var css =
      ':where(.ccd){width:10em}' +
      '.ccd{position:relative;display:inline-grid;place-items:center;aspect-ratio:1;container-type:inline-size;font-variant-numeric:tabular-nums}' +
      '.ccd>svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg);overflow:visible}' +
      '.ccd-face{fill:#fff}' +
      '.ccd-arc{fill:none;stroke-width:7;stroke-linecap:round;transition:stroke .5s}' +
      '.ccd-knob{transition:fill .5s}' +
      '.ccd-read{position:relative;display:grid;justify-items:center;gap:3cqw;text-align:center}' +
      '.ccd-num{font-size:30cqw;font-weight:800;line-height:1;letter-spacing:-0.04em}' +
      '.ccd-label{font-size:max(9px,7.5cqw);font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.5;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 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;
    }
    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 setup(el) {
      var d = el.dataset;
      var c = {
        el: el, total: parseFloat(el.getAttribute('data-circular-countdown')) || DEFAULTS.seconds,
        colors: (d.colors || DEFAULTS.colors).split(',').map(function (x) { return x.trim(); }),
        label: d.label !== undefined ? d.label : DEFAULTS.label, done: d.done || DEFAULTS.done,
        loop: d.loop !== undefined && d.loop !== 'false', still: reduced(),
        left: 0, elapsed: 0, last: 0, running: false, visible: false, raf: 0, shown: -1
      };
      el.classList.add('ccd');
      el.textContent = '';
      var s = svg('svg', { viewBox: '0 0 100 100', 'aria-hidden': 'true' }, el);
      svg('circle', { cx: 50, cy: 50, r: 49, 'class': 'ccd-face' }, s);
      svg('circle', { cx: 50, cy: 50, r: R, fill: 'none', stroke: d.track || DEFAULTS.track, 'stroke-width': 7 }, s);
      c.arc = svg('circle', { cx: 50, cy: 50, r: R, 'class': 'ccd-arc', 'stroke-dasharray': C.toFixed(2) }, s);
      c.knob = svg('circle', { cx: 50 + R, cy: 50, r: 2.2, fill: '#fff', 'class': 'ccd-knob' }, s);
      var read = make('div', 'ccd-read', el);
      c.num = make('div', 'ccd-num', read);
      c.num.setAttribute('role', 'timer');
      c.lab = make('div', 'ccd-label', read, c.label);
      c.restart = function () { restart(c); };
      draw(c, 1);
      return c;
    }

    function colour(c, p) { return p > 0.5 ? c.colors[0] : p > 0.2 ? c.colors[1] || c.colors[0] : c.colors[2] || c.colors[1] || c.colors[0]; }

    // p is the share of time left, 1 = full ring.
    function draw(c, p) {
      var col = colour(c, p);
      c.arc.setAttribute('stroke-dashoffset', (C * (1 - p)).toFixed(2));
      c.arc.style.stroke = col;
      c.arc.style.opacity = p > 0.001 ? 1 : 0;
      var a = p * Math.PI * 2;
      c.knob.setAttribute('cx', (50 + Math.cos(a) * R).toFixed(2));
      c.knob.setAttribute('cy', (50 + Math.sin(a) * R).toFixed(2));
      c.knob.style.opacity = p > 0.001 ? 1 : 0;
      var n = Math.ceil(p * c.total - 1e-6);
      if (n !== c.shown) {
        var first = c.shown < 0;
        c.shown = n;
        c.num.textContent = n;
        c.num.style.color = n && p <= 0.2 ? col : '';
        // The last five seconds pulse on each tick.
        if (!first && n > 0 && n <= 5 && !c.still && c.running) {
          anime.animate(c.num, { scale: [1.3, 1], duration: 500, ease: 'out(3)' });
        }
      }
    }

    function frame(c, ts) {
      if (!c.visible || !c.running) { c.raf = 0; c.last = 0; return; }
      if (c.last) c.elapsed += (ts - c.last) / 1000;
      c.last = ts;
      var p = Math.max(0, 1 - c.elapsed / c.total);
      // Reduced motion: the ring steps once a second instead of gliding.
      draw(c, c.still ? Math.ceil(p * c.total - 1e-6) / c.total : p);
      if (p <= 0) { finish(c); c.raf = 0; return; }
      c.raf = requestAnimationFrame(function (n) { frame(c, n); });
    }

    function run(c) {
      if (!c.raf && c.running && c.visible) c.raf = requestAnimationFrame(function (n) { frame(c, n); });
    }

    function finish(c) {
      c.running = false;
      c.lab.textContent = c.done;
      c.el.dispatchEvent(new CustomEvent('circular-countdown:done'));
      if (!c.still) {
        anime.animate(c.el, { x: [0, -7, 6, -5, 4, -2, 0], duration: 560, ease: 'linear' });
        anime.animate(c.num, { scale: [1.35, 1], duration: 700, ease: 'outElastic(1, .5)' });
      }
      if (c.loop) setTimeout(function () { restart(c); }, DEFAULTS.hold);
    }

    // The ring sweeps back to full, then counts down again.
    function restart(c) {
      c.running = false;
      c.lab.textContent = c.label;
      var from = Math.max(0, 1 - c.elapsed / c.total);
      var go = function () { c.elapsed = 0; c.running = true; c.shown = -1; run(c); };
      if (c.still) { draw(c, 1); go(); return; }
      var o = { p: from };
      anime.animate(o, { p: 1, duration: 700, ease: 'inOut(3)', onUpdate: function () { draw(c, o.p); }, onComplete: go });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._ccd;
          c.visible = e.isIntersecting;
          if (!c.visible) return;
          if (!c.started) { c.started = true; c.running = true; }
          run(c);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-circular-countdown]').forEach(function (el) {
        if (el._ccd) return;
        el._ccd = setup(el);
        io.observe(el);
      });
    }

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

<!-- a looping 10-second timer: -->
<div data-circular-countdown="10" data-loop data-label="seconds left"></div>