Countdown#

Display-only digits. The app owns time — which clock, paused when, what happens at zero — and the component renders the value it is given: real digit text that assistive technology reads straight off the DOM, zero-padded to digits and clamped at 0. Each tick replaces the digits element, which is the hook a recipe's enter animation plays on.

Import#

TSX
import { Countdown } from '@sigx/zero/countdown';

Countdown is a compound: Countdown.Root, Countdown.Value. It is also re-exported from the @sigx/zero root, together with countdownAnatomy.

Usage#

TSX
import { component } from 'sigx';
import { Countdown } from '@sigx/zero/countdown';

const SaleTimer = component(({ signal, onMounted, onUnmounted }) => {
    const state = signal({ remaining: 5400 });

    onMounted(() => {
        const id = setInterval(() => {
            if (state.remaining > 0) state.remaining -= 1;
        }, 1000);
        onUnmounted(() => clearInterval(id));
    });

    return () => (
        <Countdown.Root label="Time remaining">
            <Countdown.Value value={Math.floor(state.remaining / 3600)} digits={2} />
            :
            <Countdown.Value value={Math.floor(state.remaining / 60) % 60} digits={2} />
            :
            <Countdown.Value value={state.remaining % 60} digits={2} />
        </Countdown.Root>
    );
});

There is no model and no timer inside the component. Hours, minutes and seconds are separate Value parts; the separators between them are your own text. The interval lives in the app, started in onMounted so it never runs on the server.

Why the app owns time#

A timer is application logic: which clock it follows, whether it pauses, what fires at zero. It is also an SSR hazard — a ticking interval inside a component is shared mutable state, and server markup would render an instantly stale tick anyway. So the component renders a number, and "expired" is the app's word for 0.

Naming the timer#

TSX
<Countdown.Root label="Time remaining">

With a label the root announces as role="timer" with that name. An app aria-label names it the same way. Without either the root carries no role: an unlabelled group of digits is already readable as text, and a nameless timer role is noise. role itself is refused by the type — the root's role is the component's.

Inside a sentence#

TSX
<p>
    The sale ends in <Countdown.Root mods={{ inline: true }}>
        <Countdown.Value value={minutes} digits={2} />:<Countdown.Value value={seconds} digits={2} />
    </Countdown.Root>.
</p>

Every shipped skin declares an inline modifier for a countdown set inside running text: it takes the sentence's size and weight instead of the display step, and keeps its tabular digits, its ink and the per-tick entry. On @sigx/zero-daisyui's ./components module it is a boolean prop, <Countdown inline>.

Padding and clamping#

TSX
<Countdown.Value value={7} digits={2} />   // renders "07"
<Countdown.Value value={-3} />             // renders "0"
<Countdown.Value value={4.9} />            // renders "4"

digits is the minimum digit count, zero-padded. The value is floored and clamped at zero — a countdown never goes negative.

Anatomy#

PartElementStatesFlagsNotes
rootspan——role="timer" + aria-label while label is set. Carries the variant axes.
valuespan——One unit; publishes --countdown-value. Inside root.
digitsspan——The rendered text, keyed by its content. Inside value.

Every part carries data-scope="countdown" and data-part="<part>". Each value publishes its clamped number as the --countdown-value custom property on its own element, so a recipe can drive a counter-based paint from it. The digits element is keyed by its text: when the text changes the runtime replaces the element rather than updating it, so an animation declared on the part plays once per tick — CSS owns the motion, the runtime owns nothing but the swap. There are no states and no flags: a countdown at 0 is the number 0. See The anatomy contract.

Props#

Countdown.Root#

PropTypeDefaultDescription
labelstring—Accessible name; with one the root announces as a timer.
aria-* / data-* / id / titleHTML attributes—Forwarded to the root; an aria-label names the timer like label. role is refused.
color / size / variant / axes / modsdesign-system vocabulary—The variant axes, rendered as data-* on root.
classstring—Extra classes on the root element.

Countdown.Value#

PropTypeDefaultDescription
valuenumberrequiredThe number to display; floored and clamped at 0.
digitsnumber—Minimum digit count, zero-padded.
classstring—Extra classes.

Value renders the digits part itself; it has no slot.

In the shipped design systems#

Both @sigx/zero-basic and @sigx/zero-daisyui wire color (the eight recommended roles) and size (xs–xl) on the countdown root, so <Countdown.Root color="primary" size="xl"> is styled in both, and both declare the inline modifier. Neither wires a variant on the scope; under a design system's /register import that prop is therefore absent. See Typed vocabulary.

The recipe gives value a fixed inline size in ch units and tabular figures so a tick never shifts the layout, and declares the enter animation on digits — a short translate or fade keyed to --duration-fast. Because the runtime replaces the element on every change, the animation replays without any class toggling. Under prefers-reduced-motion: reduce a recipe collapses it to a cut: the tokenised duration already drops to near zero, and a reduced-motion condition can set animation: none outright.

Progress for the same "app owns the value" shape on a bar, Skeleton for the digits before the clock is known.