CountdownATM
Numbers that roll to their next value like an odometer. Set --value on a span (0-999) and CSS turns the digits - no JavaScript. Add data-until or data-duration to a group and the optional script makes it a live, accessible timer with running, paused and finished states.
On this page (13)
§Timer
A .countdown-group with data-duration (seconds; data-until takes a date) ticks its [data-unit] values down - days, hours, minutes, seconds in .countdown-unit boxes. Pause, resume or finish it from the State tab.
§A value
The CSS-only pattern: one span with --value and the same number as text. Change both (here once a second through api.setState('default', { value })) and the digits roll.
§Large text
The digits are 1em - data-size (sm … 3xl) or any font-size scales them.
§Clock
Several values in one .countdown, separated by plain text. data-digits='2' keeps the leading zero. No component script needed: the page writes --value and the text of each unit - the CSS rolls the digits.
§With labels
A timer inline in a sentence - the unit words are plain text between the values. No days span, so the hours carry past 24.
§In boxes
data-variant on .countdown-unit boxes each value: muted, primary, outline.
§Clock in boxes
One .countdown per box with colons between the boxes - the countdown-group is the timer.
§Leading zeros
data-digits sets the minimum width: none drops leading zeros (the column narrows away as the value falls), 2 and 3 pad.
§A rolling counter
Any value 0-999, any jump: the ones digit and the higher digits roll separately. data-speed='slow' stretches the roll.
§When it ends
At zero the timer reads data-state-name='finished' (style it from there) and fires countdown:finished. Restart it with setState('default').
§States
Named states via the shared State API, bound on each timer (and each plain .countdown):
default- as authored: a timer restarts fromdata-until/data-duration; a plain countdown takesconfig.value/config.valuesrunning- ticking; resumes, or starts towardconfig.until/config.durationpaused- frozen at the remaining timefinished- at zero,countdown:finishedfired
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/countdown-{state}.png.
Machine contract - verified against countdown.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
remaining | number | — | 0 | Seconds left - setState('running', { duration }) restarts toward it; observed from getState().config.remaining. |
running | boolean | true, false | true | The timer ticks (unchecking pauses it). |
paused | boolean | true, false | false | Frozen at the remaining time. |
finished | boolean | true, false | false | At zero; countdown:finished fired. |
§CSS view file
/* -- Countdown component ------------------------------------------ *//* The size of one digit cell, resolved to px on .countdown - the value spans set font-size: 0 (their text is the accessible fallback), so every length inside them reads this instead of em. Top-level: @property is not layered. */@property --_cd-em { syntax: '<length>'; inherits: true; initial-value: 16px;}@layer components { /* A number that rolls to its new value - CSS only. Each value is a <span style="--value:N">N</span> (0-999) inside a .countdown; change --value (and the text) and the digits roll like an odometer: the ones digit and the higher digits are two independent columns (::after / ::before), picked with CSS math (mod(), round(down, …)). Leading zeros are dropped (the column narrows away) unless data-digits="2" / "3". */ .countdown { --_cd-em: 1em; --_cd-speed: 1s; display: inline-flex; align-items: center; line-height: 1; font-variant-numeric: tabular-nums; & > span { --_v: clamp(0, round(var(--value, 0)), 999); --_hi: round(down, calc(var(--_v) / 10), 1); --_lo: mod(var(--_v), 10); display: inline-flex; align-items: flex-start; height: var(--_cd-em); overflow-x: visible; overflow-y: clip; font-size: 0; /* higher digits (0-99), right-aligned so a growing number opens leftward */ &::before { content: "0\A 1\A 2\A 3\A 4\A 5\A 6\A 7\A 8\A 9\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99" / ""; width: calc((min(1, var(--_hi)) + min(1, round(down, calc(var(--_hi) / 10), 1))) * 1ch); direction: rtl; text-align: start; /* a narrowed column clips its (right-aligned) leading digits away */ overflow: hidden; translate: 0 calc(var(--_hi) * -1em); } /* the ones digit */ &::after { content: "0\A 1\A 2\A 3\A 4\A 5\A 6\A 7\A 8\A 9" / ""; width: 1ch; translate: 0 calc(var(--_lo) * -1em); } &::before, &::after { font-size: var(--_cd-em); line-height: 1; white-space: pre; transition: translate var(--_cd-speed) cubic-bezier(1, 0, 0, 1), width calc(var(--_cd-speed) / 2) ease; } } /* at least two digits: 5 → "05" */ &[data-digits="2"] > span::before { width: calc(max(1, min(1, var(--_hi)) + min(1, round(down, calc(var(--_hi) / 10), 1))) * 1ch); } /* three digits: 7 → "007" */ &[data-digits="3"] > span::before { content: "00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99" / ""; width: 2ch; } /* -- Sizes ------------------------------------------------------- */ &[data-size="sm"] { font-size: 0.875rem; } &[data-size="md"] { font-size: 1rem; } &[data-size="lg"] { font-size: 1.5rem; } &[data-size="xl"] { font-size: 2.25rem; } &[data-size="2xl"] { font-size: 3.75rem; } &[data-size="3xl"] { font-size: 6rem; } /* -- Speed: the roll's duration ----------------------------------- */ &[data-speed="fast"] { --_cd-speed: 0.4s; } &[data-speed="slow"] { --_cd-speed: 1.6s; } } /* -- Composition: a row of units, each a value over a label ---------- */ .countdown-group { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; } .countdown-unit { display: inline-flex; flex-direction: column; align-items: center; gap: 0.375rem; min-width: 4.5rem; &[data-variant] { padding: 0.75rem 1rem; border-radius: var(--radius-lg); } &[data-variant="muted"] { background-color: var(--muted); } &[data-variant="primary"] { background-color: var(--primary); color: var(--primary-foreground); & .countdown-label { color: color-mix(in oklch, var(--primary-foreground) 75%, transparent); } } &[data-variant="outline"] { border: 1px solid var(--border); } } .countdown-label { font-size: 0.75rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted-foreground); } /* -- Accessibility ------------------------------------------------ */ @media (prefers-reduced-motion: reduce) { .countdown > span::before, .countdown > span::after { transition: none; } } @media (forced-colors: active) { .countdown-unit[data-variant] { border: 1px solid CanvasText; } }}§JS view file
/* -- Countdown component ------------------------------------------- */// The rolling digits are CSS only (countdown.css reads --value). This module// is the optional timer: a .countdown / .countdown-group with data-until// (an ISO date) or data-duration (seconds) ticks its [data-unit] values down// once a second, keeps each value's text (the accessible fallback) and the// timer's label in sync, and exposes the named State API (AGENTS.md// "State API"). Plain value countdowns get the API too - setState('default',// { value }) is the CSS-only pattern's "update --value and the text".// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();/** default = as authored (a timer restarts from its data-until / data-duration); * running / paused / finished are the timer's life cycle. */const countdownStates = ['default', 'running', 'paused', 'finished'];const UNITS = [ ['days', 86400], ['hours', 3600], ['minutes', 60], ['seconds', 1],];const isTimer = (el) => el.hasAttribute('data-until') || el.hasAttribute('data-duration');/** Write one value: --value (drives the CSS roll) + the text. */function writeValue(span, n) { const v = Math.max(0, Math.min(999, Math.round(n))); span.style.setProperty('--value', String(v)); span.textContent = String(v);}/** The value spans a root owns (a group: every descendant .countdown's). */const valuesOf = (el) => el.classList.contains('countdown') ? [...el.querySelectorAll(':scope > span')] : [...el.querySelectorAll('.countdown > span')];/** Seconds left → the present units, largest first; the largest absorbs the rest. */function split(seconds, spans) { let rest = Math.max(0, Math.floor(seconds)); const out = new Map(); for (const [unit, size] of UNITS) { const span = spans.find((s) => s.dataset.unit === unit); if (!span) continue; const v = Math.floor(rest / size); out.set(span, v); rest -= v * size; } return out;}const fmt = (() => { const DF = (Intl as unknown as { DurationFormat?: new (l?: string, o?: object) => { format(d: object): string } }).DurationFormat; return DF ? new DF(undefined, { style: 'long' }) : null;})();/** The timer's accessible label ("2 days, 4 hours, …") - unless the author named it. */function label(el, parts) { if (el._authorLabel) return; const d = {}; for (const [span, v] of parts) d[span.dataset.unit] = v; const text = fmt ? fmt.format(d) : Object.entries(d).map(([u, v]) => `${v} ${u}`).join(', '); el.setAttribute('aria-label', text || '0');}function remaining(el) { if (el._paused != null) return el._paused; return Math.max(0, (el._deadline - Date.now()) / 1000);}function render(el) { const left = remaining(el); const parts = split(Math.ceil(left - 0.001), valuesOf(el)); for (const [span, v] of parts) writeValue(span, v); label(el, parts); if (left <= 0 && el.dataset.stateName !== 'finished') finish(el);}function stop(el) { clearTimeout(el._tick); el._tick = 0;}/** Tick on the second boundary of the deadline, not a drifting interval. */function schedule(el) { stop(el); render(el); if (el.dataset.stateName !== 'running') return; const ms = ((el._deadline - Date.now()) % 1000 + 1000) % 1000 || 1000; el._tick = setTimeout(() => schedule(el), ms + 5);}function finish(el) { stop(el); el._paused = 0; el.dataset.stateName = 'finished'; for (const [span, v] of split(0, valuesOf(el))) writeValue(span, v); el.dispatchEvent(new CustomEvent('countdown:finished', { bubbles: true }));}/** The authored deadline (ms since epoch) of a timer. */function authoredDeadline(el) { if (el.dataset.until) return Date.parse(el.dataset.until); return Date.now() + parseFloat(el.dataset.duration || '0') * 1000;}function triggerStateChange(el, stateName, config) { switch (stateName) { case 'default': if (isTimer(el)) { el._deadline = authoredDeadline(el); el._paused = el.hasAttribute('data-paused') ? (el._deadline - Date.now()) / 1000 : null; el.dataset.stateName = el._paused != null ? 'paused' : 'running'; schedule(el); } else { const spans = valuesOf(el); if (config?.value !== undefined && spans[0]) writeValue(spans[0], config.value); if (config?.values) for (const s of spans) if (s.dataset.unit in config.values) writeValue(s, config.values[s.dataset.unit]); if (config?.value === undefined && !config?.values) el._authored?.forEach((v, s) => writeValue(s, v)); el.dataset.stateName = 'default'; } break; case 'running': { // resume, or start toward a new { until } / { duration } if (config?.until) el._deadline = Date.parse(config.until); else if (config?.duration != null) el._deadline = Date.now() + config.duration * 1000; else if (el._paused != null) el._deadline = Date.now() + el._paused * 1000; el._paused = null; el.dataset.stateName = 'running'; schedule(el); break; } case 'paused': el._paused = remaining(el); el.dataset.stateName = 'paused'; stop(el); render(el); break; case 'finished': finish(el); break; }}/** Registry-level API; pass the .countdown / .countdown-group explicitly. Unknown names throw. */export const countdownApi = { setState(el, stateName, config = {}) { if (!countdownStates.includes(stateName)) { throw new Error(`countdown: unknown state "${stateName}" (supported: ${countdownStates.join(', ')})`); } el._stateConfig = config; triggerStateChange(el, stateName, config); }, getState(el) { const values = {}; valuesOf(el).forEach((s, i) => (values[s.dataset.unit || i] = parseFloat(s.style.getPropertyValue('--value')) || 0)); const config = { ...el._stateConfig, values }; if (isTimer(el)) config.remaining = Math.round(remaining(el)); return { name: el.dataset.stateName || 'default', config }; },};df$.countdownApi = countdownApi;df$.countdownStates = countdownStates;function init() { document.querySelectorAll('.countdown-group:not([data-init]), .countdown:not([data-init])').forEach((el) => { // a .countdown inside a timer group belongs to the group if (el.classList.contains('countdown') && !isTimer(el) && el.parentElement?.closest('.countdown-group[data-until], .countdown-group[data-duration]')) return; if (el.classList.contains('countdown-group') && !isTimer(el)) return; el.dataset.init = ''; el.api = { setState: (stateName, config) => countdownApi.setState(el, stateName, config), getState: () => countdownApi.getState(el), }; el._authored = new Map(valuesOf(el).map((s) => [s, parseFloat(s.style.getPropertyValue('--value')) || 0])); if (isTimer(el)) { el._authorLabel = el.hasAttribute('aria-label'); if (!el.hasAttribute('role')) el.setAttribute('role', 'timer'); triggerStateChange(el, 'default', {}); } else { el.dataset.stateName = 'default'; } });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub