Timestamp
Shows a supplied instant as an absolute date or a relative phrase such as 3 hours ago, inside a time element with an ISO datetime and the full date for screen readers. Server renders absolute; relative follows after mount.
cmp_timestamp_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_timestamp_01 version 1.0.0 with variant "default", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Default
- Version
- 1.0.0
- Digest
Full digest
sha256-ead0dd4608a981ffdcdb1ca8c20babd1ecfddfd1f9409f284c1f3e2ded88b59b
<script lang="ts" module>
export type TimestampMode = 'absolute' | 'relative' | 'auto';
export type TimestampDateStyle = 'short' | 'medium' | 'long' | 'full';
export type TimestampTimeStyle = 'short' | 'medium' | 'long';
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/*
* ISO 8601: a date alone (read as midnight UTC), or a date and time with Z or an offset. A time
* without an offset is rejected, because the server and the browser would read it in their
* own zones and disagree about the instant.
*/
const ISO =
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?(?:Z|[+-]\d{2}(?::?\d{2})?))?$/i;
/** A Date or an ISO 8601 string as a valid Date, or null. Never throws. */
export function toInstant(value: string | Date | null | undefined): Date | null {
/* A copy, so a caller mutating their Date cannot split the text from the datetime. */
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : new Date(value);
if (typeof value !== 'string') return null;
const match = ISO.exec(value.trim());
if (!match) return null;
const [year, month, day, hour, minute, second] = match
.slice(1, 7)
.map((part) => Number(part ?? 0));
/* Date would roll 30 February into March; check the calendar fields survive. */
const calendar = new Date(Date.UTC(year, month - 1, day));
if (calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) return null;
if (hour > 23 || minute > 59 || second > 59) return null;
const date = new Date(value.trim().replace(' ', 'T'));
return Number.isNaN(date.getTime()) ? null : date;
}
/**
* The largest unit that reads naturally, rounded to the nearest whole one, the same way in
* both directions. Under 45 seconds is zero seconds, which Intl.RelativeTimeFormat with
* numeric 'auto' words as "now".
*/
export function relativeParts(
instant: number,
now: number
): [number, Intl.RelativeTimeFormatUnit] {
const delta = instant - now;
const span = Math.abs(delta);
const round = (unit: number) => Math.sign(delta) * Math.round(span / unit);
if (span < 45 * SECOND) return [0, 'second'];
if (span < 45 * MINUTE) return [round(MINUTE), 'minute'];
if (span < 22 * HOUR) return [round(HOUR), 'hour'];
if (span < 26 * DAY) return [round(DAY), 'day'];
if (span < 320 * DAY) return [round(30.44 * DAY), 'month'];
return [round(365.25 * DAY), 'year'];
}
/**
* A locale both formatters support, or 'en'. A well-formed but unknown tag would otherwise
* fall back to each runtime's own default, so the server and the browser could disagree.
*/
function supportedLocale(locale: string): string {
try {
const [tag] = Intl.getCanonicalLocales(locale);
const known =
Intl.DateTimeFormat.supportedLocalesOf(tag).length > 0 &&
Intl.RelativeTimeFormat.supportedLocalesOf(tag).length > 0;
return tag && known ? tag : 'en';
} catch {
return 'en';
}
}
/** An IANA zone the runtime knows, or 'UTC'. */
function supportedZone(timeZone: string): string {
try {
new Intl.DateTimeFormat('en', { timeZone });
return timeZone;
} catch {
return 'UTC';
}
}
</script>
<script lang="ts">
interface Props {
/** The instant: an ISO 8601 string with an offset, or a Date. */
value: string | Date;
/** BCP 47 locale for every piece of text, e.g. 'en-GB'. */
locale: string;
/** IANA zone for the absolute text, e.g. 'Europe/London'. Never guessed from the visitor. */
timeZone: string;
/** auto shows relative text for recent instants and absolute text past the threshold. */
mode?: TimestampMode;
/** Date style of the absolute text. */
dateStyle?: TimestampDateStyle;
/** Time style of the absolute text; omit for the date alone. */
timeStyle?: TimestampTimeStyle;
/** Show the zone's short name ("BST", "GMT+9") after the absolute text. */
showTimeZone?: boolean;
/** In auto mode, instants further away than this many days show absolute text. */
relativeThresholdDays?: number;
/** Seconds between updates of relative text; 0 keeps the first reading. */
refreshSeconds?: number;
/** A fixed "now" for previews and tests. Set, the text never refreshes. */
now?: string | Date;
/** Shown when value is not a valid instant. */
fallback?: string;
/** Extra classes on the time element, for size, weight or colour. */
class?: string;
}
let {
value,
locale,
timeZone,
mode = 'auto',
dateStyle = 'medium',
timeStyle,
showTimeZone = false,
relativeThresholdDays = 7,
refreshSeconds = 60,
now,
fallback = 'Date unavailable',
class: className
}: Props = $props();
const instant = $derived(toInstant(value));
const lang = $derived(supportedLocale(locale));
const zone = $derived(supportedZone(timeZone));
/*
* The clock is null on the server and through hydration, so the first render anywhere is
* the absolute text and never depends on when the request ran. The effect below reads the
* clock after mount, which is when relative text can appear.
*/
let clock = $state<number | null>(null);
const absolute = $derived(
instant
? new Intl.DateTimeFormat(lang, { dateStyle, timeStyle, timeZone: zone }).format(instant)
: ''
);
/** The unambiguous reading: weekday, full date, time and zone, for the title and screen readers. */
const full = $derived(
instant
? new Intl.DateTimeFormat(lang, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: zone,
timeZoneName: 'short'
}).format(instant)
: ''
);
/*
* dateStyle and timeStyle cannot be combined with timeZoneName, so the label is its own part.
* The long time style already ends in the zone, so it gets no second label.
*/
const zoneLabel = $derived(
instant && showTimeZone && timeStyle !== 'long'
? (new Intl.DateTimeFormat(lang, { timeZone: zone, timeZoneName: 'short' })
.formatToParts(instant)
.find((part) => part.type === 'timeZoneName')?.value ?? '')
: ''
);
const relative = $derived.by(() => {
if (!instant || clock === null || mode === 'absolute') return '';
if (mode === 'auto' && Math.abs(instant.getTime() - clock) > relativeThresholdDays * DAY) {
return '';
}
const [amount, unit] = relativeParts(instant.getTime(), clock);
return new Intl.RelativeTimeFormat(lang, { numeric: 'auto' }).format(amount, unit);
});
$effect(() => {
const fixed = toInstant(now);
if (fixed) {
clock = fixed.getTime();
return;
}
clock = Date.now();
if (!instant || mode === 'absolute' || !(refreshSeconds > 0)) return;
/*
* Updates are silent (no live region) and stop while the tab is hidden. The period is
* capped at the largest delay timers accept (about 24.8 days); past it they fire at once.
*/
const period = Math.min(Math.max(1, refreshSeconds) * SECOND, 2_147_483_647);
let timer: ReturnType<typeof setInterval> | undefined;
const stop = () => clearInterval(timer);
const start = () => {
stop();
timer = setInterval(() => (clock = Date.now()), period);
};
const onVisibility = () => {
if (document.hidden) return stop();
clock = Date.now();
start();
};
if (!document.hidden) start();
document.addEventListener('visibilitychange', onVisibility);
return () => {
stop();
document.removeEventListener('visibilitychange', onVisibility);
};
});
</script>
{#if instant}
<!--
Relative text never wraps; absolute text may break between its words, but the zone label
stays joined to the time before it by a no-break space. The full reading follows in
visually hidden text, so it does not depend on the title tooltip.
-->
<time
class={['timestamp text-(--_ink) tabular-nums', relative && 'whitespace-nowrap', className]}
datetime={instant.toISOString()}
title={full}
{lang}
data-display={relative ? 'relative' : 'absolute'}
>
{#if relative}{relative}{:else}{absolute}{#if zoneLabel} <span
class="timestamp__zone whitespace-nowrap text-(--_muted)">{zoneLabel}</span
>{/if}{/if}<span class="sr-only">, {full}</span>
</time>
{:else}
<span class={['timestamp text-(--_muted)', className]} data-display="invalid">{fallback}</span>
{/if}
<style>
/*
* Public tokens. The text inherits its colour by default, so the timestamp takes the tone of
* the line it sits in, light or dark. Muted colours the zone label and the fallback text.
*/
.timestamp {
--_ink: var(--timestamp-ink, currentColor);
--_muted: var(--timestamp-muted, #71717a);
}
</style>
Usage#
On this pagePass the instant, a locale and an IANA time zone; choose absolute, relative or auto. It renders one time element of inline text that inherits its line's size and colour. It does not detect the visitor's time zone or locale (pass them, from a profile or a cookie), does not pick a date, and does not announce its updates.
- Suggested location
src/lib/components/timestamp-01
Limitations
- Relative text needs JavaScript. Without it, and until the component mounts, every mode shows the absolute text; that is deliberate, so the server's clock never reaches the HTML.
- The width changes when the relative phrase replaces the absolute text after mount and as it ages. In a table column, give the column a minimum width.
- Rounding is to the nearest
unit: 22hours reads as yesterday even when both instants fall on the same calendar day, and months are 30.44 days. - A date-only value such as 2026-10-21 is parsed as midnight UTC, so a zone west of UTC shows the previous day. Pass a full instant with an offset.
- A datetime string without Z or an offset, such as 2026-10-21T14:05, renders the fallback: the server and the browser would read it in different zones.
- An unknown time zone falls back to UTC, and an unknown or malformed locale to English, silently.
- The fallback text is English by default; pass fallback in the page's language.
- The absolute text comes from each runtime's Intl data. If the server's Node and the visitor's browser ship different CLDR versions, punctuation can differ; the server's text stays until the first relative update.
Example
<script lang="ts">
import Timestamp from '$lib/components/timestamp-01/Timestamp.svelte';
</script>
<p class="text-sm text-zinc-500">
Amara Osei commented
<Timestamp value="2026-10-21T14:05:00+01:00" locale="en-GB" timeZone="Europe/London" />
</p>
<Timestamp
value="2026-10-21T14:05:00+01:00"
locale="en-GB"
timeZone="America/New_York"
mode="absolute"
timeStyle="short"
showTimeZone
/>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Timestamp#
An inline <time> element for comments, posts, feeds and audit logs. It shows an instant as an
absolute date or as a relative phrase, and always carries the exact instant twice: as an ISO
datetime attribute for machines, and as the full weekday, date, time and zone in visually
hidden text for screen readers.
<script lang="ts">
import Timestamp from '$lib/components/timestamp-01/Timestamp.svelte';
</script>
<p class="text-sm text-zinc-500">
Amara Osei commented
<Timestamp value={comment.createdAt} locale="en-GB" timeZone="Europe/London" />
</p>Server first, relative after mount#
The server always renders the absolute text, whatever the mode. The relative phrase is computed in an effect once the component has mounted, against the browser's clock. So the HTML never depends on when the request ran, the clock never causes a hydration mismatch, and visitors without JavaScript still see a correct date.
In auto mode (the default) an instant within relativeThresholdDays of now (7 by default)
reads as a phrase and an older one keeps its date. relative never switches; absolute never
computes a phrase and starts no timer.
Values#
Pass an ISO 8601 instant with Z or an offset (2026-10-21T14:05:00+01:00), or a Date. A date
alone (2026-10-21) reads as midnight UTC. A time without an offset, an impossible date such as
30 February, or any other string renders fallback with no datetime attribute.
Locale and time zone are yours to pass#
Both are required, because guessing them on the server is how timestamps end up an hour out.
Read them from the signed-in user's profile, or from a cookie the browser sets, and pass them
down. The relative phrases come from Intl.RelativeTimeFormat with numeric: 'auto', so
"yesterday", "in 2 days" and their equivalents in Arabic, German or Japanese need no
translation table.
Refresh#
Relative text refreshes every refreshSeconds (60 by default). The timer pauses while the tab
is hidden, re-reads the clock when it comes back, and is cleared when the component is
destroyed. Updates are silent: there is no live region. Pass refreshSeconds={0} for static
pages or very long lists.
Previews and tests#
Pass now to fix the current time. The phrase is then computed against it and never refreshes,
which is what the catalogue preview uses to show "3 hours ago" on every visit.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
value | string | Date | Yes | None | The instant: an ISO 8601 string with Z or an offset (a date alone reads as midnight UTC), or a Date. A time without an offset, an impossible date or anything else renders the fallback. |
locale | string | Yes | None | BCP 47 locale for the date, the relative phrase and the zone label, e.g. 'en-GB'. Also set as the time element's lang. |
timeZone | string | Yes | None | IANA zone for the absolute text, e.g. 'Europe/London'. The visitor's zone is never guessed. |
mode | 'absolute' | 'relative' | 'auto' | No | 'auto' | auto shows a relative phrase for instants within relativeThresholdDays and the absolute text beyond it. |
dateStyle | 'short' | 'medium' | 'long' | 'full' | No | 'medium' | Intl date style of the absolute text. |
timeStyle | 'short' | 'medium' | 'long' | No | None | Intl time style of the absolute text. Omitted, the date shows alone. |
showTimeZone | boolean | No | false | Show the zone's short name (BST, GMT+9) after the absolute text, in the muted tone. Ignored with timeStyle 'long', which already ends in the zone. |
relativeThresholdDays | number | No | 7 | In auto mode, instants further than this many days from now show the absolute text. |
refreshSeconds | number | No | 60 | Seconds between updates of the relative phrase. 0 keeps the first reading. Paused while the tab is hidden; capped at about 24.8 days. |
now | string | Date | No | None | A fixed current time for previews and tests. When set, the phrase is computed against it and never refreshes. |
fallback | string | No | 'Date unavailable' | Text shown when value is not a valid instant. |
class | string | No | None | Extra classes on the time element, for size, weight or colour. |
Customization#
On this pageChoose the mode, styles and zone label through props. The text inherits its line's colour; --timestamp-ink and --timestamp-muted retone the time and the zone label.
- Colour: the time takes the colour of the text around it. To set it directly, pass a text class through class, or set
--timestamp-inkon any ancestor. - Zone label and fallback:
--timestamp-muted,zinc-500by default. On a dark or tinted page set it to a lighter step such aszinc-400(#a1a1aa) so it still clears 4.5:1; the time itself follows the page's text. - Size and weight: the component sets none. Wrap it in your meta line's classes (
text-smtext-zinc-500) or pass them through class. - Threshold:
relativeThresholdDays="30" for GitHub-style feeds that stay relative for a month; mode="relative" never switches. - Refresh:
refreshSeconds="0" for a static page or long lists where a stale phrase is acceptable; keep 60 for comments and feeds. - Exact time on hover: the title attribute carries the full date. Replace it with your own tooltip component if you have one; the visually hidden text already covers screen readers.
- Rounding and units: edit
relativePartsin the module script to change where minutes become hours or to add weeks.
Public CSS variables
| Variable | Token |
|---|---|
--timestamp-ink | ink |
--timestamp-muted | muted |
Accessibility#
On this page- Renders a time element whose datetime attribute is the instant in ISO 8601 UTC. Screen readers do not read datetime, so the full weekday, date, time and zone follow the visible text in visually hidden
text: "3 hours ago, Wednesday 21 October 2026 at 14:05 BST". - The title attribute repeats the full date for mouse users. It is a convenience only; touch and keyboard users rely on the visible and hidden text.
- Updates to the relative phrase are not announced: there is no live region, so a feed of timestamps does not interrupt every minute.
- Invalid values render plain fallback text with no time element, so nothing machine-readable claims a date that is not there.
- The time element carries lang set to the locale, so a phrase in a language other than the page's is pronounced correctly. Set dir on a surrounding element when the locale is right-to-left and the page is not.
- Not focusable and not interactive.
Known limitations
- The zone label's contrast is checked for
zinc-500on white only; retone--timestamp-mutedon dark or tinted pages.
Release details#
On this page- Integration
- Local interaction
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.