Countdown Timer
A live countdown to a date and time, with every digit on its own flip card. It flips up from zero when scrolled into view, then each second the changing digits fold over and settle with a little bounce.
How to use it
- Paste the snippet just before
</head>. - Add
data-countdown-timer="2027-01-01T09:00:00"to an empty element: the date and time to count down to. Size it withfont-size.
Options
Add any of these attributes to the same element.
data-in | Count down this long from when the page opens instead, e.g. 45m or 1d 12h. Handy for demos. |
data-units | Which units to show. Default d,h,m,s; try h,m,s. The largest unit takes the overflow. |
data-labels | Names under the units. Default Days,Hours,Minutes,Seconds. |
data-card | Card colour. Default white. Digits take the text colour. |
The date is read in the visitor’s time zone unless you add one, e.g. 2027-01-01T09:00:00Z. At zero it stops and fires a countdown:done event on the element.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Countdown Timer: a live countdown to a date and time, with each digit on
// its own little flip card. When it scrolls into view the cards flip from
// zero to the time left, then every second the changing digits flip over.
// Usage: <div data-countdown-timer="2027-01-01T00:00:00">2027-01-01</div>
// or: <div data-countdown-timer data-in="2d 6h 30m"></div> (from page load)
// Optional: data-in (count down this long from when the page opens, e.g.
// "45m" or "1d 12h"), data-units (which units to show, default "d,h,m,s"),
// data-labels (their names, default "Days,Hours,Minutes,Seconds"),
// data-card (card colour, default white). When it reaches zero it fires a
// "countdown:done" event on the element.
var DEFAULTS = { units: 'd,h,m,s', labels: 'Days,Hours,Minutes,Seconds', card: '#fff' };
var SECONDS = { d: 86400, h: 3600, m: 60, s: 1 };
var ORDER = ['d', 'h', 'm', 's'];
var css =
'.cdt{--cdt-card:#fff;display:inline-flex;align-items:flex-start;gap:0.18em;font-weight:800;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:0}' +
'.cdt-unit{display:flex;flex-direction:column;align-items:center;gap:0.22em}' +
'.cdt-digits{display:flex;gap:0.06em}' +
'.cdt-sep{height:1.3em;display:grid;place-items:center;opacity:.45;transform:translateY(-0.06em)}' +
'.cdt-label{font-size:max(0.22em,10px);font-weight:600;letter-spacing:0.08em;text-transform:uppercase;opacity:.6}' +
'.cdt-card{position:relative;width:0.74em;height:1.3em;perspective:4em;border-radius:0.1em;' +
'box-shadow:0 0.04em 0.1em rgba(15,15,30,.12),0 0.16em 0.4em rgba(15,15,30,.08)}' +
'.cdt-card>span{position:absolute;inset:0;display:grid;place-items:center;border-radius:inherit;background:var(--cdt-card);' +
'backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
'.cdt-top,.cdt-ftop{clip-path:inset(0 0 50% 0)}' +
'.cdt-bot,.cdt-fbot{clip-path:inset(50% 0 0 0)}' +
// The flaps carry their own shading, deepened while they swing.
'.cdt-ftop,.cdt-fbot{z-index:2;background-image:linear-gradient(rgba(0,0,0,var(--cdt-shade,0)),rgba(0,0,0,var(--cdt-shade,0)))!important}' +
'.cdt-card::after{content:"";position:absolute;left:0;right:0;top:50%;height:0.02em;min-height:1px;background:rgba(15,15,30,.1);z-index:3}' +
'.cdt-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);
// "1d 12h 30m" → seconds.
function parseIn(s) {
var total = 0;
s.replace(/(\d+(?:\.\d+)?)\s*([dhms])/gi, function (_, n, u) { total += +n * SECONDS[u.toLowerCase()]; });
return total;
}
function el(tag, cls, parent, text) {
var n = document.createElement(tag);
n.className = cls;
if (text !== undefined) n.textContent = text;
parent.appendChild(n);
return n;
}
function makeCard(parent) {
var card = el('span', 'cdt-card', parent);
var p = {};
['top', 'bot', 'ftop', 'fbot'].forEach(function (k) { p[k] = el('span', 'cdt-' + k, card, '0'); });
anime.utils.set(p.fbot, { rotateX: 90 });
return { parts: p, value: '0', busy: false, queued: null };
}
// One flip: the top half of the old digit falls forward, revealing the new
// one behind it, and the bottom half of the new one swings down and settles.
function flip(card, next, done) {
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, '--cdt-shade': 0 });
anime.utils.set(p.fbot, { rotateX: 90 });
card.busy = false;
if (card.queued !== null) { var q = card.queued; card.queued = null; set(card, q, true); }
if (done) done();
}
})
.add(p.ftop, { rotateX: [0, -90], '--cdt-shade': [0, 0.25], duration: 260, ease: 'in(2)' })
.add(p.fbot, { rotateX: [90, 0], '--cdt-shade': [0.2, 0], duration: 420, ease: 'outBounce' });
}
function set(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 build(root) {
var d = root.dataset;
var attr = root.getAttribute('data-countdown-timer');
var end = d.in ? Date.now() + parseIn(d.in) * 1000 : Date.parse(attr || root.textContent.trim());
if (isNaN(end)) end = Date.now();
var units = (d.units || DEFAULTS.units).split(',').map(function (u) { return u.trim().charAt(0).toLowerCase(); })
.filter(function (u) { return SECONDS[u]; })
.sort(function (a, b) { return ORDER.indexOf(a) - ORDER.indexOf(b); });
var names = (d.labels || DEFAULTS.labels).split(',').map(function (l) { return l.trim(); });
root.classList.add('cdt');
root.style.setProperty('--cdt-card', d.card || DEFAULTS.card);
root.textContent = '';
var sr = el('span', 'cdt-sr', root);
sr.setAttribute('role', 'timer');
var t = { root: root, end: end, units: units, sr: sr, groups: [] };
var first = split(t);
units.forEach(function (u, i) {
if (i) el('span', 'cdt-sep', root, ':').setAttribute('aria-hidden', 'true');
var unit = el('span', 'cdt-unit', root);
unit.setAttribute('aria-hidden', 'true');
var digits = el('span', 'cdt-digits', unit);
var len = Math.max(2, String(first[i]).length);
var cards = [];
for (var k = 0; k < len; k++) cards.push(makeCard(digits));
el('span', 'cdt-label', unit, names[ORDER.indexOf(u)] || u);
t.groups.push(cards);
});
return t;
}
// Time left, broken into the units shown: the largest takes the overflow.
function split(t) {
var left = Math.max(0, Math.ceil((t.end - Date.now()) / 1000));
return t.units.map(function (u) {
var n = Math.floor(left / SECONDS[u]);
left -= n * SECONDS[u];
return n;
});
}
function render(t, animate, stagger) {
var values = split(t);
var i = 0;
t.groups.forEach(function (cards, g) {
var s = String(values[g]);
while (s.length < cards.length) s = '0' + s;
s = s.slice(-cards.length);
cards.forEach(function (card, k) {
card.target = s[k];
// Staggered flips read the target when they fire, so a tick in the
// meantime never gets overwritten by an older digit.
if (stagger && card.target !== card.value) setTimeout(function () { set(card, card.target, true); }, i++ * stagger);
else set(card, card.target, animate);
});
});
t.sr.textContent = values.map(function (v, g) { return v + ' ' + t.units[g]; }).join(' ');
return values.every(function (v) { return v === 0; });
}
function start(t, animate) {
// On arrival every card flips from zero, one after another.
var finished = render(t, animate, animate ? 90 : 0);
(function tick() {
if (finished) { t.root.dispatchEvent(new CustomEvent('countdown:done')); return; }
// Wake just after each whole second, so the digits change on the beat.
setTimeout(function () {
finished = render(t, animate);
tick();
}, ((t.end - Date.now()) % 1000 + 1000) % 1000 + 20);
})();
}
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);
start(e.target._cdt, true);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-countdown-timer]').forEach(function (root) {
if (root._cdt) return;
root._cdt = build(root);
// Reduced motion: the numbers simply change, without flipping.
if (reduced) start(root._cdt, false);
else io.observe(root);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-countdown-timer="2027-01-01T00:00:00" style="font-size: 48px"></div>
<!-- dark cards, hours to go: -->
<div data-countdown-timer="2027-01-01T00:00:00" data-units="h,m,s" data-card="#18181b" style="color: #fff"></div>