# How to Add a Timer and Stopwatch to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A timer/stopwatch needs a state machine (idle/running/paused/finished), a drift-free time engine using Date.now() delta instead of raw setInterval ticks, and an audio alert on countdown end via the Web Audio API. With Lovable or V0 you can ship both modes with lap tracking and keyboard shortcuts in 1–2 hours for $0/month — no backend required unless you want to persist session history.

## What a Timer and Stopwatch Feature Actually Is

A timer/stopwatch is deceptively simple at first glance but has two failure modes that break the majority of first builds. The first is drift: using setInterval to count ticks accumulates error under CPU load or when the browser tab is throttled. A correct implementation records the start time once and computes elapsed time as Date.now() minus that anchor on every render tick — drift becomes mathematically impossible. The second failure mode is the audio alert: browsers require a user gesture to unlock the AudioContext, so scheduling a beep for when the countdown hits zero only works if the context was activated earlier. Beyond these two mechanics, the product decisions are simple: which modes to expose, whether to persist session data, and how keyboard-friendly to make the UI.

## Anatomy of the Feature

Seven components, but three of them are often generated incorrectly by AI tools. The timer state machine, countdown display, and audio alert need explicit specification to avoid the most common failure modes.

- **Timer State Machine** (ui): React useReducer managing four discrete states: idle (no session started), running (timer ticking), paused (elapsed time frozen), finished (countdown hit zero or stopwatch stopped). State transitions map to buttons: Start, Pause, Resume, Reset, Lap. Using useReducer instead of multiple useState calls prevents impossible states like 'running and paused simultaneously'.
- **Countdown Display** (ui): MM:SS.ms or MM:SS display using CSS font-variant-numeric: tabular-nums to prevent layout shift when digits change width. Large accessible font (minimum 3rem). Color transitions red when remaining time drops below 10 seconds, providing visual urgency without additional UI chrome.
- **Progress Ring** (ui): SVG circle element with stroke-dashoffset animated via CSS transition. Calculated on each render as (remaining / total) * circumference where circumference = 2 * Math.PI * radius. No canvas required. The SVG approach allows CSS animations and transforms without JavaScript animation loops.
- **Audio Alert** (service): Web Audio API AudioContext.createOscillator() generates a zero-dependency beep when the countdown reaches zero. The AudioContext must be created and resumed on the first user click (the Start button) to satisfy the browser autoplay policy. The reference is then stored and the scheduled beep fires from the already-unlocked context when the timer ends. As an alternative, an HTMLAudioElement pointing to a short .mp3 file in the public/ directory works but requires a file to ship.
- **Lap Tracker** (data): In-memory array of objects: { lapNumber: number, splitTime: number, totalTime: number }. Rendered in a scrollable list below the timer display. No database needed for session-only laps — the array is part of the component state and is reset on R. Each lap entry shows both the split (time since last lap) and the cumulative total.
- **Persistence Layer** (data): localStorage via the useLocalStorage hook from usehooks-ts saves the last used timer duration and mode between browser sessions. An optional Supabase table (timer_sessions) stores completed session metadata for logged-in users who want cross-device history or productivity analytics.
- **Preset Selector** (ui): Row of chip buttons for common durations: 5 min, 10 min, 25 min (Pomodoro), 1 hour. Clicking a chip sets the countdown value and switches to countdown mode. Styled as shadcn/ui Badge components in an interactive variant, or as toggle-style pill buttons.

## Build paths

### Lovable — fit 4/10, 1–2 hours

Lovable generates clean React timer logic and the Audio API alert reliably when prompted with the Date.now() pattern explicitly. Best choice for a standalone productivity tool or when the timer is part of a larger Lovable app.

1. Create or open a Lovable project in Agent Mode
2. Paste the prompt below — specify Date.now() delta and Audio API explicitly to override Lovable's default setInterval pattern
3. After generation, open the preview and test: start the timer, switch tabs for 30 seconds, return and verify the display is accurate — this catches drift immediately
4. Test the audio alert by letting a 10-second countdown expire with the volume up — the beep should play even if you clicked away from the Start button
5. Publish and test keyboard shortcuts (Space and R) on the live HTTPS URL

