Theme
Design your own
On this page (14)
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.

§API

Generated from countdown.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type CountdownState = 'default' | 'running' | 'paused' | 'finished' - setState(name, config) takes the config of the state it names.

StateDescription
default
As authored: a timer restarts from data-until / data-duration; a plain countdown shows the given values, else its authored ones.
Config fieldTypeDescription
value?numbera plain countdown: the first unit's value (0-999)
values?Record<string, number>a plain countdown: values by unit name (data-unit: days, hours, minutes, seconds)
remaining?numberreported by getState() on a timer: the seconds left
running
Ticking - resumes, or starts toward a new deadline.
Config fieldTypeDescription
until?stringthe deadline, a date Date.parse reads
duration?numberthe deadline as seconds from now (used when until is not given)
values?Record<string, number>reported by getState(): the values shown, by unit
remaining?numberreported by getState(): the seconds left
paused
Frozen at the remaining time.
Config fieldTypeDescription
values?Record<string, number>reported by getState(): the values shown, by unit
remaining?numberreported by getState(): the seconds left
finished
At zero - countdown:finished fired.
Config fieldTypeDescription
values?Record<string, number>reported by getState(): the values shown, by unit (all 0)
remaining?numberreported by getState(): 0

Every element

MemberDescription
el.api.setState<S extends CountdownState>(name: S, config?: CountdownStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?CountdownStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: CountdownState; config: CountdownStateConfigs[CountdownState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: CountdownState; config: CountdownStateConfigs[CountdownState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: CountdownState; config: CountdownStateConfigs[CountdownState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: CountdownState; config: CountdownStateConfigs[CountdownState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: CountdownState; config: CountdownStateConfigs[CountdownState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.countdownApi.setState<S extends CountdownState>(el: HTMLElement, name: S, config?: CountdownStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?CountdownStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.countdownApi.getState(el: HTMLElement): { name: CountdownState; config: CountdownStateConfigs[CountdownState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: CountdownState; config: CountdownStateConfigs[CountdownState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.countdownApi.render(state: { name: CountdownState; config: CountdownStateConfigs[CountdownState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: CountdownState; config: CountdownStateConfigs[CountdownState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.countdownApi.store(el: HTMLElement): Store<{ name: CountdownState; config: CountdownStateConfigs[CountdownState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: CountdownState; config: CountdownStateConfigs[CountdownState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.countdownApi.commit<S extends CountdownState>(el: HTMLElement, name: S, config?: CountdownStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?CountdownStateConfigs[S]its config
df$.shadcn.countdownStates: CountdownState[]The declared states, 'default' first: default, running, paused, finished.

Events

EventDescription
countdown:finished
Fires once when the countdown reaches zero.

No detail.

§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, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
/** 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'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state (getState() reports the units shown - and a timer's remaining seconds). */
export interface CountdownStateConfigs {
  /** As authored: a timer restarts from data-until / data-duration; a plain countdown shows the given values, else its authored ones. */
  default: {
    /** a plain countdown: the first unit's value (0-999) */
    value?: number;
    /** a plain countdown: values by unit name (data-unit: days, hours, minutes, seconds) */
    values?: Record<string, number>;
    /** reported by getState() on a timer: the seconds left */
    remaining?: number;
  };
  /** Ticking - resumes, or starts toward a new deadline. */
  running: {
    /** the deadline, a date Date.parse reads */
    until?: string;
    /** the deadline as seconds from now (used when until is not given) */
    duration?: number;
    /** reported by getState(): the values shown, by unit */
    values?: Record<string, number>;
    /** reported by getState(): the seconds left */
    remaining?: number;
  };
  /** Frozen at the remaining time. */
  paused: {
    /** reported by getState(): the values shown, by unit */
    values?: Record<string, number>;
    /** reported by getState(): the seconds left */
    remaining?: number;
  };
  /** At zero - countdown:finished fired. */
  finished: {
    /** reported by getState(): the values shown, by unit (all 0) */
    values?: Record<string, number>;
    /** reported by getState(): 0 */
    remaining?: number;
  };
}
const UNITS: [unit: string, seconds: number][] = [
  ['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') ? [...dfDollar(el).find(':scope > span').toArray()] : [...dfDollar(el).find('.countdown > span').toArray()];
/** 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 paintTimer(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);
  paintTimer(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);
  // Fires once when the countdown reaches zero.
  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;
}
/**
 * The markup of a state, for render(), on a detached copy of the authored
 * markup: a plain countdown's digits ({ value } / { values } in 'default'),
 * the zeros of 'finished'. A timer's digits and label follow the clock - they
 * are written by the tick, not by a state (runtime-owned, see the e2e).
 */
function applyMarkup(el, stateName, config) {
  const spans = valuesOf(el);
  if (stateName === 'finished') {
    for (const [span, v] of split(0, spans)) writeValue(span, v);
    return;
  }
  if (isTimer(el) || stateName !== 'default') return;
  if (config?.value !== undefined && spans[0]) writeValue(spans[0], config.value);
  if (config?.values) for (const span of spans) if (span.dataset.unit in config.values) writeValue(span, config.values[span.dataset.unit]);
}
function triggerStateChange(el, stateName, config) {
  // running / paused are a timer's life cycle - a plain value countdown has
  // no clock to run (scheduling one wrote NaN into its digits)
  if (!isTimer(el) && (stateName === 'running' || stateName === 'paused')) {
    el.dataset.stateName = stateName;
    return;
  }
  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);
      paintTimer(el);
      break;
    case 'finished':
      finish(el);
      break;
  }
}
/** Registry-level API; pass the .countdown / .countdown-group explicitly. Unknown names throw. */
export const countdownApi = componentState({
  component: 'countdown',
  states: countdownStates,
  apply: (el, state) => triggerStateChange(el, state.name, state.config),
  read: (el, state) => {
    const values = {};
    valuesOf(el).forEach((s, i) => (values[s.dataset.unit || i] = parseFloat(s.style.getPropertyValue('--value')) || 0));
    const config: Record<string, unknown> = { ...state.config, values };
    if (isTimer(el)) config.remaining = Math.round(remaining(el));
    return { name: el.dataset.stateName || 'default', config };
  },
  markup: (el, state) => applyMarkup(el, state.name, state.config),
});
df$.countdownApi = countdownApi;
df$.countdownStates = countdownStates;
function init() {
  dfDollar('.countdown-group:not([data-init]), .countdown:not([data-init])').toArray().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.store + el.api (AGENTS.md "State through stores")
    bindComponent(el, countdownApi);
    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