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, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"time"> & {
to: string;
label?: string;
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>
<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 ?? "");
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;
}
};
const rtf = new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, {
numeric: "auto",
});
let previous = Number.POSITIVE_INFINITY;
let timer: ReturnType<typeof setInterval> | undefined;
const tick = () => {
if (!root.isConnected) return clearInterval(timer);
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");
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 ?? "");
root.dispatchEvent(new Event("countdown:end", { bubbles: true }));
};
tick();
if (target > Date.now()) timer = setInterval(tick, 1000);
}
onReadyOnce('[data-slot="countdown"]', wire);
</script>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"span"> & {
unit: "days" | "hours" | "minutes" | "seconds";
label?: string;
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;
export interface Remaining {
total: number;
days: number;
hours: number;
minutes: number;
seconds: number;
}
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,
};
}
export interface Threshold {
ms: number;
value: number;
unit: "day" | "hour" | "minute" | "second";
}
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" },
];
export function crossedThreshold(prev: number, next: number): Threshold | null {
let hit: Threshold | null = null;
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.