Feedback
TimerTray
The timers that are running, paused or finished, in one strip, so a person who leaves a timer's screen can still see it count down, hear it at zero, and stop it. Pass every timer the app has, by the timerId its Timer uses; the tray shows only those not idle, and hides itself while none are. When a timer reaches zero while only the tray shows it, the tray chimes and says so; when its Timer is on the page too, the Timer alerts instead (with its own silent), whichever of the two mounted first.
In Spoonjoy
Spoonjoy, a recipe app, is built with Ouro HX (how).
See it live, no sign-in needed: A recipe · Test kitchen
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-tray#v1.2.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/timer-trayIts 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-tray#v1.2.13 ourostack/ouro-ui/timer-tray-tests#v1.2.13import { TimerTray } from "@/components/ouro/timer/timer-tray";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<TimerTray timers={[{ timerId: "pasta", label: "Pasta", duration: 600, href: "/steps/2" }]} />Usage
Put it where it stays in view, such as under the header (className="sticky top-0"), and leave out the timer on the screen in view.
Use instead when
- Use
Timerinstead for one timer with all its controls.
Props
Type: TimerTrayProps.
alert- Type:
"once" | "repeat" - Default:
"once" - How it sounds when a timer reaches zero while only the tray shows it, as
Timer'salert:once, orrepeat, again every 2.5 seconds until the timer is dismissed orrepeatForseconds pass. Dismiss or Cancel in the tray also stops aTimer's repeating alert. className- Type:
string - Layout only: margins, sticky placement (
sticky top-0). Appearance classes warn in development. finalFocus- Type:
RefObject<HTMLElement | null> - Where focus goes when Cancel or Dismiss stops the tray's last timer, such as the page heading (give it
tabIndex={-1}):finalFocus={headingRef}. Stopping any other timer moves focus to the next timer's button, or the previous one's. Without it focus goes to the tray, which stays in view, empty, until focus leaves it. label- Type:
string - Default:
"Timers" - Names the tray for screen readers, as a region.
labels- Type:
TimerLabels - The words it shows and says, for apps in other languages.
ref- Type:
Ref<HTMLElement> - 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. A value that is not finite and above 0 is taken as 120, and development warns. silent- Type:
boolean - No chime and no vibration when a timer reaches zero while only the tray shows it. A
Timeron the page that shows the same timer alerts instead of the tray, by its ownsilent. timers(required)- Type:
readonly TimerTrayItem[] - The timers to watch, by
timerId. Only those that are running, paused or finished show.
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.
hidden: Not a prop. The tray shows only timers that are running, paused or finished; pass them all.items: Not a prop. The tray liststimers:<TimerTray timers={[{ timerId, label, duration }]} />.
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: TimerTray 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;
}TimerTrayItem
One timer a TimerTray watches.
interface TimerTrayItem {
/** The `timerId` the timer's `Timer` or `useTimer` has. */
timerId: string;
/** Not a key. A timer is named by `timerId`, as its `Timer` has it: `{ timerId: "pasta", label, duration }`. */
id?: { 'Use timerId: "pasta"': never };
/** What it is for, as its `Timer` says it: "Pasta". */
label: string;
/** Its length in seconds, as its `Timer` has it. */
duration: number;
/** Where the timer's own screen is, for a "Go to" link. */
href?: string | undefined;
/**
* Called once when this timer finishes. `event.first` says whether this view owns the alert;
* when a Timer shares it, that Timer owns it instead.
*/
onDone?: ((event: TimerDoneEvent) => void) | undefined;
}Claim sources
- layout-classes: test:registry/ouro/components/ouro/timer/timer-tray.test.tsx::TimerTray > forwards a ref, passes attributes through, and lets layout classes win
verify:testExists(registry/ouro/components/ouro/timer/timer-tray.test.tsx::TimerTray > forwards a ref, passes attributes through, and lets layout classes win)