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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/display/countdown/Countdown.astro
---
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>
src/components/ui/display/countdown/CountdownUnit.astro
---
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>
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
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.