Ansora UI
Primitives

Live duration

An elapsed time that ticks without drifting, without a hydration mismatch and without announcing itself — one shared clock for every instance on the page.

primitivetime

Install

pnpm dlx shadcn@4.23.0 add @ansora/live-duration

Examples

Usage

asOf is required and has no default: pass the instant the server rendered this data. asOf={new Date()} inside a client component is the one thing that puts the hydration mismatch back — the expression is evaluated once during SSR and again during hydration, and the two differ by a round trip.

asOf fixes HYDRATION and nothing else. Once hydrated, the running value is computed from the BROWSER's clock, so a client clock skewed against your server's shows a duration wrong by that skew for the whole session — clamped to 0:00 if the client clock sits behind from, never an exception. If a corrected wall clock matters to your screen, correct from against your own server time before you pass it; this component does not carry an offset, because the ticker is one shared module for the whole tab and forty rows would each want a different correction.

to present means frozen: no subscription, no timer, a pure function of props.

max is a DISPLAY ceiling. It freezes the number and adds data-maxed; it does not stop anything. data-maxed is compared against the ceiling ROUNDED DOWN to a whole tick — with tick="second" and max={90_500} the attribute appears at 90 s, because the displayed value quantizes and would otherwise never reach 90 500. There is deliberately no onMax: the timer this component runs is stopped in a hidden tab on purpose, which is exactly when a long session reaches its ceiling, so a callback hung on it would be least likely to fire when it mattered most. Stopping the capture is your session controller's job.

The number is never announced. role="timer" aria-live="off" — readable on demand, silent by default, because a per-second announcement talks over a doctor listening to a patient. aria-live, aria-atomic and role are removed from the props type, so this is a compile error to override, not a request. State transitions — started, paused, stopped — are what belongs in a polite live region, and they belong to whatever renders the session state around this number. There is no component for that in this registry yet.

tick: "minute" FLOORS and lags, and the lag is the part that surprises people. The displayed value is the elapsed time floored to a whole minute AS OF THE LAST WAKEUP, and wakeups are aligned to the wall clock rather than to each instance's own start — so a wait of up to 1 min 59 s can still read 0:00. Never ahead, never skipped, and it corrects itself at the next wakeup. Measured 2026-08-05 on a forty-row board: at three arbitrary instants, 14, 9 and 30 of the 40 rows read exactly one minute behind the true floored elapsed time. The same effect exists at tick: "second" and is bounded by one second, which is why nobody has noticed it there. This is the price of ticking in unison; if your screen needs the floor of the elapsed time NOW rather than at the last wakeup, this component is not it.

This component adds no function-valued prop of its own — checked as a type, not asserted — so nothing about its own API forces a client boundary. It still accepts the rest of <time>'s props, event handlers included, so passing onClick from a server component fails at runtime exactly as it would on a plain <time>. That is not something this component can enforce for you.

Three files in one item: the component, its hook (hooks/ansora/use-live-duration.ts) and the shared ticker (lib/ansora/live-clock.ts). The ticker is module state on purpose — one setTimeout chain and one clock reading for every instance in the tab, so forty rows on a dispatch board cost one wakeup, not forty. There is no provider to mount and none to forget.

import { LiveDuration } from "@/components/ui/ansora/live-duration"

// `asOf` comes from the server render, never from `new Date()` in the client.
<LiveDuration from={startedAt} asOf={renderedAt} label="Doba nahrávání" />

// The dispatch board: minute granularity, one shared ticker for every row.
<LiveDuration from={call.queuedAt} asOf={renderedAt} tick="minute" label="Čeká" />

// Finished: `to` freezes it — no subscription, no timer.
<LiveDuration from={visit.startedAt} asOf={renderedAt} to={visit.endedAt} hours label="Délka návštěvy" />

Files

Dependencies

  • @ansora/base

On this page