shshadcn-htmx

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.json

2. Use it

components/ui/relative-time.tsx
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
components/ui/relative-time.tsx
/** @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

templates/components/relative-time.html
{% 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
templates/components/relative-time.html
{# 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

components/relative-time.tmpl
{{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
components/relative-time.tmpl
{{/*
  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

lib/my_app_web/components/relative_time.ex
<.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
lib/my_app_web/components/relative_time.ex
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

snippets/relative-time.html
<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
snippets/relative-time.html
<!--
  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>

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>

API Reference

<RelativeTime>

PropTypeDefaultDescription
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.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required