# Timer

**When to use:** A countdown timer with a depleting ring, add/remove-time and pause/cancel controls, and an imperative ref API. Built for step-by-step flows where a wait needs its own clock — a recipe step ("simmer for 9 minutes"), a rest interval, a checkout hold, a break timer. Each instance owns its own state, so several can run independently at once (e.g. one per recipe step). For a short text countdown, such as "Resend code in 24s", a counter of your own is lighter: Timer is a ring with controls.

**Keywords:** countdown, cooking timer, stopwatch, recipe, kitchen timer, ring progress

**Import:** `import { Timer } from '@ahrowe/ui'`
**Types:** `import type { TimerProps, TimerHandle, TimerStatus } from '@ahrowe/ui'`

```tsx
import { Timer } from '@ahrowe/ui';

// Basic countdown — starts automatically, built-in controls shown
<Timer durationSeconds={9 * 60} />

// With a label inside the ring
<Timer durationSeconds={9 * 60} label="Simmer the sauce" />

// Doesn't start until start() is called (see ref example below)
<Timer durationSeconds={60} autoStart={false} />

// Display only — no built-in controls
<Timer durationSeconds={45} showControls={false} />

// Only some controls — keep add/remove, hide pause and cancel
<Timer durationSeconds={45} hidePauseButton hideCancelButton adjustSeconds={15} />

// Smaller ring, thinner stroke
<Timer durationSeconds={30} size="100px" strokeWidth={6} />

// Depletes counter-clockwise instead of the default clockwise sweep
<Timer durationSeconds={30} counterClockwise />

// Urgent colour + heartbeat pulse kicks in earlier (last 25% instead of the default 10%)
<Timer durationSeconds={60} urgentThreshold={0.25} />

// Ambient sonar-style ripples expanding from the center while running
<Timer durationSeconds={10 * 60} showRunningRipples label="Meditating" />

// Completion sound and a browser Notification (both opt-in, both default off)
<Timer
  durationSeconds={5 * 60}
  playSoundOnComplete
  notifyOnComplete
  notificationTitle="Timer done"
  notificationBody="The pasta is ready"
/>

// Imperative control via ref — for a fully custom UI, or driving start()/pause() from
// elsewhere on the page
import { useRef } from 'react';
import type { TimerHandle } from '@ahrowe/ui';

function Example() {
  const ref = useRef<TimerHandle>(null);
  return (
    <>
      <Timer ref={ref} durationSeconds={60} showControls={false} autoStart={false} />
      <button onClick={() => ref.current?.start()}>Start</button>
      <button onClick={() => ref.current?.addTime(30)}>+30s</button>
    </>
  );
}
```

**Not a controlled value:** `Timer` isn't a controlled numeric input like `Slider` — there's no `remainingSeconds` prop to drive from outside. `durationSeconds` seeds the countdown; from there it owns the ticking internally, exposing state via callbacks (`onTick`, `onComplete`, …) and the `TimerHandle` ref rather than a value a consumer sets on every tick. Changing `durationSeconds` after mount resets the timer to the new duration (see `resetOnDurationChange` below).

**Direction:** the ring depletes clockwise by default, starting from 12 o'clock. Set `counterClockwise` to sweep the other way instead.

**Controls:** the built-in row has remove-time / pause-resume / add-time, plus a cancel button underneath. `showControls={false}` hides the whole block; the individual `hide*Button` props hide one control while keeping the rest (e.g. a fixed-duration display-only step keeps pause/cancel but drops add/remove). The pause button doubles as a play/restart button whenever there's nothing to pause — titled "Start" for an idle timer that hasn't run yet, or "Restart" once completed or cancelled — so there's always a way to (re)start the countdown from the built-in controls alone, without wiring up a separate button via the ref. Add-time also stays available after completion and, if clicked, un-completes and resumes the countdown (the "oops, give it 2 more minutes" case).

**Urgency and the heartbeat pulse:** once remaining time drops to `urgentThreshold` (default the last 10% of the original duration) the ring switches to `--timer-urgent-color` and, if `pulseOnUrgent` is set (default `true`), the ring gently scales in a breathing "heartbeat" — accelerating from a 1s to a 0.6s cycle in the final 5 seconds. Disable it with `pulseOnUrgent={false}` if the motion isn't wanted.

`urgentThreshold`'s 10%-of-duration fraction gets vanishingly small on a short timer (10% of 5 seconds is half a second — barely long enough to register). `urgentMinSeconds` (default `3`) sets a floor in absolute seconds: the urgent state uses whichever of the two — the fraction or the floor — produces the larger window. It has no effect on longer timers, where the fraction alone already exceeds it.

**Running ripples:** `showRunningRipples` (default `false`) shows a thin ring pulsing outward from the dial's own edge and fading out — a beacon/live-indicator-style halo, similar to a map's live-location dot. It starts right at the ring's boundary and only grows slightly beyond it, so it never overlaps the time readout or the progress ring itself. A new one spawns about once a second while `status === 'running'`, each animating through its own 3-second cycle independently, so several are visible overlapping at once. Pausing (or completing/cancelling) only stops *new* ones from spawning — whichever are already mid-flight keep animating through to a normal, faded finish rather than disappearing mid-cycle. Purely decorative: colour comes from `--timer-ripple-color` (falls back to `--timer-progress-color`/`--primary-color`), and it's disabled entirely under `prefers-reduced-motion: reduce`.

