Relative Time

"3 minutes ago" that stays current, in any language, with a server fallback.

zweiundeins <sb-relative-time> time date relative intl i18n Since 2026-09-22 MIT licence 1.6 kB1 Open in playground Edit on GitHub

Playground

Shows a moment relative to now, like "3 minutes ago" or "in 2 days", with the browser's Intl.RelativeTimeFormat. It keeps itself current (one timer for every instance on the page, each updated only when its text changes). Hovering shows the full date.

Days count calendar dates in the viewer's time zone (or the one time-zone names), like the date on hover: "yesterday" is the date before today, whatever the hour.

Put a server-formatted date inside it: that is what shows before the component loads, for search engines, and without JavaScript.

Examples

Live

This page was opened just now.
<div data-signals="{_loaded: Date.now()}">
  This page was opened <sb-relative-time data-attr:datetime="$_loaded" data-preserve-attr="datetime">just now</sb-relative-time>.
</div>

Past and future

datetime takes ISO 8601 or Unix time: a number is seconds below 10¹¹, milliseconds from there (March 1973 on). A four-digit number is a year (2026), as in HTML's <time>.

  • Launch: 21 Sep 2026
  • Next window: 15 Jan 2027
  • First light: 5 Mar 2026
<ul>
  <li>Launch: <sb-relative-time datetime="2026-09-21T09:30:00Z">21 Sep 2026</sb-relative-time></li>
  <li>Next window: <sb-relative-time datetime="2027-01-15T12:00:00Z" format="short">15 Jan 2027</sb-relative-time></li>
  <li>First light: <sb-relative-time datetime="1772668800" numeric="always">5 Mar 2026</sb-relative-time></li>
</ul>

Languages

The page's lang (or the lang attribute) picks the words, also from inside another component's shadow root. A tag written with an underscore (de_DE) works too; one the browser can't read falls back to its own language.

<ul>
  <li><sb-relative-time lang="de" datetime="2026-09-21T09:30:00Z"></sb-relative-time></li>
  <li><sb-relative-time lang="fr" datetime="2026-09-21T09:30:00Z"></sb-relative-time></li>
  <li><sb-relative-time lang="ja" datetime="2026-09-21T09:30:00Z"></sb-relative-time></li>
</ul>

A date after a while

With threshold="7", anything more than a week away shows as a date. While the page is open, a past moment turns into a date as it crosses the threshold, and a future one into relative text.

1 Jan 2026
<sb-relative-time threshold="7" datetime="2026-01-01T00:00:00Z">1 Jan 2026</sb-relative-time>

Time zones and the date on hover

time-zone takes an IANA name (Europe/Zurich) for the date on hover, the threshold date and the day count, so a page that shows its times in the server's zone says "yesterday" by the server's calendar. Empty means the viewer's zone, and so does a name the browser can't read. Hover the first two: the same moment, a different date and hour.

The date on hover follows the text's language, or title-lang (auto is the browser's own). title-style="numeric" shows it as numbers with seconds, the way Date.prototype.toLocaleString() does, to match other times on the page written that way.

  • Zurich: 22 Sep 2026
  • New York: 21 Sep 2026
  • In your language: 21 Sep 2026
<ul>
  <li>Zurich: <sb-relative-time time-zone="Europe/Zurich" datetime="2026-09-21T22:30:00Z">22 Sep 2026</sb-relative-time></li>
  <li>New York: <sb-relative-time time-zone="America/New_York" datetime="2026-09-21T22:30:00Z">21 Sep 2026</sb-relative-time></li>
  <li>In your language: <sb-relative-time title-lang="auto" title-style="numeric" datetime="2026-09-21T22:30:00Z">21 Sep 2026</sb-relative-time></li>
</ul>

From the server

The datetime attribute belongs to the server. When a Datastar stream re-renders it with a new moment, the text follows:

<p>Last paint <sb-relative-time datetime="2026-09-22T08:14:03Z">08:14</sb-relative-time></p>

