component — display

Elapsed Time

How long this has been going on: a semantic <time> element with a tick on top, formatted through Intl with a locale named in the code. Renders the zero duration until mount rather than reading the clock during render, so a prerendered page and its hydration agree.

  • time
  • duration
  • timer
  • elapsed
  • formatting
  • display

03 examples

Running

With no `now` the component renders the zero duration until it has mounted, then picks up the reference point in an effect and ticks once a second. It is a `<time>` element, not a live region: a duration that announces itself every second is a metronome, not information.

—

A run that has finished

Pass `now` and the output is a pure function of its props — identical in a prerender and in the browser that hydrates it, and stable because there is nothing left to tick.

under a minute
the four-minute Bash call
past an hour, so the hours appear

Clock, or units

`clock` is the shape to put next to a running Activity Pulse: compact, tabular, and only the digits that changed appear to change. `units` spells the units through `Intl` in the locale you name, for prose and for a duration that is read once rather than watched.

source

elapsed-time.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { useEffect, useState } from "react"
import { getNumberFormat } from "@/lib/intl"
import { cn } from "@/lib/utils"

/**
 * ElapsedTime — quebi design system
 *
 * The clock that sits beside an `ActivityPulse`: how long this has been going
 * on. A formatter with a tick on top, and the newest member of the
 * `FormattedDate` / `FormattedNumber` / `FormattedStorage` family rather than a
 * second kind of thing — which is why the numbers go through `Intl` with a
 * locale that is named in the code, never through `toLocaleString()`.
 *
 * ## Why it does not read the clock during render
 *
 * This site is prerendered (`ssr: false` plus a `prerender()` list), so every
 * page is HTML generated in Node and hydrated in a browser some time later. A
 * component that computed `Date.now() - start` while rendering would bake one
 * number into the HTML and compute a different one at hydration — the same bug
 * `FormattedDate` avoids for "3 days ago", and it takes the same two shapes
 * here:
 *
 * - Pass `now` and the output is a pure function of its props, identical in
 *   both runtimes. That is also how you render a *finished* run: `now` is when
 *   it ended, and `isRunning={false}` stops the tick.
 * - Pass nothing and the zero duration is rendered until mount, after which the
 *   reference point is picked up in an effect, where there is only one runtime
 *   left to disagree with.
 *
 * ## It is not a live region
 *
 * A duration that announces itself every second is a metronome, not
 * information — the same reason `ActivityPulse` keeps its strip `aria-hidden`
 * and puts the label in the live region instead. This renders a semantic
 * `<time>` with a machine-readable `dateTime` duration and says nothing until
 * it is read.
 *
 * @example
 * <ElapsedTime start={startedAt} isRunning />                        // 4:12
 * <ElapsedTime start={startedAt} now={finishedAt} format="units" />  // 4 Min. 12 Sek.
 */

/**
 * `clock` is `4:12` / `1:02:03` — compact, monospaced, and the right shape next
 * to a running pulse. `units` spells the units out through `Intl`, for prose
 * and for a duration that is read once rather than watched.
 */
export type ElapsedTimeFormat = "clock" | "units"

/** Two units is the readable maximum: "1 Std. 4 Min.", never "1 Std. 4 Min. 9 Sek.". */
const MAX_UNITS = 2

export interface ElapsedTimeProps
  extends Omit<React.ComponentProps<"time">, "children" | "dateTime"> {
  /** When the run began. A Date, an ISO string, or a timestamp. */
  start: Date | string | number
  /**
   * The reference point. Pass it to make the output deterministic — and to
   * render a finished run, where it is the moment the run ended. Without it,
   * the zero duration renders until mount.
   */
  now?: Date | string | number
  /** Whether to keep ticking. Ignored when `now` is given, which pins the answer. */
  isRunning?: boolean
  /** How often to re-read the clock, in ms. */
  tickMs?: number
  format?: ElapsedTimeFormat
  /**
   * BCP 47 locale tag, pinned rather than read from the runtime for the reason
   * in the component docs. Defaults to `"de"`, as `FormattedDate` does.
   */
  locale?: string
}

/** `4:12`, or `1:02:03` once there are hours. Minor parts are always two digits. */
function formatClock(totalSeconds: number, locale: string): string {
  const hours = Math.floor(totalSeconds / 3600)
  const minutes = Math.floor(totalSeconds / 60) % 60
  const seconds = totalSeconds % 60
  const plain = getNumberFormat(locale, { useGrouping: false })
  const padded = getNumberFormat(locale, { minimumIntegerDigits: 2, useGrouping: false })

  return hours > 0
    ? `${plain.format(hours)}:${padded.format(minutes)}:${padded.format(seconds)}`
    : `${plain.format(minutes)}:${padded.format(seconds)}`
}

/** The two most significant non-zero units, each spelled by `Intl`. */
function formatUnits(totalSeconds: number, locale: string): string {
  const parts: { value: number; unit: "hour" | "minute" | "second" }[] = [
    { value: Math.floor(totalSeconds / 3600), unit: "hour" },
    { value: Math.floor(totalSeconds / 60) % 60, unit: "minute" },
    { value: totalSeconds % 60, unit: "second" },
  ]
  // Leading zeros are noise ("0 Std. 4 Min."); a zero *between* two units is
  // not, so the slice runs from the first non-zero part rather than filtering.
  const first = parts.findIndex((part) => part.value > 0)
  const shown = first === -1 ? parts.slice(-1) : parts.slice(first, first + MAX_UNITS)

  return shown
    .map((part) =>
      getNumberFormat(locale, {
        style: "unit",
        unit: part.unit,
        unitDisplay: "narrow",
        maximumFractionDigits: 0,
      }).format(part.value),
    )
    .join(" ")
}

/** The `datetime` attribute's value: an ISO 8601 duration. */
function isoDuration(totalSeconds: number): string {
  const hours = Math.floor(totalSeconds / 3600)
  const minutes = Math.floor(totalSeconds / 60) % 60
  const seconds = totalSeconds % 60
  return `PT${hours > 0 ? `${hours}H` : ""}${minutes > 0 ? `${minutes}M` : ""}${seconds}S`
}

export function ElapsedTime({
  start,
  now,
  isRunning = false,
  tickMs = 1000,
  format = "clock",
  locale = "de",
  className,
  ...props
}: ElapsedTimeProps) {
  const [mountedNow, setMountedNow] = useState<number | null>(null)

  useEffect(() => {
    if (now !== undefined) return
    setMountedNow(Date.now())
    if (!isRunning) return
    const id = setInterval(() => setMountedNow(Date.now()), tickMs)
    return () => clearInterval(id)
  }, [now, isRunning, tickMs])

  const startedAt = new Date(start).getTime()
  const reference = now !== undefined ? new Date(now).getTime() : mountedNow
  const elapsed =
    reference === null || Number.isNaN(startedAt) || Number.isNaN(reference)
      ? 0
      : Math.max(0, Math.floor((reference - startedAt) / 1000))

  return (
    <time
      {...props}
      dateTime={isoDuration(elapsed)}
      // Tabular figures, because the whole point is that only the digits that
      // changed appear to change — proportional digits make a running clock
      // shuffle its own width once a second.
      className={cn("tabular-nums", className)}
    >
      {format === "clock" ? formatClock(elapsed, locale) : formatUnits(elapsed, locale)}
    </time>
  )
}