Skip to contentVibraUI
Utilities

Countdown

Time left until a moment, ticking every second, in a clock, a compact line, or unit tiles.

A value that changes every second is noise rather than news, so the timer is aria-live off and carries the whole summary as its label instead. The first paint reads no clock: it renders "--" placeholders and the label "Time remaining", so the server render and the hydrating one agree down to the attributes, and every value turns real together one render after hydration. From there the remaining time is derived from the target during render, so a new target lands immediately; the interval stops itself at zero and onComplete fires exactly once, never on a render that has not read the clock. now sets the clock it counts against: a fixed instant is where the count starts — the now of a sample world pinned to a reference date, whose sale the real clock passed long ago — and from there the seconds that pass on the page count down; with nothing to read, that first paint is already real and the same on the server and in the browser. A function is read on every tick in place of Date.now. Reduced motion stops movement, not information: a wait under ten minutes — a resend held for 45 seconds — keeps its seconds and its once-a-second tick, and a longer countdown drops the seconds (three tiles, "2d 04:13", a summary in minutes) and moves when the minute it shows changes — never sooner than a second after its last move, so a clock that stands still is read once a second — the seconds coming back for its last ten minutes. Under a second left the digits read zero and the label says "Less than a second remaining"; "Time is up" lands with data-complete and onComplete. The numbers are set in text-2xl; valueClassName sizes them — text-4xl for a sale in a hero, text-base inside a row — replacing that size in every format, the unit tiles included. splitDuration is exported for custom layouts; the unit tiles sit in data-slot="countdown-units", their numbers in countdown-unit-value.

Install

npx shadcn@latest add @vibra/countdown

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
toDate | string | number—The moment being counted down to.
nowDate | string | number | (() => Date | number)the reader's clockThe clock it counts against. A fixed instant starts the count there and counts down the seconds that pass on the page — a sample world pinned to a reference date; a function is read on every tick.
format"compact" | "units" | "clock""compact"compact reads "2d 04:13:22", clock folds the days into hours, units draws four tiles.
onComplete() => void—Called once when the target passes.
labelsPartial<Record<"days" | "hours" | "minutes" | "seconds", string>>Days, Hours, Minutes, SecondsCaptions for the unit tiles.
valueClassNamestring—Classes for the numbers, merged over their text-2xl: text-4xl for a hero, text-base in a row.

Dependencies

Source

components/ui/countdown.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"

/** Splits a millisecond duration into whole days, hours, minutes, and seconds, clamped at zero. */
export function splitDuration(ms: number): {
  days: number
  hours: number
  minutes: number
  seconds: number
} {
  const total = Math.max(0, Math.floor(ms / 1000))
  return {
    days: Math.floor(total / 86_400),
    hours: Math.floor(total / 3_600) % 24,
    minutes: Math.floor(total / 60) % 60,
    seconds: total % 60,
  }
}

type Unit = "days" | "hours" | "minutes" | "seconds"

const DEFAULT_LABELS: Record<Unit, string> = {
  days: "Days",
  hours: "Hours",
  minutes: "Minutes",
  seconds: "Seconds",
}

const UNIT_ORDER: Unit[] = ["days", "hours", "minutes", "seconds"]

// The spoken summary is built from these rather than from `labels`, which are
// display captions and may be translated or abbreviated.
const SPOKEN_UNITS: Record<Unit, string> = {
  days: "day",
  hours: "hour",
  minutes: "minute",
  seconds: "second",
}

const pad = (value: number) => String(value).padStart(2, "0")

// Stands in for every number until the clock can be read, so the markup does
// not depend on when it was rendered.
const PLACEHOLDER = "--"

// Nothing to subscribe to: the store exists only to hand the server and the
// client different snapshots, the same shape use-media-query and
// use-local-storage use to reveal a browser-only value after hydration.
const subscribeToNothing = () => () => {}
const onClient = () => true
const onServer = () => false

/** False through the server render and the hydrating one, true from the commit on — so nothing derived from the clock differs between the two. */
function useHasClock(): boolean {
  return React.useSyncExternalStore(subscribeToNothing, onClient, onServer)
}

function summarise(parts: Record<Unit, number>, units: Unit[], remaining: number): string {
  const spoken = units
    .filter((unit) => parts[unit] > 0)
    .map((unit) => `${parts[unit]} ${SPOKEN_UNITS[unit]}${parts[unit] === 1 ? "" : "s"}`)
  if (spoken.length > 0) return `${spoken.join(", ")} remaining`
  // Under the smallest unit shown the digits read zero, but the target has not
  // passed: "Time is up" waits for the moment data-complete and onComplete do.
  return remaining > 0 ? `Less than a ${SPOKEN_UNITS[units[units.length - 1]]} remaining` : "Time is up"
}

/**
 * Under this much time left the seconds are what a reader is waiting on, so
 * they stay under reduced motion: reduced motion stops movement, not
 * information.
 */
const SHORT_WAIT = 10 * 60_000

/** A moment as milliseconds, whatever form it was handed in. */
const toTime = (moment: Date | string | number) => new Date(moment).getTime()