Starter prompt:

```
Build a timer and stopwatch feature. Use React useReducer for state: modes are idle, running, paused, finished. CRITICAL: time must use Date.now() delta, not raw setInterval tick counting — record startTime = Date.now() when timer starts, compute remaining = duration - (Date.now() - startTime) on each RAF/setInterval tick. Both countdown and stopwatch modes on the same page with a visible mode toggle that doesn't lose session state. Countdown display: MM:SS format, CSS font-variant-numeric tabular-nums, turns red below 10 seconds. SVG progress ring showing percentage of time remaining. Audio alert when countdown hits zero using Web Audio API: create AudioContext on first Start button click (to satisfy autoplay policy), store the reference, play a 440hz oscillator beep for 0.5 seconds when the timer finishes. Stopwatch mode: lap button records split time and total time, displayed in a scrollable list below (use React.memo on the list component). Keyboard shortcuts: Space = start/stop, R = reset. Preset chips for 5 min, 10 min, 25 min, 1 hr. Save last timer duration to localStorage (use useLocalStorage from usehooks-ts, wrapped in useEffect for SSR safety). Edge case: if user refreshes mid-session, restore last duration from localStorage and show in idle state. Page Visibility API: on visibilitychange event, recalculate elapsed time from the stored startTime so the display is accurate when the user returns to the tab.
```

Limitations:

- Lovable may default to raw setInterval tick counting if the Date.now() instruction is not explicit — always verify by backgrounding the tab for 60 seconds and checking the displayed time against a real clock
- The Lovable preview iframe may throttle timers differently than the published app — test keyboard shortcuts and the audio alert on the published HTTPS URL

### V0 — fit 5/10, 1–2 hours

V0 excels at polished timer UI — generates the accessible SVG progress ring, tabular-nums display, and shadcn/ui styling natively. Best choice when the timer is a component inside a larger Next.js productivity app.

1. Paste the prompt below in V0 — it generates a client component with the full timer logic
2. Mark the component with 'use client' — V0 sometimes omits this on stateful components
3. Test the localStorage restoration by refreshing the page mid-countdown — the last duration should appear in idle state
4. Verify the audio alert: AudioContext creation on the Start button click must happen before the countdown ends or the beep will be silently blocked
5. Publish to Vercel and test on a real browser — the V0 sandbox preview may not reflect audio behavior accurately

Starter prompt:

```
Create a Next.js timer and stopwatch component. Mark it 'use client'. Use React useReducer with states: idle, running, paused, finished. Time engine: on Start, record startTime = Date.now(); on each setInterval(fn, 100) tick, compute elapsed = Date.now() - startTime; remaining = duration - elapsed. Clear the interval in the useEffect cleanup function. Two modes switchable via a tab toggle: countdown (default 25 min) and stopwatch. Countdown display: large MM:SS text with font-variant-numeric: tabular-nums; transitions text-red-500 when remaining < 10 seconds. SVG progress ring: a circle element with stroke-dashoffset = circumference * (1 - remaining/duration), animated with CSS transition. Audio alert on countdown end: create AudioContext on the first Start button click interaction (store ref in a useRef); when timer finishes, call oscillatorRef.current.start() with frequency 440hz for 0.5s. Stopwatch mode: Lap button appends {lapNumber, splitTime, totalTime} to a laps array; render in a React.memo list component below the display. Keyboard shortcuts via useEffect on window: Space = toggle start/pause, R = reset; call e.preventDefault() first. Preset chips: 5 min, 10 min, 25 min, 1 hr — set the countdown value without needing a text input. localStorage: wrap read in useEffect, store last duration as 'timer-last-duration'; null is the SSR-safe initial state. Page Visibility API: addEventListener('visibilitychange') to re-sync display from startTime when tab regains focus.
```

Limitations:

