Skip to main content
astrocraft-ui/ components · 101

Countdown

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add display/countdown

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add display/countdown --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add display/countdown --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/countdown --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/countdown --theme tailwind --bridge lumos

Live 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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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>
src/components/ui/display/countdown/index.ts
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
// 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.