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.
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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then 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.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/timerIts 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.13import { 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
Progressinstead for how far a task has got, anduseTimerfor a timer of your own design.
Props
Type: TimerProps.
alert- Type:
"once" | "repeat" - Default:
"once" - How it sounds when the time is up.
oncechimes and vibrates once.repeatchimes 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 aTimerTrayor in any view with itstimerId) orrepeatForseconds pass. Either way screen readers hear "Time's up" once, andsilentkeeps 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 (aTimeror aTimerTrayrow with itstimerId) 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:
mdbeside other content,lgas a screen's focus. timerId- Type:
string - Keeps the timer through a reload and a closed tab, and shares it with a
TimerTrayand every otherTimeroruseTimergiven the sametimerId. Without it the timer ends with the component. (idis 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 bytimerId:<Timer timerId="pasta" />.minutes: Not a prop. The length isduration, in seconds:<Timer duration={10 * 60} />.onComplete: Not a prop. Ouro UI saysonDone:<Timer onDone={() => …} />.onEnd: Not a prop. Ouro UI saysonDone:<Timer onDone={() => …} />.seconds: Not a prop. The length isduration, in seconds:<Timer duration={600} />.sound: Not a prop. Turn the sound off withsilent:<Timer silent />.storageKey: Not a prop. A timer is kept and shared bytimerId:<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
Timerinstead 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
- layout-classes: test:registry/ouro/components/ouro/timer/timer.test.tsx::Timer > forwards a ref, passes attributes through, and lets layout classes win
verify:testExists(registry/ouro/components/ouro/timer/timer.test.tsx::Timer > forwards a ref, passes attributes through, and lets layout classes win)