Styling

Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.

  • Fonts and colours: it is text: it takes the font, size and colour of wherever you put it.
  • Parts: time, the <time> element (its title shows the full date on hover). The component sets no styles of its own, so your ::part() rules need no !important.

Launched on 1 September.

<style>
  .my-time { color: var(--sb-text-muted); }
  .my-time::part(time) { font-style: italic; text-decoration: underline dotted; }
</style>
<p>Launched <sb-relative-time class="my-time" datetime="2026-09-01T08:00:00Z">on 1 September</sb-relative-time>.</p>

Accessibility

It renders a <time datetime="…"> with the full date as its title, so the precise moment is available to assistive technology and on hover.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<sb-relative-time datetime="2026-09-22T08:00:00Z" numeric="always">22 Sep 2026</sb-relative-time>

No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.

Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/relative-time@b706abfc6d5d/relative-time.bundle.min.js" integrity="sha384-JcQdI6zg5EQfFHdCyfIHH1p/z8f5PMdFZjSTU5ScEOooPUokCG5RbTBaXQPIHH90"></script>

<sb-relative-time datetime="2026-09-22T08:00:00Z" numeric="always">22 Sep 2026</sb-relative-time>

Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/relative-time@b706abfc6d5d/relative-time.min.js": "sha384-ZpslNWLozx8nyKmPb6Xx6diMjVaqZfGfjdHZWcMkO2UxiYJXqys0JPbHIeJR5y+d"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/relative-time@b706abfc6d5d/relative-time.min.js" integrity="sha384-ZpslNWLozx8nyKmPb6Xx6diMjVaqZfGfjdHZWcMkO2UxiYJXqys0JPbHIeJR5y+d"></script>

<sb-relative-time datetime="2026-09-22T08:00:00Z" numeric="always">22 Sep 2026</sb-relative-time>

In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/relative-time@b706abfc6d5d/relative-time.bundle.min.js": "sha384-JcQdI6zg5EQfFHdCyfIHH1p/z8f5PMdFZjSTU5ScEOooPUokCG5RbTBaXQPIHH90"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-relative-time datetime="2026-09-22T08:00:00Z" numeric="always">22 Sep 2026</sb-relative-time>

Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

  • <sb-relative-time>
    • relative-time.bundle.min.js one file 1.6 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/relative-time/relative-time.bundle.min.js"></script>

<sb-relative-time datetime="2026-09-22T08:00:00Z" numeric="always">22 Sep 2026</sb-relative-time>

Or copy the module files, keeping each component's folder, and load each component's module:

  • <sb-relative-time>
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/relative-time/relative-time.min.js"></script>

<sb-relative-time datetime="2026-09-22T08:00:00Z" numeric="always">22 Sep 2026</sb-relative-time>

Size

A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
relative-time.bundle.min.js 3.3 kB1.7 kB1.6 kB
As module files: one request, 1.6 kB minified with brotli

For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.

FileOriginalgzipbrotliminified
relative-time.js 7.5 kB3.3 kB2.8 kB1.6 kB

API reference

Props

AttributeTypeDefaultDescription
datetimestring""The moment: ISO 8601 (2026-09-22T08:00:00Z) or Unix time (seconds or ms).
numeric"auto" | "always""auto""auto" allows words like "yesterday"; "always" says "1 day ago".
format"long" | "short" | "narrow""long"Length of the units ("3 minutes" / "3 min." / "3m").
langstring""Locale (default: the page's lang, then the browser's).
thresholdnumber0After this many days, show the date instead (0: always relative).
syncbooleantrueKeep the text current while the page is open.
time-zonestring""IANA time zone (Europe/Zurich) of the date on hover, the threshold date and the day count (default, and for a zone the browser can't read: the browser's).
title-langstring""Locale of the date on hover (default: the text's; "auto": the browser's).
title-style"full" | "numeric""full""full" spells out the weekday and month; "numeric" shows what toLocaleString() does, seconds included.