- V0's sandbox preview cannot test the audio alert accurately — deploy to Vercel and test in a real browser to confirm AudioContext unlocking works
- localStorage reads outside useEffect cause Next.js hydration warnings — ensure all reads are inside useEffect or use V0's generated useLocalStorage pattern

### Custom — fit 3/10, 1–2 days

Custom development is worth it only when the timer must integrate with external systems that AI tools cannot wire in a single prompt. For standalone timer UI, the AI paths produce identical results at 10% of the effort.

1. Integrate with time tracking APIs like Toggl or Clockify — log completed sessions automatically via their REST API with OAuth tokens stored in Supabase Secrets
2. Implement Google Calendar API blocking — create calendar events for planned focus sessions and delete them when sessions end early
3. Build Slack webhook notifications that post to a channel when a session ends, using a Supabase Edge Function as the notification proxy
4. Add enterprise timesheet compliance: server-side sign each session with a timestamp from a trusted server clock, stored in a tamper-proof audit log

Limitations:

- For a standalone timer feature, custom dev takes 5–10x longer than the AI paths with no user-facing difference in the result — the complexity gap is only justified by the external integrations

## Gotchas

- **Timer drifts visibly after a few minutes** — setInterval is not guaranteed to fire on its exact schedule. Under CPU load or when macOS/Windows deprioritizes background processes, the interval may fire 5–20ms late on each tick, accumulating to 2–5 seconds of drift per hour. A timer showing 5:02 when a real clock reads 5:00 is unusable for Pomodoro or cooking. Fix: Record startTime = Date.now() when the timer starts. On each setInterval tick, compute remaining = duration - (Date.now() - startTime) and render that value — never decrement a counter. This anchors the display to wall-clock time regardless of how late each interval fires.
- **Timer display freezes when the tab is hidden** — Chrome, Firefox, and Safari throttle setInterval to a minimum of 1000ms (1 second) for background tabs to save battery. A 100ms interval becomes a 1-second interval as soon as the user switches tabs, making the display jump in 1-second increments and eventually appear frozen. Fix: Add a visibilitychange event listener: when the tab becomes visible again, call the tick function immediately to recalculate remaining from the stored startTime. The Date.now() delta approach makes the displayed time accurate the instant the tab is focused, regardless of how long it was throttled.
- **Audio alert plays in the V0 preview but not after Vercel deployment** — Browsers block AudioContext creation and audio playback until a user gesture occurs. When the timer expires and the code calls audioContext.resume().then(() => playBeep()), the Promise resolves as 'suspended' if no click has happened. The preview environment's iframe security model differs from a real browser tab, masking this bug during development. Fix: Create the AudioContext and call audioContext.resume() inside the Start button's click handler on the very first press, and store the context in a useRef. When the countdown ends, the context is already running — call oscillator.start() directly without another resume(). Test this on the deployed Vercel URL by pressing Start and immediately switching away, then letting the countdown expire.
- **localStorage state causes Next.js hydration mismatch on the V0 app** — The server renders the component with an empty initial state (no localStorage available during SSR). The client hydrates with whatever is stored in localStorage. React detects that the rendered HTML doesn't match what the client would render, logs a hydration error, and may render incorrectly. Fix: Initialize all localStorage-dependent state as null or a defined default value that matches the server render. Inside a useEffect (which only runs client-side), read localStorage and update the state. The pattern with usehooks-ts useLocalStorage handles this correctly if you pass null as the initial value and check for null before rendering preset data.
- **Lap list causes scroll jank during long stopwatch sessions** — If the lap list component re-renders on every setInterval tick (every 100ms), a session with 50+ laps will re-render a 50-item list 10 times per second. This is unnecessary because lap data only changes when the user clicks Lap, not on every tick. Fix: Wrap the lap list in React.memo with a custom comparison that returns true when the laps array reference is unchanged. Move the laps array outside the timer tick state so that ticks do not re-create the array reference. Only the timer display component should re-render on every tick — the lap list updates only when a new lap is added.

## Best practices

