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; there is nothing else.

src/components/ui/utility/relative-time/RelativeTime.astro
---
// src/components/ui/utility/relative-time/RelativeTime.astro — headless primitive (see ../../README.md).
// "3 hours ago", "in 2 days" — a timestamp said the way people read it, on a real `<time>` element.
//
//   <RelativeTime datetime={comment.createdAt} />
//
// WHAT IS RENDERED ON THE SERVER IS THE ABSOLUTE DATE, deliberately, and it is the decision this
// component turns on. A relative string computed at build time is frozen at build time: "3 hours
// ago" on a statically generated page says three hours ago for the next fortnight, and it is wrong
// in a way the reader has no way to detect. So the server renders the absolute instant — always
// true, never stale — and the script swaps in the relative phrasing on load. With JavaScript off you
// get a date, which is worse than "3 hours ago" and far better than a lie.
//
// The absolute fallback is formatted in UTC with its zone named, because a build machine's timezone
// is not the reader's: "2:30 PM" rendered on a server in Sydney is a different afternoon in Lisbon.
// Naming the zone is what makes the fallback honest; the script then re-renders `title` in the
// reader's own zone, where it can know it.
//
// The phrasing is `Intl.RelativeTimeFormat` in the document's language — no month tables, no
// pluralisation rules, no "1 days ago", and nothing to translate. Which unit it picks and how often
// it re-renders are in `format.ts`, where both are checked.
//
// `title` is a POINTER affordance only — it never appears for a keyboard or touch user, and it is not
// where accessibility lives. The machine-readable instant is `datetime`, which is what assistive tech
// and every parser reads, and the announced text is the same relative phrase that is on screen.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"time"> & {
  /**
   * The instant, as an ISO 8601 string with an offset or `Z` — `date.toISOString()` from a Date.
   * It stays a string because it IS the `datetime` attribute, whose type the element already fixes.
   */
  datetime: string;
  /** Locale for the server-rendered fallback only. The script uses the reader's own. */
  locale?: string;
};

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

const date = new Date(datetime);
const iso = Number.isNaN(date.valueOf()) ? datetime : date.toISOString();
// dateStyle/timeStyle cannot be combined with timeZoneName — Intl throws — so the parts are explicit.
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" });
    // No timeZone given, so this one resolves to the READER's — which the server could not know.
    const absolute = new Intl.DateTimeFormat(lang, { dateStyle: "long", timeStyle: "short" });
    el.title = absolute.format(ms);

    const render = () => {
      // A view transition swaps the element out while its timer is still pending. Without this the
      // chain runs forever against a detached node — one leaked timer per timestamp per navigation.
      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
// src/components/ui/utility/relative-time/format.ts — the two pure rules behind RelativeTime: which unit to
// say it in, and when to say it again. A plain module so both are unit-checkable (see format.test.ts)
// without a DOM or a wall clock.

/** The largest unit that still describes a gap, and how many of it — ready for `Intl`. */
export interface RelativeParts {
  /** Signed: negative is the past ("3 hours ago"), positive the future ("in 3 hours"). */
  value: number;
  unit: Intl.RelativeTimeFormatUnit;
}

/**
 * The buckets, largest first. A month is 30 days and a year is 365: this is a label on a timestamp,
 * not a calendar calculation, and nobody reading "2 months ago" is counting.
 */
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 },
];

/**
 * Turn a signed gap into the number and unit to render it with.
 *
 * Truncated, never rounded, and that is a correctness choice rather than a style one: rounding turns
 * 90 minutes into "2 hours ago", which claims more time has passed than actually has. Every UI that
 * people trust under-reports here.
 *
 * @param diffMs - target minus now, in milliseconds (negative for the past)
 * @returns the value and unit to hand to `Intl.RelativeTimeFormat`
 * @example relativeParts(-5_400_000) // => { value: -1, unit: "hour" } — "1 hour ago", not 2
 */
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);
  // Under a second there is no unit small enough; `numeric: "auto"` renders 0 seconds as "now".
  if (!bucket) return { value: 0, unit: "second" };
  return { value: Math.trunc(diff / bucket.ms), unit: bucket.unit };
}

/**
 * How long to wait before re-rendering — the backing-off interval.
 *
 * A timestamp's label gets less volatile as it ages: "12 seconds ago" is wrong in a second, "3 months
 * ago" is right for a fortnight. A fixed one-second timer on a page of fifty timestamps is fifty
 * timers waking the tab every second to write the same string, which on a long thread is measurable
 * battery for no change at all.
 *
 * @param diffMs - target minus now, in milliseconds
 * @returns milliseconds to wait
 */
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.