Feedback

Timer

A countdown timer with its own controls: the time left in large numbers, Start, then Pause and Cancel, Resume when paused, and at zero "Time's up" with Dismiss and Restart. It keeps time by the clock, so it stays right in a background tab and, with timerId, across a reload. At zero it chimes and vibrates (unless silent), again every 2.5 seconds until stopped with alert="repeat", and screen readers hear "Pasta: Time's up" once; the time itself is a timer region, read when the person asks rather than every second.

Focus · 25 min

25:00

Tea · 3 s

0:03

In Spoonjoy

Spoonjoy, a recipe app, is built with Ouro HX (how).

In its source: app/components/recipe/CookTimers.tsx

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/timer#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/timer

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/timer#v1.2.13 ourostack/ouro-ui/timer-tests#v1.2.13
import { Timer } from "@/components/ouro/timer/timer";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Timer label="Pasta" duration={600} timerId="pasta" />
<Timer label="Roast" duration={5400} timerId="roast" alert="repeat" />

Usage

Give timers that should outlive their screen a timerId and list them in a TimerTray, so a person who moves on can still see and stop them.

Use instead when

  • Use Progress instead for how far a task has got, and useTimer for a timer of your own design.

Props

Type: TimerProps.

alert
Type: "once" | "repeat"
Default: "once"
How it sounds when the time is up. once chimes and vibrates once. repeat chimes and vibrates again every 2.5 seconds, for a timer that may finish while the person is across the room, until they dismiss, restart or cancel it (here, in a TimerTray or in any view with its timerId) or repeatFor seconds pass. Either way screen readers hear "Time's up" once, and silent keeps it quiet.
className
Type: string
Layout only: margins, width (mt-6). Appearance classes warn in development.
duration (required)
Type: number
How long it runs, in seconds: duration={600}.
label (required)
Type: string
What the timer is for, shown above the time and naming it for screen readers: "Pasta", "Break".
labels
Type: TimerLabels
The words it shows and says, for apps in other languages.
onDone
Type: ((event: TimerDoneEvent) => void)
Called once when the timer reaches zero, including when it ended while the page was closed.
ref
Type: Ref<HTMLDivElement>
Reaches the rendered element.
repeatFor
Type: number
Default: 120
With alert="repeat", how long it keeps sounding, in seconds from when the time was up, unless stopped. A value that is not finite and above 0 is taken as 120, and development warns. The alert also stops once no view of the timer (a Timer or a TimerTray row with its timerId) is left on the page.
silent
Type: boolean
No chime and no vibration at zero; it still shows and says that the time is up.
size
Type: "md" | "lg"
Default: "md"
How large the time is: md beside other content, lg as a screen's focus.
timerId
Type: string
Keeps the timer through a reload and a closed tab, and shares it with a TimerTray and every other Timer or useTimer given the same timerId. Without it the timer ends with the component. (id is the element's, as on any <div>.)

Native attributes

Also accepts the attributes in React's HTMLAttributes (id, aria-*, event handlers), passed to the rendered element, except those named on this page.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • autoSaveId: Not a prop. A timer is kept and shared by timerId: <Timer timerId="pasta" />.
  • minutes: Not a prop. The length is duration, in seconds: <Timer duration={10 * 60} />.
  • onComplete: Not a prop. Ouro UI says onDone: <Timer onDone={() => …} />.
  • onEnd: Not a prop. Ouro UI says onDone: <Timer onDone={() => …} />.
  • seconds: Not a prop. The length is duration, in seconds: <Timer duration={600} />.
  • sound: Not a prop. Turn the sound off with silent: <Timer silent />.
  • storageKey: Not a prop. A timer is kept and shared by timerId: <Timer timerId="pasta" />.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: Timer merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

TimerDoneEvent

What a timer's onDone hears when it reaches zero.

interface TimerDoneEvent {
  /**
   * When it reached zero, in milliseconds since the epoch: in the past when the page was closed at the time.
   */
  endedAt: number;
  /**
   * Whether this `useTimer` is the one on the page that should sound the alert: the first with
   * the timer's `timerId` to hear it, not counting `fallback` ones unless only those are mounted. When
   * a `Timer` and a `TimerTray` both show one timer, the `Timer` alerts, whichever mounted first.
   * A hook without `onDone` reads the timer without taking the alert.
   */
  first: boolean;
}

TimerLabels

The words a timer shows and says, for apps in other languages.

interface TimerLabels {
  /** @default "Start" */
  start?: string | undefined;
  /** @default "Pause" */
  pause?: string | undefined;
  /** @default "Resume" */
  resume?: string | undefined;
  /** @default "Cancel" */
  cancel?: string | undefined;
  /** @default "Dismiss" */
  dismiss?: string | undefined;
  /** @default "Restart" */
  restart?: string | undefined;
  /**
   * What it shows, and screen readers hear, at zero. @default "Time's up"
   */
  done?: string | undefined;
  /** What it shows after the time while paused. @default "Paused" */
  paused?: string | undefined;
  /** TimerTray's link to the timer's page. @default "Go to" */
  goTo?: string | undefined;
}

buzz()

Vibrates the device in a short alert pattern, where it can (most Android phones; not iOS or desktops). Returns whether it did. Call it with chime(), so a phone on silent still gets the alert.

import { buzz } from "@/lib/ouro/feedback";

Example

buzz();

Returns

boolean

chime()

Plays a short alert, three soft tones, for something that just finished: a timer at zero. It resolves true once the tones are scheduled and false when the browser has no Web Audio or keeps the page silent (no interaction yet), so the caller can rely on what shows on screen instead. It settles within about 300 ms even when the browser never lets sound start, and a context it made for itself is always closed. It never throws. Pair it with buzz() for phones on silent.

import { chime } from "@/lib/ouro/feedback";

Example

const played = await chime({ context });

Use instead when

  • Use toast() instead to tell the person something in words; a chime only draws their attention back to the screen.

Parameters

{ context }
Type: ChimeOptions
{}

Returns

Promise<boolean>

createChimeContext()

Makes an audio context for chime, or returns null where the browser has no Web Audio. Call it from a click or key handler, where browsers allow sound to start, and pass it to every later chime.

import { createChimeContext } from "@/lib/ouro/feedback";

Example

const context = useRef<AudioContext | null>(null);
const start = () => {
  context.current ??= createChimeContext();
  timer.start();
};

Returns

AudioContext | null

formatDuration()

Seconds as a clock reads them: 9:05, or 1:02:03 from an hour up.

import { formatDuration } from "@/lib/ouro/use-timer";

Example

formatDuration(545); // "9:05"

Parameters

seconds
Type: number

Returns

string

useTimer()

A countdown timer that keeps time by the clock, not by ticks, so it stays right in a background tab, across a reload (with timerId) and when the screen sleeps. It re-renders four times a second while it runs, and calls onDone once at zero. Every useTimer, Timer and TimerTray with the same timerId shares one timer, in this tab and in others.

import { useTimer } from "@/lib/ouro/use-timer";

Example

const timer = useTimer({ duration: 600, timerId: "pasta", onDone: () => chime() });
<Button onClick={timer.start}>Start</Button>
<p>{formatDuration(timer.remaining)}</p>

Use instead when

  • Use Timer instead for the whole control (the time, Start, Pause and the alert), and this hook for a timer of your own design.

Parameters

{ duration, timerId, onDone, fallback }
Type: TimerOptions

Returns

TimerControls

Types

TimerControls

What useTimer returns.

interface TimerControls {
  status: TimerStatus;
  /**
   * Whole seconds left, rounded up: the full duration before it starts, 0 once it is done.
   */
  remaining: number;
  /** The timer's length in seconds, as it was started. */
  duration: number;
  /**
   * When it reached zero, or `null` until it does.
   */
  endedAt: number | null;
  /** Starts from the full duration, from any status. */
  start: () => void;
  /** Stops a running timer where it is. */
  pause: () => void;
  /** Carries a paused timer on from where it stopped. */
  resume: () => void;
  /** Cancels or dismisses the timer: back to `idle` at the full duration, and forgotten. */
  reset: () => void;
}

TimerDoneEvent

What a timer's onDone hears when it reaches zero.

interface TimerDoneEvent {
  /**
   * When it reached zero, in milliseconds since the epoch: in the past when the page was closed at the time.
   */
  endedAt: number;
  /**
   * Whether this `useTimer` is the one on the page that should sound the alert: the first with
   * the timer's `timerId` to hear it, not counting `fallback` ones unless only those are mounted. When
   * a `Timer` and a `TimerTray` both show one timer, the `Timer` alerts, whichever mounted first.
   * A hook without `onDone` reads the timer without taking the alert.
   */
  first: boolean;
}

TimerOptions

Options for useTimer.

interface TimerOptions {
  /** How long the timer runs, in seconds. */
  duration: number;
  /**
   * Keeps the timer in `localStorage` under this name, so it carries on through a reload and
   * a closed tab, and shares it with every `useTimer`, `Timer` and `TimerTray` given the same
   * `timerId`, in this tab and in others. Without it the timer lives as long as the component.
   */
  timerId?: string | undefined;
  /** Not an option. A timer is kept and shared by `timerId`: `useTimer({ duration: 600, timerId: "pasta" })`. */
  id?: { 'Use timerId: "pasta"': never };
  /**
   * Called once when the timer reaches zero, including when it ended while the page was closed.
   */
  onDone?: ((event: TimerDoneEvent) => void) | undefined;
  /**
   * For a view that watches a timer shown elsewhere, as `TimerTray` does: its `onDone` hears
   * `first: true` only while no `useTimer` without `fallback` shares the timer.
   */
  fallback?: boolean | undefined;
}

TimerStatus

Where a timer is: not started, counting down, stopped part way, or at zero.

type TimerStatus = "idle" | "running" | "paused" | "done";

Claim sources