Relative Time
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 utility/relative-timePlain-CSS theme — no build step
npx astrocraft-ui add utility/relative-time --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add utility/relative-time --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add utility/relative-time --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add utility/relative-time --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 |
|---|---|---|---|---|
| `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 — 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 — 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;
}
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.