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.json

Usage

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

  1. 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.
  2. Layout-shared enter / exit. Each row carries a layoutId derived from its id. When a row is added, removed, or reordered, motion springs it into its new slot with SPRINGS.smooth rather than snapping — and an exiting row vacates in place instead of jumping the stack.
  3. Completion latch per id. The tray remembers which ids have crossed to zero. onTimerComplete fires exactly once per crossing, on the microtask after render. Restarting a row clears the latch so the next zero re-fires.
  4. Overflow folding. Beyond maxVisible rows, 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.
  5. Fixed or inline. By default the tray is position: fixed and pins to a corner of the viewport. Pass inline to render as a static block (handy for docs previews, or for embedding the tray inside a sidebar / card).

Props

PropTypeDefaultDescription
timersTimerTrayItem[]requiredControlled list of timer rows.
onTimerComplete(id: string) => voidFires once per id the first time the row's remaining time crosses to zero.
onTimerDismiss(id: string) => voidFires 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.
maxVisiblenumber5Cap on rendered rows. Excess rows collapse into an overflow footer.
inlinebooleanfalseRender as a static block instead of a fixed-position tray.
classNamestringMerged onto the root <div> via cn().

TimerTrayItem

FieldTypeDescription
idstringStable unique id. Used as the React key and layoutId.
labelstringShort label rendered above the time readout.
durationMsnumberTotal countdown length. Drives the ring fill.
remainingMsnumberRemaining time. Clamped to the duration.
runningbooleanWhether this row is actively counting down — visual only.

Accessibility

  • The tray container is a role="region" with aria-label="Active timers" so screen-reader rotor menus can jump straight to it.
  • Each row's time uses an off-by-default aria-live to avoid every-second announcements — wire your own announcement strategy on onTimerComplete if you want completion announcements.
  • The dismiss button is a real <button type="button"> with a per-row aria-label and a focus-visible ring keyed to the accent token.
  • The mini progress ring is aria-hidden="true" — the label and time text carry the meaning.
  • An empty timers array 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-specific useCookbookTimer hook (consumers bring their own tick source); replaced the single-active-timer card chrome with a stack of compact rows; added the position, maxVisible, and inline props; swapped framer-motion for motion/react and the inline transition object for SPRINGS.smooth; replaced glassmorphic project vars with cb-* tokens.