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
- Paste the snippet just before
</head>. - 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-loop | Refill the ring and go again after a short pause. |
data-label | Text under the number. Default seconds. |
data-done | Text shown at zero. Default Time’s up. |
data-colors | Three colours: plenty of time, half time, nearly out. Default #22c55e,#f59e0b,#ef4444. |
data-track | Colour of the empty ring. |
At zero it fires a circular-countdown:done event on the element. Call el._ccd.restart() to run it again.
<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><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>