Countdown
StableA countdown with presets or a typed length, +1 min while it runs, a closing square and an honest finished banner.
Pick a preset or type a length (“25”, “12:30”, “1h 30m”), then start; while it runs it shows the clock time it ends at. The deadline is saved per viewer, so a reload keeps counting, and if it ended while the tab was hidden or closed the banner says how long ago. Sound is off until asked for, and the chime is prepared inside the Start press, as browsers require.
Updated View source
Countdown with presets
import { Countdown } from '@rchursin/product/timer'
/** * The whole tool. Choose a length (or type one), start, add a minute while it runs. It keeps its * state in this browser, so a reload picks up the same deadline; if it ended while you were away, * the banner says how long ago. Click its background, then Space starts and pauses, M adds a minute, R resets. */export default function CountdownApp() { return ( <div className="flex justify-center px-4 py-10 sm:px-8"> <Countdown storageKey="library:countdown" /> </div> )}Usage
import { Countdown, LengthPicker } from '@rchursin/product/timer'<Countdown />Examples
Length picker
LengthPicker on its own. Try “90s”, “1h 30m” or “12:75” in Custom length.
import { useState } from 'react'import { formatDuration, MINUTE } from '@rchursin/product/lib/dates'import { LengthPicker } from '@rchursin/product/timer'
/** * The countdown's length chooser on its own: six presets as pressed buttons, or a typed length * ("25", "12:30", "1h 30m"). A bad entry says what's wrong and keeps focus in the field. */export default function CountdownLengthPicker() { const [ms, setMs] = useState(25 * MINUTE)
return ( <div className="flex w-full flex-col items-center gap-4"> <LengthPicker presets={[5, 10, 15, 25, 45, 60]} durationMs={ms} onChoose={setMs} /> <p className="text-xs text-fg-3 tabular-nums">Length: {formatDuration(ms, 'long')}</p> </div> )}API
Countdown props
| Prop | Type | Default | Description |
|---|---|---|---|
storageKey | string | 'timer:countdown' | Where the run is saved (per viewer). The sound choice is saved under the same key plus ":sound". |
presets | number[] | [1, 5, 10, 15, 25, 45] | Preset lengths in minutes. They sit in a six-column row, so pass six. |
label | string | 'Countdown' | Visible name above the digits, and the clock’s accessible name. |
onStatusChange | (status: CountdownState['status']) => void | – | Told 'idle', 'running', 'paused' or 'finished' whenever it changes. |
className | string | – | Classes on the wrapper. |
LengthPicker props
| Prop | Type | Default | Description |
|---|---|---|---|
presetsrequired | number[] | – | Preset lengths in minutes (a six-column row). |
durationMsrequired | number | – | The current length; the matching preset is pressed. |
onChooserequired | (ms: number) => void | – | A preset was pressed, or a typed length was set. |
locked | boolean | – | Disables the presets and hides the field, with “Reset to choose another length.” (while a run is active). |
className | string | – | Classes on the wrapper. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Space | Starts, pauses or resumes while focus is on the countdown itself. On a button, Space presses that button. |
| M | Adds a minute while it runs or is paused. |
| R | Resets to the start of the length, with Undo in a toast (not after it has finished). |
| Enter | In Custom length: sets the typed length. |
- Changes are announced politely: “Countdown started, 5 minutes”, “Paused, 3 minutes left”, “Added a minute, 4 minutes left”, and the 5 and 1 minute marks.
- When it ends, the banner is role="alert"; after Restart or Dismiss its buttons go away, so focus moves to the Start / Pause button and is never lost.
- Presets are toggle buttons (aria-pressed) in a group named “Preset lengths”; while a run is active they are disabled and the group points at the reason.
- A bad typed length says what’s wrong (“Seconds go up to 59. Try something like 12:30.”) and keeps focus in the field.
- There are no system notifications (they can’t be granted in a sandboxed frame); the banner says it in the page instead.
Guidelines
Do
- Give each countdown on a page its own storageKey.
- Leave sound off by default; let people turn it on.
Don’t
- Don’t pass fewer than six presets; the row is a fixed grid.
- Don’t promise a notification when the tab is closed; the banner is honest about lateness instead.
In use
Related
- Time displayThe clock face: Geist Mono digits in fixed cells, lit like the buttons, still or live, with a spoken form for screen readers.
- BannersIn-page messages for things that happen on their own: a timer that finished, a reminder that came due.
- ChimeA soft two-note chime made with Web Audio: arm it inside a click, then play it later, even from a background tab.
- TimerThe Focus screen: a focus session, a stopwatch and a countdown one switch apart, with the time log beside them.
Source
The component’s own files, exactly as they run on this site.
/** * Countdown: presets (1, 5, 10, 15, 25, 45 min) or a typed length ("25" minutes, "12:30"), +1 min while it runs, * a square that closes as time passes, and an in-page banner when it ends. * * <Countdown /> keeps its own state (saved under "timer:countdown") * <Countdown presets={[2, 10]} /> * * Sound is off by default. When it is on, the audio is created inside the Start click (browsers block sound that * wasn't preceded by a gesture) and a soft chime plays at the end. There are no system notifications: they can't be * granted inside a sandboxed iframe (see chime.ts), so the banner says it, and says it honestly if the page only * noticed later ("Finished 3 min ago · 2:25 PM"). * * Keys (while focus is inside it): Space start / pause · M add a minute · R reset (with Undo). */import { useEffect, useId, useRef, useState, type SubmitEvent } from "react"import { cx } from "../../components/cx"import { useAnnounce } from "../lib/announce"import { MINUTE, formatDuration, formatTime } from "../lib/dates"import { useHotkeys } from "../lib/hotkeys"import { Button } from "../ui/button"