**Sound and notifications (both opt-in, both default `false`):** `playSoundOnComplete` plays a short built-in chime on completion (override with your own clip via `soundUrl`); playback failures (autoplay policy, unsupported browser) are swallowed silently, never thrown. `notifyOnComplete` shows a browser `Notification` on completion — permission is requested lazily on the first `start()` (a user gesture, as browsers require), and a denied or unsupported Notification API fails completely silently, with no console warning. Note: `autoStart` firing on page load with no prior user interaction may itself have its permission prompt silently blocked by the browser — that's a platform limitation, not something `Timer` can work around.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `durationSeconds` | `number` | Starting duration in seconds (required) |
| `autoStart` | `boolean` | Start ticking automatically on mount and on `durationSeconds` change (default `true`) |
| `resetOnDurationChange` | `boolean` | A `durationSeconds` change restarts the countdown (`true`, default) or only updates the idle baseline (`false`) |
| `onTick` | `(remainingMs: number) => void` | Fires roughly every 200ms while running |
| `onComplete` | `() => void` | Fires exactly once when it reaches zero |
| `onStart` / `onPause` / `onResume` / `onCancel` | `() => void` | Lifecycle callbacks |
| `onAddTime` / `onRemoveTime` | `(seconds, newRemainingSeconds) => void` | Fires when time is adjusted, by button or ref |
| `showControls` | `boolean` | Show the built-in control row (default `true`) |
| `hideAddButton` / `hideRemoveButton` / `hidePauseButton` / `hideCancelButton` | `boolean` | Hide one control while keeping the rest |
| `adjustSeconds` | `number` | Seconds added/removed per click (default `30`) |
| `maxSeconds` | `number` | Optional cap on remaining time when adding |
| `label` | `ReactNode` | Text under the time readout, inside the ring |
| `formatTime` | `(remainingMs: number) => ReactNode` | Overrides the default `MM:SS` / `H:MM:SS` formatter |
| `size` | `string` | Ring diameter, any CSS length. Defaults to `10em`, i.e. 160px at a 16px base |
| `strokeWidth` | `number` | Ring stroke width in px (default `10`) |
| `counterClockwise` | `boolean` | Depletes counter-clockwise instead of the default clockwise sweep (default `false`) |
| `urgentThreshold` | `number` | Fraction (0–1) of the original duration at which the urgent colour/pulse kicks in (default `0.1`) |
| `urgentMinSeconds` | `number` | Floor, in seconds, for the urgent window — whichever of this or `urgentThreshold`'s fraction is larger wins (default `3`) |
| `pulseOnUrgent` | `boolean` | Enables the heartbeat pulse once urgent (default `true`) |
| `showRunningRipples` | `boolean` | Shows a beacon-style halo pulsing outward from the dial's edge while running (default `false`) |
| `playSoundOnComplete` | `boolean` | Play the built-in (or `soundUrl`) chime on completion (default `false`) |
| `soundUrl` | `string` | Overrides the built-in completion sound |
| `soundVolume` | `number` | Completion sound volume, 0–1 (default `0.5`) |
| `notifyOnComplete` | `boolean` | Show a browser Notification on completion (default `false`) |
| `notificationTitle` / `notificationBody` | `string` | Notification text (body falls back to `label` when it's a string) |

**`TimerHandle`** (via `ref`):

| Method | Description |
|--------|-------------|
| `start()` | Starts (or restarts, if idle/completed/cancelled) from the current duration |
| `pause()` | Pauses a running timer |
| `resume()` | Resumes a paused timer from where it left off |
| `addTime(seconds)` | Adds seconds to the remaining time, clamped to `maxSeconds` if set |
| `removeTime(seconds)` | Removes seconds, clamped to 0 (which completes the timer) |
| `cancel()` | Stops and resets to idle at the original duration; does not fire `onComplete` |
| `reset()` | Resets to idle at `durationSeconds` |
| `getStatus()` | Returns the current `TimerStatus` synchronously |
| `getRemainingMs()` | Returns the current remaining ms synchronously |

**`TimerStatus`:** `'idle'` \| `'running'` \| `'paused'` \| `'completed'` \| `'cancelled'`

**Theming:** override these CSS variables theme-wide via `ThemeProvider` or per instance via `style`; each falls back to a built-in default:

| Variable | Falls back to |
|----------|---------------|
| `--timer-track-color` | `color-mix(in srgb, currentcolor 20%, transparent)`: a tint of the text colour, visible on a Card as on the page |
| `--timer-progress-color` | `var(--primary-color)` |
| `--timer-urgent-color` | `var(--error-color)` |
| `--timer-ripple-color` | `var(--timer-progress-color)` (which itself falls back to `var(--primary-color)`) |
| `--timer-size` | `10em`, i.e. 160px at a 16px base (also settable per instance via `size`) |
| `--timer-stroke-width` | `10px` (also settable per instance via `strokeWidth`) |
| `--timer-text-color` | `var(--text-color)` |
| `--timer-label-color` | `var(--text-dark)` |

**Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ Timer: { adjustSeconds: 60, playSoundOnComplete: true } }}`. See [ConfigProvider.md](ConfigProvider.md).

**Slots:** `root` `ringWrapper` `ringSvg` `ringTrack` `ringProgress` `ripples` `ripple` `display` `time` `label` `controls` `addButton` `removeButton` `pauseButton` `cancelButton`

**Labels:** `cancel` — the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `TimerLabels`.

## Sizing

The ring follows the theme's text size, because the readout inside it does: `--timer-size` falls back to `10em`, i.e. 160px at a 16px base, and the gaps and padding around it are `em` too. The `size` prop still wins where it is given.

`--timer-stroke-width` is the exception and stays in viewBox units. It already scales with the ring, and it has to match the `strokeWidth` prop, which the component subtracts from the circle's radius so the stroke fits inside the viewBox.
