Skip to main content
astrocraft-ui/ components · 101

Relative Time

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 utility/relative-time

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add utility/relative-time --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add utility/relative-time --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add utility/relative-time --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add utility/relative-time --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
`RelativeTime.astro``relative-time`———

Source

What the command copies — 3 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/utility/relative-time/RelativeTime.astro
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"time"> & {
  datetime: string;
  locale?: string;
};

const { datetime, locale, class: className, ...rest } = Astro.props;

const date = new Date(datetime);
const iso = Number.isNaN(date.valueOf()) ? datetime : date.toISOString();
const absolute = Number.isNaN(date.valueOf())
  ? datetime
  : new Intl.DateTimeFormat(locale, {
      year: "numeric",
      month: "short",
      day: "numeric",
      hour: "2-digit",
      minute: "2-digit",
      timeZone: "UTC",
      timeZoneName: "short",
    }).format(date);
---

<time datetime={iso} title={absolute} class={className} data-slot="relative-time" {...rest}>
  {absolute}
</time>

<script>
  import { onReadyOnce } from "../../_once";
  import { refreshDelay, relativeParts } from "./format";

  function wire(el: HTMLElement) {
    const ms = Date.parse(el.getAttribute("datetime") ?? "");
    if (Number.isNaN(ms)) {
      console.error(`RelativeTime: \`datetime\` is not a parseable date: ${el.textContent}`);
      return;
    }

    const lang = document.documentElement.lang || undefined;
    const relative = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
    const absolute = new Intl.DateTimeFormat(lang, { dateStyle: "long", timeStyle: "short" });
    el.title = absolute.format(ms);

    const render = () => {
      if (!el.isConnected) return;
      const diff = ms - Date.now();
      const { value, unit } = relativeParts(diff);
      el.textContent = relative.format(value, unit);
      setTimeout(render, refreshDelay(diff));
    };

    render();
  }

  onReadyOnce('[data-slot="relative-time"]', wire);
</script>
src/components/ui/utility/relative-time/format.ts
export interface RelativeParts {
  value: number;
  unit: Intl.RelativeTimeFormatUnit;
}

const UNITS: readonly { unit: Intl.RelativeTimeFormatUnit; ms: number }[] = [
  { unit: "year", ms: 31_536_000_000 },
  { unit: "month", ms: 2_592_000_000 },
  { unit: "week", ms: 604_800_000 },
  { unit: "day", ms: 86_400_000 },
  { unit: "hour", ms: 3_600_000 },
  { unit: "minute", ms: 60_000 },
  { unit: "second", ms: 1_000 },
];

export function relativeParts(diffMs: number): RelativeParts {
  const diff = Number.isFinite(diffMs) ? diffMs : 0;
  const abs = Math.abs(diff);
  const bucket = UNITS.find((u) => abs >= u.ms);
  if (!bucket) return { value: 0, unit: "second" };
  return { value: Math.trunc(diff / bucket.ms), unit: bucket.unit };
}

export function refreshDelay(diffMs: number): number {
  const abs = Math.abs(Number.isFinite(diffMs) ? diffMs : 0);
  if (abs < 60_000) return 10_000;
  if (abs < 3_600_000) return 60_000;
  if (abs < 86_400_000) return 900_000;
  return 3_600_000;
}
src/components/ui/utility/relative-time/index.ts
import RelativeTime from "./RelativeTime.astro";

export { refreshDelay, type RelativeParts, relativeParts } from "./format";
export { RelativeTime };
export default RelativeTime;

What you get

The component source, copied into your project by npx astrocraft-ui add utility/relative-time — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.