Theme
On this page (13)
Component Skill — components/countdown/component-skill.md

Native basis

A <span style="--value:N">N</span> per number; CSS math (mod(), round()) picks two rolling digit columns. The span's text is the accessible fallback.

Web Platform APIs

mod()round()@propertycontent: … / ""role="timer"Intl.DurationFormatprefers-reduced-motion

Classes

.countdown.countdown-group.countdown-unit.countdown-label

Data attributes

data-digits (2, 3), data-size (sm … 3xl), data-speed (fast, slow); timers: data-until, data-duration, data-paused, data-unit; boxes: .countdown-unit[data-variant] (muted, primary, outline).

§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 from data-until / data-duration; a plain countdown takes config.value / config.values
  • running - ticking; resumes, or starts toward config.until / config.duration
  • paused - frozen at the remaining time
  • finished - at zero, countdown:finished fired

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:

StateTypeValuesDefaultDescription
remainingnumber—0Seconds left - setState('running', { duration }) restarts toward it; observed from getState().config.remaining.
runningbooleantrue, falsetrueThe timer ticks (unchecking pauses it).
pausedbooleantrue, falsefalseFrozen at the remaining time.
finishedbooleantrue, falsefalseAt 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