Dots

Countdown

A deadline in monospaced digits, optionally with a ring that empties as time runs out.

Rate locked1 EUR = 1.0842 USD
Rate lock: loading
"use client"

import * as React from "react"

The Countdown component shows the time left until a deadline. Use it for rate locks, code resends, deposit address lifetimes, approvals and lockouts. It counts down once per second, shows 0:00 muted when the time is up and speaks only at a few milestones, so screen reader users are not interrupted every second. Pick it over Progress when the limit is time rather than an amount.

Installation

pnpm dlx shadcn@latest add @dots/countdown

Usage

import { Countdown } from "@/components/ui/countdown"
<Countdown endsAt={rateLockEndsAt} duration={30} variant="ring" label="Rate lock" />

Set the deadline in an effect or from server data, not with Date.now() during render. The component reads the clock only in effects, so the server and the first client render both show a placeholder.

const [endsAt, setEndsAt] = React.useState<number | null>(null)
 
React.useEffect(() => {
  setEndsAt(Date.now() + 30 * 1000)
}, [])

Features

  • Accepts a Date or a timestamp in milliseconds, and null while loading
  • Formats as 0:42, 1:02:05 or 2d 04:13:07
  • An optional ring that empties over duration
  • Five sizes, from inline text to a hero timer
  • A warning tone for deadlines where money can be lost
  • onExpire fires once per deadline
  • Sparse polite announcements, configurable with announce
  • A useCountdown hook for driving the rest of the screen, such as enabling a button

Ring

Set variant="ring" and pass the whole window as duration in seconds. The ring starts full and empties as time runs out.

Quote: loadingQuote: loadingQuote: loading
"use client"

import * as React from "react"

Sizes

Use size to switch between inline, sm, default, lg and display. display is for screens that are about the wait itself. Its digits step down for waits of a day or more so they fit a phone column.

Approval: loadingApproval: loadingApproval: loadingApproval: loading
"use client"

import * as React from "react"

Inline

Use size="inline" to keep the surrounding text size. Set spokenLabel={false} when the sentence already names the deadline, and announce="expiry" to speak only when the wait is over.

Send a new code in loading

"use client"

import * as React from "react"

Warning

Set tone="warning" only where money can be lost after the deadline, such as a deposit address that stops accepting funds. Expiry on its own is not an error.

Deposit address expiresFunds sent after this may be lost
Deposit address: loading
"use client"

import * as React from "react"

Display

Combine size="display" with the ring for a lockout or a recovery hold.

Too many attempts. Try again inLockout: loading
"use client"

import * as React from "react"

Several timers

When a screen shows several long timers, set announce="off" on each. The digits can still be read on demand.

WithdrawalsWithdrawals unlock: loading
New device sign-inNew device sign-in unlocks: loading
"use client"

import * as React from "react"

Pending and expired

endsAt={null} shows -:-- while the deadline loads. A deadline in the past shows 0:00 muted with an empty ring.

Rate lock: loadingLoading
Rate lock: loadingExpired
import { Countdown } from "@/components/ui/countdown"

export function CountdownStates() {

Accessibility

The digits sit in an element with role="timer". They are hidden from screen readers and replaced with words, such as "Rate lock: 24 seconds left", which are read when the user moves to the timer.

  • Pass a label that names what runs out. It prefixes the spoken time and every announcement.
  • A separate polite region speaks at 5 minutes, 1 minute, every 10 seconds in the last minute, and at expiry. Long deadlines stay quiet until the last 5 minutes.
  • Use announce="expiry" for a single wait that unlocks something, and announce="off" when several timers share a screen or the screen announces the outcome itself.
  • Keep the countdown mounted through expiry and let it show 0:00. Removing it at zero also removes the "time is up" announcement.
  • Color is not the only signal: the expired state also shows 0:00, and tone="warning" should come with text that explains the risk.

API Reference

Countdown

Accepts all span props except children.

PropTypeDefaultDescription
endsAtDate | number | null | undefined-Required. When the time is up. null shows a placeholder.
durationnumber-The whole window in seconds. Drives the ring.
variant"digits" | "ring""digits"Show the time only, or add a ring that empties as time runs out.
size"inline" | "sm" | "default" | "lg" | "display""default"inline keeps the surrounding text size. display is the hero timer of a screen.
tone"default" | "warning""default"warning only where money can be lost after the deadline.
labelstring"Countdown"What runs out, for screen readers.
spokenLabelbooleantruefalse drops the label from the timer text. Announcements keep it.
expiredMessagestring"Time is up"Spoken when the time is up.
announce"milestones" | "expiry" | "off""milestones"What the polite region speaks.
onExpire() => void-Called once when the time is up.
classNamestring-Additional classes to apply to the root element.

useCountdown

Ticks once per second toward a deadline. Reads the clock only in effects, so the first render matches on server and client.

const { remaining, seconds, expired } = useCountdown(endsAt, onExpire)
ArgumentTypeDescription
endsAtDate | number | null | undefinedThe deadline.
onExpire() => voidOptional. Fires once per deadline at zero.
ReturnTypeDescription
remainingnumber | nullMilliseconds left, or null before the first tick.
secondsnumber | nullWhole seconds left, rounded up, or null before the first tick.
expiredbooleantrue once the time is up.

formatClock

Formats whole seconds the way the component shows them: 42 gives "0:42", 3725 gives "1:02:05", two days give "2d 00:00:00".

formatClock(seconds: number): string