Countdown
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add display/countdownPlain-CSS theme — no build step
npx astrocraft-ui add display/countdown --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add display/countdown --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add display/countdown --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add display/countdown --theme tailwind --bridge lumosLive demo
Countdown & RelativeTime
Neither one announces on every tick. The countdown's digits are role="timer", whose aria-live is off by definition — readable on demand, silent while running — and a separate polite region speaks only at thresholds. Both read the clock rather than counting: a setInterval is throttled in a background tab and stops while a laptop sleeps.
- Last edited
- Created
View source on those two: the server rendered an absolute date in UTC, and the script replaced it with the relative phrasing in your locale. A relative string baked at build time would still say "3 hours ago" in a fortnight.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `Countdown.astro` | `countdown` `countdown-status` | — | `data-ended` | — |
| `CountdownUnit.astro` | `countdown-label` `countdown-unit` `countdown-value` | — | — | — |
Source
What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/display/countdown/Countdown.astro — headless primitive (see ../../README.md).
// Time remaining until a moment: a sale ending, a session timing out, a launch. Compose
// CountdownUnit for the parts you want to show.
//
// <Countdown to="2026-12-25T09:00:00Z" label="Time until launch">
// <CountdownUnit unit="days" label="days" />
// <CountdownUnit unit="hours" label="hrs" />
// <CountdownUnit unit="minutes" label="min" />
// </Countdown>
//
// TWO THINGS ARE THE COMPONENT, and neither is the digits.
//
// • IT ANNOUNCES AT THRESHOLDS, NOT AT TICKS. A live region that updates every second interrupts a
// screen reader every second — the page becomes unreadable while the timer is on it, and the one
// thing the user needed ("five minutes left") is buried in three hundred announcements that said
// nothing. So the digits are NOT a live region: the root is `role="timer"`, whose `aria-live` is
// `off` by definition, which leaves the value readable on demand without it shouting. The
// announcements come from a separate polite region that speaks only when the countdown crosses
// one of the marks in `parts.ts`, and says so through `Intl.RelativeTimeFormat` in the
// document's own language rather than an English sentence built in JavaScript.
// • IT READS THE CLOCK, NEVER A COUNTER. Every tick recomputes from `Date.now()` against the
// target. `setInterval` is not a clock — it drifts, it is throttled to once a minute in a
// background tab, and it stops entirely while a laptop is asleep — so anything that decrements a
// stored number is minutes wrong by lunchtime and cannot be fixed by a shorter interval.
//
// `to` is an absolute instant: pass an ISO 8601 string WITH its offset or `Z`. A local-looking
// string ("2026-12-25T09:00") means a different moment in every timezone the page is read in.
//
// With JavaScript off the units render their placeholders and `datetime` carries the target, which
// is all a static page honestly can: the remaining time at build time is not the remaining time now.
// Put the absolute date in your own copy beside it.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"time"> & {
/** The instant to count down to, as an ISO 8601 string with an offset or `Z`. */
to: string;
/** Names the timer. Without it, it is announced as an unlabelled one. */
label?: string;
/** Announced once when the countdown reaches zero. */
endedText?: string;
};
const { to, label, endedText = "Time is up", class: className, ...rest } = Astro.props;
---
<time
role="timer"
datetime={to}
aria-label={label}
class={className}
data-slot="countdown"
data-to={to}
data-ended-text={endedText}
{...rest}
>
<slot />
</time>
{/* The only live region here — see the header for why the digits above are not one. */}
<span role="status" aria-live="polite" data-slot="countdown-status"></span>
<script>
import { onReadyOnce } from "../../_once";
import { crossedThreshold, remaining } from "./parts";
function wire(root: HTMLElement) {
const target = Date.parse(root.dataset.to ?? "");
// An unparseable date is a consumer mistake, and rendering `NaN:NaN` at them is a poor way to
// report it. Say it once, leave the placeholders alone, and let the page work.
if (Number.isNaN(target)) {
console.error(`Countdown: \`to\` is not a parseable date: ${root.dataset.to}`);
return;
}
const status = root.nextElementSibling;
const units = [...root.querySelectorAll<HTMLElement>('[data-slot="countdown-unit"]')];
const announce = (text: string) => {
if (status instanceof HTMLElement && status.dataset.slot === "countdown-status") {
status.textContent = text;
}
};
// The document's own language, so the announcement is localised by the platform rather than by a
// sentence assembled here. `undefined` falls back to the browser's locale.
const rtf = new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, {
numeric: "auto",
});
let previous = Number.POSITIVE_INFINITY;
let timer: ReturnType<typeof setInterval> | undefined;
const tick = () => {
// A view transition swaps the element out while the interval is still running; without this
// the timer ticks forever against a detached node, one leak per countdown per navigation.
if (!root.isConnected) return clearInterval(timer);
// Recomputed from the clock every time — see the header. This is what survives a sleeping tab.
const left = Math.max(0, target - Date.now());
const parts = remaining(left);
for (const unit of units) {
const key = unit.dataset.unit;
if (key === "days" || key === "hours" || key === "minutes" || key === "seconds") {
const value = unit.querySelector<HTMLElement>('[data-slot="countdown-value"]');
const next = String(parts[key]).padStart(unit.dataset.pad === "true" ? 2 : 1, "0");
// Only touch the DOM when the digit actually changed: days and hours are identical on 3599
// consecutive ticks, and a write is a style recalculation whether or not anything moved.
if (value && value.textContent !== next) value.textContent = next;
}
}
const mark = crossedThreshold(previous, left);
if (mark) announce(rtf.format(mark.value, mark.unit));
previous = left;
if (left > 0) return;
clearInterval(timer);
root.dataset.ended = "true";
announce(root.dataset.endedText ?? "");
// Your hook for whatever expiry means here — closing a checkout, refreshing a price, logging
// out. The library only knows the clock ran out.
root.dispatchEvent(new Event("countdown:end", { bubbles: true }));
};
tick();
if (target > Date.now()) timer = setInterval(tick, 1000);
}
onReadyOnce('[data-slot="countdown"]', wire);
</script>
---
// src/components/ui/display/countdown/CountdownUnit.astro — Countdown compound part (see ../../README.md).
// One field of the countdown. The value and the unit's name are separate elements so a theme can
// draw the digits large and the word small without either of them being an image of text.
//
// `pad` zero-pads to two digits, which is what stops a clock jittering sideways every time it goes
// from 10 to 9. It is off for `days`, where a leading zero is noise, and on by default for the rest.
//
// The placeholder is what shows before the script's first tick and in a browser with JavaScript off
// — an em dash rather than a zero, because a zero is a lie that looks like a value.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"span"> & {
unit: "days" | "hours" | "minutes" | "seconds";
/** The word beside the digits ("hrs", "minutes", "min"). Yours to translate. */
label?: string;
/** Zero-pad to two digits. Defaults on for everything but `days`. */
pad?: boolean;
placeholder?: string;
};
const {
unit,
label,
pad = unit !== "days",
placeholder = "—",
class: className,
...rest
} = Astro.props;
---
<span
class={className}
data-slot="countdown-unit"
data-unit={unit}
data-pad={pad ? "true" : "false"}
{...rest}
>
<span data-slot="countdown-value">{placeholder}</span>
{label && <span data-slot="countdown-label">{label}</span>}
</span>
import Countdown from "./Countdown.astro";
import CountdownUnit from "./CountdownUnit.astro";
export { crossedThreshold, type Remaining, remaining, type Threshold, THRESHOLDS } from "./parts";
export { Countdown, CountdownUnit };
export default Countdown;
// src/components/ui/display/countdown/parts.ts — the two pure rules behind Countdown, in a plain module so
// they are unit-checkable (see parts.test.ts) without a DOM, a clock, or a second of waiting.
/** A remaining duration, split the way a countdown displays it. */
export interface Remaining {
/** Milliseconds left, clamped at 0. */
total: number;
days: number;
/** 0–23: hours WITHIN the day, not hours in total. */
hours: number;
minutes: number;
seconds: number;
}
/**
* Split a remaining duration into days / hours / minutes / seconds.
*
* Every field but `days` is a remainder, which is the whole point: a countdown that renders 2 days
* and 50 hours has added the same time twice. Negative input clamps to zero — a countdown that has
* passed is over, not counting up — and so does anything unparseable, since the alternative is `NaN`
* rendered into the page.
*
* @param ms - milliseconds remaining
* @example remaining(90_061_000) // => { days: 1, hours: 1, minutes: 1, seconds: 1, total: … }
*/
export function remaining(ms: number): Remaining {
const total = Number.isFinite(ms) ? Math.max(0, ms) : 0;
const secs = Math.floor(total / 1000);
return {
total,
days: Math.floor(secs / 86400),
hours: Math.floor(secs / 3600) % 24,
minutes: Math.floor(secs / 60) % 60,
seconds: secs % 60,
};
}
/** A point at which a countdown is worth interrupting someone about. */
export interface Threshold {
/** Milliseconds remaining at the crossing. */
ms: number;
/** The number and unit to announce — passed straight to `Intl.RelativeTimeFormat`. */
value: number;
unit: "day" | "hour" | "minute" | "second";
}
/**
* The moments a countdown announces, largest first. Deliberately sparse and deliberately uneven:
* they cluster as the deadline approaches, because that is when the information changes what someone
* does. Announcing every second would make the page unusable with a screen reader — the region
* interrupts whatever is being read, once a second, forever.
*/
export const THRESHOLDS: readonly Threshold[] = [
{ ms: 86_400_000, value: 1, unit: "day" },
{ ms: 3_600_000, value: 1, unit: "hour" },
{ ms: 600_000, value: 10, unit: "minute" },
{ ms: 300_000, value: 5, unit: "minute" },
{ ms: 60_000, value: 1, unit: "minute" },
{ ms: 30_000, value: 30, unit: "second" },
{ ms: 10_000, value: 10, unit: "second" },
];
/**
* Which threshold, if any, the countdown has just passed.
*
* The two cases that are not obvious, and that a naive `next === t.ms` would both get wrong:
* a tick never lands exactly on a threshold (timers drift, and the value is whatever the clock said
* when the callback ran), and a tab that was backgrounded for an hour comes back having passed
* several at once. Several crossings therefore report the SMALLEST — the most urgent and the only
* one still true.
*
* @param prev - milliseconds remaining at the previous tick
* @param next - milliseconds remaining now
* @returns the threshold crossed, or `null`
* @example crossedThreshold(61_000, 59_000)?.unit // => "minute"
*/
export function crossedThreshold(prev: number, next: number): Threshold | null {
let hit: Threshold | null = null;
// THRESHOLDS runs largest to smallest, so the last match is the smallest one crossed.
for (const t of THRESHOLDS) if (prev > t.ms && next <= t.ms) hit = t;
return hit;
}
What you get
The component source, copied into your project by npx astrocraft-ui add display/countdown — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.