Countdown

Time left until a deadline, with ticking digits and a shrinking bar. Both stop at zero.

import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"

// A deadline the demo owns: when it began, when it ends, and two ways to move it. The countdown only shows it.
function useDeadline(seconds: number) {
  const [start, setStart] = useState(() => Date.now())
  const [end, setEnd] = useState(() => Date.now() + seconds * 1000)
  return {
    start,
    end,
    extend: (s: number) => setEnd((e) => Math.max(e, Date.now()) + s * 1000),
    restart: () => {
      const now = Date.now()
      setStart(now)
      setEnd(now + seconds * 1000)
    },
  }
}

// The buttons drive the deadline and are the demo's, not the countdown's: the preview frame sets them in a bar
// along its top edge, apart from the component, and on a page of your own they are two plain buttons.
export default function CountdownDemo() {
  const inquiry = useDeadline(30)
  return (
    <>
      <div data-demo-controls className="flex flex-wrap gap-2 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
        <button type="button" className="rounded-md border border-border px-2 py-1" onClick={() => inquiry.extend(15)}>
          Extend by 15 s
        </button>
        <button type="button" className="rounded-md border border-border px-2 py-1" onClick={inquiry.restart}>
          Start again
        </button>
      </div>
      <Countdown expiresAt={inquiry.end} startsAt={inquiry.start} label="Inquiry" className="font-(family-name:--tradecn-font-mono) text-xl" />
    </>
  )
}

Installation

pnpm dlx shadcn@latest add tradecn/ui/countdown#v2.0.0
npx shadcn@latest add tradecn/ui/countdown#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/countdown#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/countdown#v2.0.0

Usage

import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"

function InquiryCountdown() {
  const [receivedAt] = useState(() => Date.now())
  return <Countdown expiresAt={receivedAt + 30_000} startsAt={receivedAt} label="Inquiry Q-104" />
}

This example starts a thirty-second timer on mount. In an application, pass the inquiry's received and expiry timestamps from the server, in milliseconds since the epoch. The preview's native buttons extend or restart its sample deadline; the countdown itself only displays it.

Compact

Set compact for the digits alone, inline and without the bar, and give each its own label, since the row's name is not part of the timer's. In a grid or stack that does the speaking, pass announce={false} too. When a row runs out here, the demo hands it a fresh deadline.

import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"

// Three inquiries with the time each has left, the way a stack lists them: the digits alone, no bar, each
// timer named for its row. When one runs out the demo hands its row a fresh deadline, the way the next
// inquiry fills a row on a desk, where the stack takes both from the server.
export default function CountdownCompactDemo() {
  const [inquiries, setInquiries] = useState(() => {
    const now = Date.now()
    return [
      { id: "Q-104", expiresAt: now + 95_000 },
      { id: "Q-105", expiresAt: now + 40_000 },
      { id: "Q-106", expiresAt: now + 12_000 },
    ]
  })
  const renew = (id: string) => setInquiries((rows) => rows.map((row) => (row.id === id ? { ...row, expiresAt: Date.now() + 60_000 } : row)))
  return (
    <ul role="list" aria-label="Inquiries" className="w-48 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
      {inquiries.map((inquiry) => (
        <li key={inquiry.id} className="flex items-baseline justify-between border-b py-1 last:border-0">
          <span>{inquiry.id}</span>
          <Countdown expiresAt={inquiry.expiresAt} compact label={`Time left ${inquiry.id}`} onExpire={() => renew(inquiry.id)} />
        </li>
      ))}
    </ul>
  )
}

Thresholds

thresholds.soonMs sets how far out the soon tier begins, when the bar turns expiring and the digits sit on the expiring tint in the foreground color, or, compact, turn expiring themselves. The same deadline under the default and under a desk that counts the last thirty seconds as soon; the demo starts it over at zero.

import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"

// One deadline under two ideas of soon: the default's ten seconds, and a desk that counts the last thirty
// seconds as soon, so its countdown turns first. When the deadline passes the demo starts it over.
function useDeadline(seconds: number) {
  const [start, setStart] = useState(() => Date.now())
  return { start, end: start + seconds * 1000, restart: () => setStart(Date.now()) }
}

export default function CountdownThresholdsDemo() {
  const deadline = useDeadline(45)
  return (
    <div className="flex flex-wrap gap-8 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
      <div className="space-y-1">
        <Countdown expiresAt={deadline.end} startsAt={deadline.start} label="Inquiry, soon at ten seconds" className="text-xl" onExpire={deadline.restart} />
        <p className="text-muted-foreground">soon at ten seconds, the default</p>
      </div>
      <div className="space-y-1">
        <Countdown expiresAt={deadline.end} startsAt={deadline.start} thresholds={{ soonMs: 30_000 }} label="Inquiry, soon at thirty seconds" className="text-xl" />
        <p className="text-muted-foreground">soon at thirty seconds</p>
      </div>
    </div>
  )
}

