Timer Tray
A floating (or inline) tray of concurrent countdowns. Each row carries a label, a mini progress ring, the remaining time in mm:ss, and a dismiss button. The tray runs no timers of its own — remainingMs flows in from outside, so multiple consumers can share one tick source.
Active timers03
- Oven preheat00:45
- Pasta01:15
- Tea steep00:30
Customize
Position
bottom-right
Visible cap
5
Layout
Installation
npx shadcn@latest add https://craftbits.dev/r/timer-tray.jsonUsage
import { TimerTray, type TimerTrayItem } from "@craft-bits/core";
import { useEffect, useState } from "react";
const [timers, setTimers] = useState<TimerTrayItem[]>([
{ id: "oven", label: "Oven preheat", durationMs: 45_000, remainingMs: 45_000, running: true },
{ id: "pasta", label: "Pasta", durationMs: 75_000, remainingMs: 75_000, running: true },
]);
useEffect(() => {
const id = window.setInterval(() => {
setTimers((prev) =>
prev.map((t) =>
t.running && t.remainingMs > 0
? { ...t, remainingMs: Math.max(0, t.remainingMs - 250) }
: t,
),
);
}, 250);
return () => window.clearInterval(id);
}, []);
<TimerTray
timers={timers}
onTimerComplete={(id) => playChime(id)}
onTimerDismiss={(id) => setTimers((prev) => prev.filter((t) => t.id !== id))}
/>Pin to a different corner:
<TimerTray position="top-left" timers={timers} />Inline (for embedding inside another card):
<TimerTray inline timers={timers} />Understanding the component
- Headless tick source. The tray never schedules its own timer. Every row's remaining time is computed by the consumer — typically one app-level interval feeding many subscribers. A kitchen-clock view and a sidebar widget can share the same loop and stay in sync.
- Layout-shared enter / exit. Each row carries a
layoutIdderived from itsid. When a row is added, removed, or reordered, motion springs it into its new slot withSPRINGS.smoothrather than snapping — and an exiting row vacates in place instead of jumping the stack. - Completion latch per id. The tray remembers which ids have crossed to zero.
onTimerCompletefires exactly once per crossing, on the microtask after render. Restarting a row clears the latch so the next zero re-fires. - Overflow folding. Beyond
maxVisiblerows, the remainder collapses into a single overflow footer (+N more). Default cap is 5 — wider trays read as a list, not a glance-able stack. - Fixed or inline. By default the tray is
position: fixedand pins to a corner of the viewport. Passinlineto render as a static block (handy for docs previews, or for embedding the tray inside a sidebar / card).
Props
| Prop | Type | Default | Description |
|---|---|---|---|
timers | TimerTrayItem[] | required | Controlled list of timer rows. |
onTimerComplete | (id: string) => void | — | Fires once per id the first time the row's remaining time crosses to zero. |
onTimerDismiss | (id: string) => void | — | Fires when the user clicks the per-row dismiss button. |
position | 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-right' | Where the tray pins. Ignored when inline is true. |
maxVisible | number | 5 | Cap on rendered rows. Excess rows collapse into an overflow footer. |
inline | boolean | false | Render as a static block instead of a fixed-position tray. |
className | string | — | Merged onto the root <div> via cn(). |
TimerTrayItem
| Field | Type | Description |
|---|---|---|
id | string | Stable unique id. Used as the React key and layoutId. |
label | string | Short label rendered above the time readout. |
durationMs | number | Total countdown length. Drives the ring fill. |
remainingMs | number | Remaining time. Clamped to the duration. |
running | boolean | Whether this row is actively counting down — visual only. |
Accessibility
- The tray container is a
role="region"witharia-label="Active timers"so screen-reader rotor menus can jump straight to it. - Each row's time uses an off-by-default
aria-liveto avoid every-second announcements — wire your own announcement strategy ononTimerCompleteif you want completion announcements. - The dismiss button is a real
<button type="button">with a per-rowaria-labeland afocus-visiblering keyed to the accent token. - The mini progress ring is
aria-hidden="true"— the label and time text carry the meaning. - An empty
timersarray renders nothing, so the region doesn't clutter screen-reader output when there's no activity.
Credits
- Extracted from:
terminal-dreams(src/components/cookbook/TimerTray.tsx). Generalized: stripped the project-specificuseCookbookTimerhook (consumers bring their own tick source); replaced the single-active-timer card chrome with a stack of compact rows; added theposition,maxVisible, andinlineprops; swappedframer-motionformotion/reactand the inline transition object forSPRINGS.smooth; replaced glassmorphic project vars withcb-*tokens.