Components
Relative Time
A semantic timestamp built on the native <time> element. The server renders a machine-readable instant in datetime and a human label as the text. A tiny optional script re-localises the label to the visitor's locale and timezone and degrades to the server value with no JavaScript.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/relative-time.json2. Use it
import { RelativeTime } from "@/components/ui/relative-time"
<RelativeTime datetime="2024-05-12T09:00:00Z">3 days ago</RelativeTime>
<RelativeTime datetime="2024-05-12T09:00:00Z" format="datetime" tone="default">
May 12, 2024
</RelativeTime>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Relative Time — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A semantic timestamp. The server renders BOTH a machine-readable instant
// (the `datetime` attribute) and a human-readable label as the element's text
// content ("3 days ago", "May 15, 2024"). With no JavaScript the server label
// is what the user sees — fully progressive. When the optional site.js block
// is present it re-localises the label to the visitor's locale + timezone via
// the Intl APIs and keeps relative labels fresh, degrading silently to the
// server text if Intl is unavailable.
//
// Built on the native <time> element:
// - <time datetime> carries the machine-readable instant; the child text is
// the human label. Per the spec, when `datetime` is present the element
// MAY have descendant text; the datetime value is the attribute.
// repos/mdn/files/en-us/web/html/reference/elements/time/index.md
// The implicit ARIA role is `time` (a structural role with an HTML
// equivalent), so no extra role/ARIA is needed — AT reads the text label.
// - The localising script (returned in the docs site.js) uses the platform
// Intl.RelativeTimeFormat / Intl.DateTimeFormat. These are web standards,
// not a userland date library, so there is nothing to emulate: if a UA
// lacks them the server label simply stays.
// - htmx attrs (hx-*) and data-*/aria-* flow through {...rest} so a label
// can be hx-swapped or refreshed; verified against repos/htmx/www/reference.md.
//
// Style analogues (tokens + anatomy kept in sync):
// registry/ui/badge.tsx — inline element, data-slot, {...rest} forwarding
// registry/ui/status.tsx — text-token tones for supporting/secondary text
//
// Composition:
// <RelativeTime datetime="2024-05-12T09:00:00Z">3 days ago</RelativeTime>
// <RelativeTime datetime="2024-05-12T09:00:00Z" format="datetime" tone="muted">
// May 12, 2024
// </RelativeTime>
// "relative" → script renders "3 days ago" via Intl.RelativeTimeFormat.
// "datetime" → script renders an absolute, locale-formatted date/time via
// Intl.DateTimeFormat. The server text is the fallback for both.
export type RelativeTimeFormat = "relative" | "datetime"
export type RelativeTimeTone = "default" | "muted"
const base = "tabular-nums"
const tones: Record<RelativeTimeTone, string> = {
default: "text-foreground",
muted: "text-muted-foreground",
}
export function relativeTimeClasses(opts?: {
tone?: RelativeTimeTone
class?: ClassValue
}): string {
const tone = opts?.tone ?? "muted"
return cn(base, tones[tone], opts?.class)
}
type RelativeTimeProps = PropsWithChildren<{
// Machine-readable instant — any valid HTML `datetime` value. An ISO 8601
// string (e.g. "2024-05-12T09:00:00Z") is what the script can parse to
// re-localise; other valid `datetime` microsyntaxes still render natively.
datetime: string
// How the script should format the label. "relative" (default) → "3 days
// ago"; "datetime" → an absolute locale/timezone-aware date+time.
format?: RelativeTimeFormat
// Text tone. Defaults to "muted" — timestamps are usually supporting text.
tone?: RelativeTimeTone
id?: string
class?: ClassValue
// hx-*, data-*, aria-*, title, etc. flow straight onto the <time> element.
[key: string]: unknown
}>
export function RelativeTime(props: RelativeTimeProps) {
const {
children,
datetime,
format = "relative",
tone,
id,
class: className,
...rest
} = props
return (
<time
id={id}
datetime={datetime}
data-slot="relative-time"
data-relative-time=""
data-format={format}
class={relativeTimeClasses({ tone, class: className })}
{...rest}
>
{children}
</time>
)
}
1. Save the file
Copy relative-time.html into templates/components/.
2. Use it
{% from "components/relative-time.html" import relative_time %}
{% call relative_time("2024-05-12T09:00:00Z") %}3 days ago{% endcall %}
{% call relative_time("2024-05-12T09:00:00Z", format="datetime") %}May 12, 2024{% endcall %}View source
{# Relative Time macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/relative-time.tsx. Renders a native <time> element
carrying a machine-readable `datetime` plus a human-readable label as its
body. With no JS the server label is shown; the shared site.js block
re-localises it to the visitor's locale/timezone via the Intl APIs.
Usage:
{% from "components/relative-time.html" import relative_time %}
{% call relative_time("2024-05-12T09:00:00Z") %}3 days ago{% endcall %}
{% call relative_time("2024-05-12T09:00:00Z", format="datetime", tone="muted") %}May 12, 2024{% endcall %} #}
{% macro relative_time(
datetime,
format="relative",
tone="muted",
id=none,
extra_class="",
**attrs
) %}
{%- set base = "tabular-nums" -%}
{%- set tones = {
"default": "text-foreground",
"muted": "text-muted-foreground"
} -%}
<time
{%- if id %} id="{{ id }}"{% endif %}
datetime="{{ datetime }}"
data-slot="relative-time"
data-relative-time=""
data-format="{{ format }}"
class="{{ base }} {{ tones[tone] }} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ caller() }}</time>
{% endmacro %}
1. Save the file
Add relative-time.tmpl alongside your templates.
2. Use it
{{template "relative-time" (dict "Datetime" "2024-05-12T09:00:00Z" "Label" "3 days ago")}}
{{template "relative-time" (dict "Datetime" "2024-05-12T09:00:00Z" "Label" "May 12, 2024" "Format" "datetime")}}View source
{{/*
Relative Time template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/relative-time.tsx.
Renders a native <time> element carrying a machine-readable `datetime`
plus a human-readable label as its body. With no JS the server label is
shown; the shared site.js block re-localises it to the visitor's
locale/timezone via the Intl APIs.
Usage:
type RelativeTimeArgs struct {
Datetime string // machine-readable instant (ISO 8601)
Label string // human-readable text, e.g. "3 days ago"
Format string // relative (default) | datetime
Tone string // muted (default) | default
ID string
Attrs map[string]string
}
{{template "relative-time" (dict "Datetime" "2024-05-12T09:00:00Z" "Label" "3 days ago")}}
*/}}
{{define "relative-time"}}
{{- $format := or .Format "relative" -}}
{{- $tone := or .Tone "muted" -}}
{{- $base := "tabular-nums" -}}
{{- $tones := dict
"default" "text-foreground"
"muted" "text-muted-foreground" -}}
<time
{{- if .ID}} id="{{.ID}}"{{end}}
datetime="{{.Datetime}}"
data-slot="relative-time"
data-relative-time=""
data-format="{{$format}}"
class="{{$base}} {{index $tones $tone}}"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{htmlSafe .Label}}</time>
{{end}}
1. Save the file
Drop relative_time.ex into lib/my_app_web/components/.
2. Use it
<.relative_time datetime="2024-05-12T09:00:00Z">3 days ago</.relative_time>
<.relative_time datetime="2024-05-12T09:00:00Z" format="datetime" tone="default">
May 12, 2024
</.relative_time>View source
defmodule ShadcnHtmx.Components.RelativeTime do
@moduledoc """
Relative Time — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/relative-time.tsx. Renders a native `<time>` element
carrying a machine-readable `datetime` plus a human-readable label as its
body. With no JavaScript the server label is what the visitor sees — fully
progressive. The small behaviour `<script>` rendered with the component
re-localises the label to the visitor's locale + timezone via the platform
`Intl.RelativeTimeFormat` / `Intl.DateTimeFormat` APIs, degrading silently
to the server label if `Intl` is unavailable.
Built on the native `<time>` element (implicit ARIA role `time`); no extra
role/ARIA needed. `hx-*` / `data-*` / `aria-*` flow through `@rest`.
"""
use Phoenix.Component
@base "tabular-nums"
@tones %{
"default" => "text-foreground",
"muted" => "text-muted-foreground"
}
# Re-localise every [data-relative-time] <time> on the page. Self-guarded so
# it attaches once page-wide no matter how many timestamps render, and re-runs
# after htmx swaps. Rendered raw inside <script>. Identical to the shared
# site.js block — drop this and load site.js once instead if you prefer.
@behaviour_js """
(function () {
if (window.__shadcnRelativeTime) return;
window.__shadcnRelativeTime = true;
var DIV = [
["year", 31536000], ["month", 2592000], ["week", 604800],
["day", 86400], ["hour", 3600], ["minute", 60], ["second", 1]
];
function relLabel(then, now) {
var diff = Math.round((then - now) / 1000);
var abs = Math.abs(diff);
for (var i = 0; i < DIV.length; i++) {
if (abs >= DIV[i][1] || DIV[i][0] === "second") {
var rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
return rtf.format(Math.round(diff / DIV[i][1]), DIV[i][0]);
}
}
}
function localize(el) {
var iso = el.getAttribute("datetime");
if (!iso) return;
var t = new Date(iso);
if (isNaN(t.getTime())) return;
try {
if (el.getAttribute("data-format") === "datetime") {
el.textContent = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium", timeStyle: "short"
}).format(t);
} else if (typeof Intl.RelativeTimeFormat === "function") {
el.textContent = relLabel(t, new Date());
}
if (!el.getAttribute("title")) {
el.setAttribute("title", new Intl.DateTimeFormat(undefined, {
dateStyle: "full", timeStyle: "long"
}).format(t));
}
} catch (e) { /* leave the server label in place */ }
}
function run(root) {
(root || document)
.querySelectorAll('[data-slot="relative-time"][data-relative-time]')
.forEach(localize);
}
document.addEventListener("DOMContentLoaded", function () { run(document); });
run(document);
document.addEventListener("htmx:after:swap", function (e) { run(e.target || document); });
document.addEventListener("htmx:afterSwap", function (e) { run(e.target || document); });
setInterval(function () { run(document); }, 60000);
})();
"""
attr :datetime, :string, required: true, doc: "Machine-readable instant (ISO 8601)."
attr :format, :string, default: "relative", values: ~w(relative datetime)
attr :tone, :string, default: "muted", values: ~w(default muted)
attr :id, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true, doc: "Human-readable label, e.g. \"3 days ago\"."
def relative_time(assigns) do
assigns =
assigns
|> assign(:tone_class, Map.fetch!(@tones, assigns.tone))
|> assign(:base_class, @base)
|> assign(:behaviour_js, Phoenix.HTML.raw(@behaviour_js))
~H"""
<time
id={@id}
datetime={@datetime}
data-slot="relative-time"
data-relative-time=""
data-format={@format}
class={[@base_class, @tone_class, @class]}
{@rest}
>{render_slot(@inner_block)}</time>
<script>{@behaviour_js}</script>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<time datetime="2024-05-12T09:00:00Z"
data-slot="relative-time" data-relative-time="" data-format="relative"
class="tabular-nums text-muted-foreground">3 days ago</time>View source
<!--
shadcn-htmx — Relative Time (raw HTML snippet).
A semantic timestamp. The native <time> element carries BOTH:
- a machine-readable instant in `datetime` (ISO 8601), and
- a human-readable label as its text content ("3 days ago").
With no JavaScript the server label is exactly what the visitor sees — fully
progressive. The small behaviour <script> at the bottom re-localises the
label to the visitor's locale + timezone using the platform
Intl.RelativeTimeFormat / Intl.DateTimeFormat APIs (web standards, not a date
library), degrading silently to the server label if Intl is unavailable.
Requirements:
1. Tailwind CSS v4 theme tokens (--foreground, --muted-foreground). Copy
the :root / .dark blocks from app/styles/input.css.
2. No htmx extension. The script below is self-guarded so it attaches once
page-wide and re-runs after htmx swaps.
data-format:
"relative" (default) → "3 days ago" via Intl.RelativeTimeFormat
"datetime" → "12 May 2024, 09:00" via Intl.DateTimeFormat
-->
<!-- relative (default) -->
<time datetime="2024-05-12T09:00:00Z"
data-slot="relative-time" data-relative-time="" data-format="relative"
class="tabular-nums text-muted-foreground">3 days ago</time>
<!-- absolute, locale-formatted -->
<time datetime="2024-05-12T09:00:00Z"
data-slot="relative-time" data-relative-time="" data-format="datetime"
class="tabular-nums text-foreground">May 12, 2024</time>
<!-- Re-localise every [data-relative-time] <time>. Self-guarded; re-runs after
htmx swaps. Identical to the shared site.js block — load that once instead
if you ship multiple components. -->
<script>
(function () {
if (window.__shadcnRelativeTime) return;
window.__shadcnRelativeTime = true;
var DIV = [
["year", 31536000], ["month", 2592000], ["week", 604800],
["day", 86400], ["hour", 3600], ["minute", 60], ["second", 1]
];
function relLabel(then, now) {
var diff = Math.round((then - now) / 1000);
var abs = Math.abs(diff);
for (var i = 0; i < DIV.length; i++) {
if (abs >= DIV[i][1] || DIV[i][0] === "second") {
var rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
return rtf.format(Math.round(diff / DIV[i][1]), DIV[i][0]);
}
}
}
function localize(el) {
var iso = el.getAttribute("datetime");
if (!iso) return;
var t = new Date(iso);
if (isNaN(t.getTime())) return;
try {
if (el.getAttribute("data-format") === "datetime") {
el.textContent = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium", timeStyle: "short"
}).format(t);
} else if (typeof Intl.RelativeTimeFormat === "function") {
el.textContent = relLabel(t, new Date());
}
if (!el.getAttribute("title")) {
el.setAttribute("title", new Intl.DateTimeFormat(undefined, {
dateStyle: "full", timeStyle: "long"
}).format(t));
}
} catch (e) { /* leave the server label in place */ }
}
function run(root) {
(root || document)
.querySelectorAll('[data-slot="relative-time"][data-relative-time]')
.forEach(localize);
}
document.addEventListener("DOMContentLoaded", function () { run(document); });
run(document);
document.addEventListener("htmx:after:swap", function (e) { run(e.target || document); });
document.addEventListener("htmx:afterSwap", function (e) { run(e.target || document); });
setInterval(function () { run(document); }, 60000);
})();
</script>
Examples
Relative label — localised on the client
The server renders 'a while ago' as a safe fallback; the script swaps in a fresh, locale-aware relative label like '3 days ago'.
The datetime attribute is the machine-readable source of truth — search engines and calendars read it. The text node is the human label. When the script runs it rewrites the text with Intl.RelativeTimeFormat in the visitor's own language, and adds a title with the absolute instant on hover.
Last edited .
<RelativeTime datetime="2024-05-12T09:00:00Z">a while ago</RelativeTime>{% call relative_time("2024-05-12T09:00:00Z") %}a while ago{% endcall %}{{template "relative-time" (dict "Datetime" "2024-05-12T09:00:00Z" "Label" "a while ago")}}<.relative_time datetime="2024-05-12T09:00:00Z">a while ago</.relative_time><p class="text-sm">
Last edited
<time datetime="2024-05-12T09:00:00Z" data-slot="relative-time" data-relative-time="" data-format="relative" class="tabular-nums text-muted-foreground">a while ago</time>
.
</p>Further reading
Absolute date — locale + timezone aware
Pass format="datetime" to render an absolute date/time formatted for the visitor's locale and timezone via Intl.DateTimeFormat.
Use format="datetime" when the exact moment matters more than its distance from now (published dates, due dates). The server still ships a fixed fallback string; the script reformats it into the reader's locale so a visitor in Tokyo and one in Berlin each see a familiar format from the same datetime.
Published .
<RelativeTime datetime="2024-05-12T09:00:00Z" format="datetime" tone="default">
May 12, 2024
</RelativeTime>{% call relative_time("2024-05-12T09:00:00Z", format="datetime", tone="default") %}May 12, 2024{% endcall %}{{template "relative-time" (dict "Datetime" "2024-05-12T09:00:00Z" "Label" "May 12, 2024" "Format" "datetime" "Tone" "default")}}<.relative_time datetime="2024-05-12T09:00:00Z" format="datetime" tone="default">
May 12, 2024
</.relative_time><p class="text-sm">
Published
<time datetime="2024-05-12T09:00:00Z" data-slot="relative-time" data-relative-time="" data-format="datetime" class="tabular-nums text-foreground">May 12, 2024</time>
.
</p>Further reading
Progressive enhancement — server label is the floor
With JavaScript disabled the server-rendered text is shown verbatim. There is no spinner, no layout shift, no broken state — the element is meaningful before the script ever runs.
This is the whole point of building on <time>: the markup is complete and accessible on first paint. The script is an enhancement layered on top, not a dependency. Render whatever absolute or relative label your server computes; the client only refines it when Intl is available.
- Commit
- Comment
<RelativeTime datetime="2023-11-02T14:30:00Z">Nov 2, 2023</RelativeTime>
<RelativeTime datetime="2024-04-30T18:05:00Z">recently</RelativeTime>{% call relative_time("2023-11-02T14:30:00Z") %}Nov 2, 2023{% endcall %}
{% call relative_time("2024-04-30T18:05:00Z") %}recently{% endcall %}{{template "relative-time" (dict "Datetime" "2023-11-02T14:30:00Z" "Label" "Nov 2, 2023")}}
{{template "relative-time" (dict "Datetime" "2024-04-30T18:05:00Z" "Label" "recently")}}<.relative_time datetime="2023-11-02T14:30:00Z">Nov 2, 2023</.relative_time>
<.relative_time datetime="2024-04-30T18:05:00Z">recently</.relative_time><ul class="space-y-1 text-sm">
<li>
Commit
<time datetime="2023-11-02T14:30:00Z" data-slot="relative-time" data-relative-time="" data-format="relative" class="tabular-nums text-muted-foreground">Nov 2, 2023</time>
</li>
<li>
Comment
<time datetime="2024-04-30T18:05:00Z" data-slot="relative-time" data-relative-time="" data-format="relative" class="tabular-nums text-muted-foreground">recently</time>
</li>
</ul>Further reading
API Reference
<RelativeTime>
| Prop | Type | Default | Description |
|---|---|---|---|
datetime* | string | — | Machine-readable instant for the datetime attribute. An ISO 8601 string (e.g. 2024-05-12T09:00:00Z) is what the script can re-localise; any valid HTML datetime value still renders.MDN<time> datetime |
format | "relative"|"datetime" | "relative" | How the optional script formats the label: relative renders 3 days ago via Intl.RelativeTimeFormat; datetime renders an absolute, locale/timezone-aware date+time via Intl.DateTimeFormat.MDNIntl.RelativeTimeFormat |
tone | "default"|"muted" | "muted" | Text colour. muted for supporting timestamps, default for foreground emphasis. |
children* | Child | — | Server-rendered human label shown verbatim when JavaScript is off. The script replaces this text once Intl is available. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required