Expired

A countdown mounted past its deadline reads 0:00, holds an empty bar, and calls onExpire once; the demo counts the calls. It is a display event, not a verdict (what it does not do).

import { useState } from "react"
import { Countdown } from "@/components/ui/countdown"

// An inquiry that ended before the page drew it: the digits hold at zero, the bar is empty, and onExpire
// fires once on mount. The demo counts the calls; a desk reads the server's status instead.
export default function CountdownExpiredDemo() {
  const [over] = useState(() => Date.now() - 1000)
  const [calls, setCalls] = useState(0)
  return (
    <div className="flex flex-col items-center gap-3 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums">
      <Countdown expiresAt={over} startsAt={over - 30_000} label="An earlier inquiry" className="text-xl" onExpire={() => setCalls((n) => n + 1)} />
      <p className="text-muted-foreground">
        onExpire fired {calls} {calls === 1 ? "time" : "times"}
      </p>
    </div>
  )
}

API Reference

Props

Prop Type Default Purpose
expiresAt number Required Deadline timestamp. NaN reads as over; Infinity is a wait with no end.
startsAt number First render, freshly sampled Start timestamp used to size the bar. A value that is not a finite time is ignored.
thresholds CountdownThresholds { soonMs: 10_000 } When the soon tier begins.
clock Clock sharedClock() Clock for updates and test timing.
label string "Time left" Names the timer, with the time left, and leads its announcements.
compact boolean false Inline digits without a bar.
announce boolean true Announce tier changes to screen readers.
onExpire () => void None Called on expiry, including an already-expired mount. Can fire again after the deadline is extended.
className string None CSS classes for the root timer element.

Tiers

The root's data-tier tracks the time left:

Tier Time left
plenty Above thresholds.soonMs
soon At or below thresholds.soonMs, but above zero
expired Zero or less

The default, PROVISIONAL_COUNTDOWN_THRESHOLDS, is a placeholder. Set thresholds.soonMs with the people using the screen. For row colors or sorting, countdownTier(remainingMs, thresholds) gives the same tier without rendering a component.

formatRemaining(ms) rounds up to whole seconds: 1:30, 0:09, 0:00, or 1:02:03 past an hour. It never shows zero while time remains, and prints – for Infinity.

The bar

Pass startsAt so the full bar represents the time from start to expiry. Without it, the bar starts full on first render if time remains, even for a countdown restored partway through.

One linear Web Animation shrinks the bar to zero, with no JavaScript work per frame. Changing expiresAt cancels and restarts it. Under prefers-reduced-motion, as read on mount, or in a browser without Web Animations, the bar steps with the digits instead. A deadline that isn't a finite time holds the bar still: empty for NaN, full for Infinity.

The clock

Countdowns and feed-health share one timer: sharedClock() from @/lib/clock, ticking once a second. Each countdown subscribes independently; ticks don't re-render its parents.

The clock's now() returns its last tick, which refreshes while anything subscribes. On a clock with sample, as createClock makes, a countdown samples the time source when it is first drawn and is never behind that moment, so one mounted after the clock sat idle shows the real time left from its first frame and announces nothing as the clock catches up. That moment stays when clock changes; change the countdown's key to start it again. Pass a faster clock for finer timing, or createClock(1000, () => t) for tests. createClock ticks once a second for an interval that is not a positive number of milliseconds up to 2³¹ − 1, the most a timer holds.

Render countdowns on the client. The shared clock advances only while something in a browser subscribes, so server-rendered digits, tiers, and data-tier go stale and mismatch on hydration.

What it says

A polite live region announces tier changes, such as "Inquiry 0:10" and "Inquiry 0:00", without speaking on every tick. Set announce={false} when the containing grid or stack handles announcements.

The timer's accessible name is its label and the time left, so the time is read wherever the name is. A grid row named by its cells would change its name at every tick, so name such rows with the grid's getRowLabel, as rfq-stack does; the grid reads the focused row's cells, the timer's name among them, once focus rests on the row.

What it does not do

onExpire is a display event. Use the server's status to decide whether an inquiry has expired. The component only displays time left; it doesn't count up or past zero.

Tokens

The soon tier uses expiring and expiring-soft. Installation adds missing tokens; theme items set their values.

This page is docs/countdown.md at v2.0.0.