export type CountdownProps = React.ComponentProps<"div"> & {
  /** The moment being counted down to. */
  to: Date | string | number
  /**
   * The clock it counts against — the reader's own when left out. A fixed
   * instant is where the count starts, the "now" of a sample world pinned to
   * a reference date: from there the seconds that pass on the page count
   * down, and the first paint is already real. A function is read on every
   * tick in place of `Date.now`.
   */
  now?: Date | string | number | (() => Date | number)
  format?: "compact" | "units" | "clock"
  /** Called once, when the target passes — including on mount for a target already in the past. */
  onComplete?: () => void
  labels?: Partial<Record<Unit, string>>
  /** Classes for the numbers — `text-4xl` for a hero's clock, `text-base` in a row — which replace their `text-2xl`. */
  valueClassName?: string
}

function Countdown({
  className,
  to,
  now,
  format = "compact",
  onComplete,
  labels,
  valueClassName,
  ...props
}: CountdownProps) {
  const target = React.useMemo(() => toTime(to), [to])
  // A fixed now is known on the server as well as in the browser; a clock —
  // the reader's own, or one passed as a function — is not.
  const pinned = React.useMemo(
    () => (now === undefined || typeof now === "function" ? null : toTime(now)),
    [now]
  )
  const read = typeof now === "function" ? () => toTime(now()) : Date.now
  // Two servers and a browser never agree on the millisecond, so the first
  // paint reads no clock at all: it renders placeholders from `to` alone, and
  // every value, label, and attribute below turns real together, one render
  // after hydration. The clock is the only state from then on; what is left is
  // derived from it during render, so a new `to` lands on the spot rather than
  // one effect — and one extra render — later. A fixed now needs no clock to
  // start from, so it paints real digits at once, identical on both sides.
  const hasClock = useHasClock()
  const [opened] = React.useState(() => Date.now())
  const [reading, setReading] = React.useState(read)
  const current = pinned === null ? reading : pinned + (hasClock ? reading - opened : 0)
  const known = pinned !== null || hasClock
  // Zero, not the real gap, until the clock is readable: it keeps `days > 0`
  // and every other branch below off the clock as well, not just the digits.
  const remaining = known ? Math.max(0, target - current) : 0
  const ticking = hasClock && remaining > 0

  // Reduced motion stops movement, not information. A short wait keeps its
  // seconds and its tick; a longer countdown drops the seconds and moves only
  // when the minute it shows changes — as the time left drops below a whole
  // minute, so the next tick lands a millisecond past that boundary. Never
  // sooner than a second from now, though, the seconds' own cadence: at a
  // whole number of minutes the boundary is a millisecond away, and against a
  // clock that stands still — a `now` function handing back one instant — that
  // 1 ms step re-armed a thousand times a second. A minute that turns within
  // the next second shows within a second of it; every later tick is exact.
  const reduced = useReducedMotion()
  const minutesOnly = reduced && remaining >= SHORT_WAIT
  const step = minutesOnly ? Math.max(1000, (remaining % 60_000) + 1) : 1000
  useInterval(() => setReading(read()), ticking ? step : null)

  const fired = React.useRef(false)
  React.useEffect(() => {
    // Never on the hydrating render, where `remaining` is not measured yet.
    if (!known) return
    if (remaining > 0) {
      fired.current = false
      return
    }
    if (fired.current) return
    fired.current = true
    onComplete?.()
  }, [known, remaining, onComplete])

  const parts = splitDuration(remaining)
  const { days, hours, minutes, seconds } = parts
  const shown = minutesOnly ? UNIT_ORDER.filter((unit) => unit !== "seconds") : UNIT_ORDER
  const digits = (value: number) => (known ? pad(value) : PLACEHOLDER)
  const tail = minutesOnly ? "" : `:${digits(seconds)}`
  const clock = `${digits(hours)}:${digits(minutes)}${tail}`

  return (
    <div
      data-slot="countdown"
      data-format={format}
      data-complete={(known && remaining === 0) || undefined}
      // A value that changes every second is noise, not news: the label
      // carries the summary and the ticks stay silent.
      role="timer"
      aria-live="off"
      aria-label={known ? summarise(parts, shown, remaining) : "Time remaining"}
      className={cn("w-fit text-foreground", className)}
      {...props}
    >
      {format === "units" ? (
        <div data-slot="countdown-units" className="flex items-stretch divide-x rounded-md border">
          {shown.map((unit) => (
            <div
              key={unit}
              data-slot="countdown-unit"
              data-unit={unit}
              className="flex min-w-16 flex-col items-center gap-0.5 px-3 py-2"
            >
              <span
                data-slot="countdown-unit-value"
                className={cn("text-2xl leading-none font-semibold tracking-tight tabular-nums", valueClassName)}
              >
                {digits(parts[unit])}
              </span>
              <span className="text-xs text-muted-foreground">
                {labels?.[unit] ?? DEFAULT_LABELS[unit]}
              </span>
            </div>
          ))}
        </div>
      ) : (
        <span
          data-slot="countdown-value"
          className={cn("text-2xl font-semibold tracking-tight tabular-nums", valueClassName)}
        >
          {format === "clock"
            ? `${digits(days * 24 + hours)}:${digits(minutes)}${tail}`
            : days > 0
              ? `${days}d ${clock}`
              : clock}
        </span>
      )}
    </div>
  )
}

export { Countdown }