- Always use Date.now() as the time source for elapsed calculation, never accumulate setInterval ticks — drift under CPU or tab throttling is guaranteed otherwise
- Create AudioContext on the first Start button click and store it in a useRef; do not create it at module load time or the browser will block it
- Return a cleanup function from every useEffect that sets up a setInterval or addEventListener — unmounting without cleanup causes memory leaks and the 'state update on unmounted component' warning
- Use tabular-nums (font-variant-numeric: tabular-nums) on the timer display to prevent digits from shifting the layout width on each tick
- Wrap the lap list in React.memo so it only re-renders when laps.length changes, not on every 100ms tick
- Add the visibilitychange listener to re-sync display time when the user returns to the tab — this is a 5-line addition that eliminates the most common user complaint about background throttling
- Persist only the last used duration to localStorage, not the running state — restoring a 'running' timer after a page refresh is disorienting; idle with the pre-set duration is the right UX

## Frequently asked questions

### Will the timer keep running if I switch tabs?

The timer continues running — it uses Date.now() anchored to the start time, so elapsed time accumulates even when the tab is hidden. However, the display updates will be throttled to 1-second intervals by the browser to save battery. When you return to the tab, add a visibilitychange listener that immediately recalculates from the stored startTime and the display will show the correct time instantly.

### Can I add multiple timers running simultaneously?

Yes, by making the timer a self-contained component with its own useReducer state and rendering multiple instances on the page. Each instance has its own startTime reference. The main design decision is whether they share a single AudioContext (create one at the app level and pass it as a prop) or each create their own (simpler but uses more browser resources).

### How do I save timer history across sessions?

For local persistence, write the completed session to localStorage on each reset or finish. For cross-device history, insert a row into a Supabase timer_sessions table (user_id, mode, duration_seconds, laps jsonb, started_at, ended_at) when the session ends. Add the optional Supabase table from the data model section and include it in your prompt.

### Can the timer send a notification when it finishes — even if the tab is not focused?

The Web Notifications API can show a system notification when the countdown ends. Call Notification.requestPermission() on user interaction (the Start button), then call new Notification('Timer done!') when the countdown hits zero. This works even when the tab is in the background. For push notifications that work even when the tab is closed, you need a Service Worker — that is a separate feature to build on top of the timer.

### What is the difference between a countdown timer and a stopwatch in terms of build complexity?

They share the same time engine (Date.now() delta) and state machine. The differences are: countdown starts at a user-specified duration and counts down to zero, triggering the audio alert on finish; stopwatch starts at zero and counts up indefinitely, exposing a Lap button. The SVG progress ring only applies to countdown. Both modes run on the same component — the mode toggle just changes which direction the display counts and whether the Lap button is visible.

### Can I embed this timer in an existing web app page?

Yes — the timer is a self-contained React component with no external dependencies beyond the Web Audio API (built into every browser). Drop the component into any page that uses React. The only requirement is that the parent page must be served over HTTPS for the AudioContext to work in production, and the component must be marked 'use client' if it is inside a Next.js App Router project.

### How do I add custom alarm sounds?

Place an audio file (alarm.mp3 or alarm.ogg) in the public/ directory of your project. Replace the AudioContext oscillator approach with an HTMLAudioElement: const alarm = new Audio('/alarm.mp3'). Call alarm.play() when the countdown ends. The trade-off: audio files give you richer sounds but add a file download for every user; the Web Audio API oscillator is zero-byte and always works but only generates simple tones.

### Does this work on mobile browsers?

Yes. The Web Audio API and Page Visibility API are supported on iOS Safari 14.5+ and all Android browsers. The main difference on mobile is that Safari on iOS may suspend the AudioContext when the page is backgrounded — create the context on the Start tap and test on a real iPhone, not just the Chrome DevTools mobile simulator which behaves differently.

---

Source: https://www.rapidevelopers.com/app-features/timer-stopwatch
© RapidDev — https://www.rapidevelopers.com/app-features/timer-stopwatch
