Dot timer
NewA dark recording pill whose round lamp of LEDs sweeps a band of light while the time counts up.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Showing that something is being recorded, and for how long: a voice memo, a screen capture, a call.
- An elapsed time the person starts and pauses themselves, with one press and nothing else to learn.
- A status that must read from across the room: the sweeping lamp says “live” before the figures are read.
- A time kept by your own recorder: pass
elapsedandrunningand the pill only shows them.
When not to use
- Counting down to a deadline. It only counts up; use a countdown that says what happens at zero.
- Times that need tenths or hundredths, like a stopwatch for laps. It shows whole seconds only.
- Several timers side by side: a row of sweeping lamps competes for the eye. Use plain figures and one dot.
- Dense toolbars and table cells: the pill sits on its own stage and wants room around it.
You can add this component with the shadcn CLI or by hand:
Into components/dot-timer of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/dot-timer.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { useState } from 'react';import { DotTimer } from './dot-timer';export function RecordButton() { const [recording, setRecording] = useState(false); return ( <DotTimer running={recording} onToggle={(running) => setRecording(running)} onReset={() => setRecording(false)} color="red" autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A recording timer in a dark pill. On its left, a round lamp of 21 LEDs rolls a leaning band of light across itself, each LED glowing warm just before it lights and just after; on its right, the time counts up. Press the pill to start and pause, hold it to reset, and pick the LEDs’ colour.
- The lamp is a 5 by 5 grid without its corners: 21 LEDs, 5.4 px across and 7.82 px apart. A band of light rolls across it from left to right, leaning so that its bottom leads, once every 1.2 s.
- Each LED runs the same cycle of 36 frames: lit for 16, warm for 3, dim for 14, warm for 3 again. Only where it starts differs: about 5 frames after its left neighbour and 2 before the one above it. That is the whole pattern, a table of 21 numbers.
- The cycle is one CSS animation on opacity, in steps, and each LED gets its starting frame as a negative animation delay. Starting and pausing the timer only plays or pauses the animations; nothing runs per frame in script.
- The clock is timestamps, not ticks: what was counted before the last start, plus the time since. The display re-renders once on each whole second, and not at all off screen or in a hidden tab, where the LEDs pause too; it is right again the moment it is seen.
- Press the pill, or Space or Enter on it, to start and pause; hold it for 600 ms, pressing in as it goes, or press R, Escape, Backspace or Delete, to reset to 0:00. A reset restarts the lamp from its first frame.
- While stopped or paused the lamp holds its frame and dims; the time keeps tabular digits, so the pill keeps its width as they change.
- Everything is drawn in --u, a px of the pill at its own size; the stage sets it from its own width with a container query, so the pill stays about 40% of the stage and never smaller than itself.
- While on screen and untouched, it counts from 0:00 to 0:05 and starts over. The first press or key hands it to the reader for good, at the time it shows.
- Under reduced motion the LEDs hold a half-lit frame as plain opacities, with no animation at all; the time still counts.
- The stage’s colours are light-dark() tokens on the root, lilac by day and deep violet at night; the pill is dark on both. The color prop sets the LEDs: red, amber, green, blue or white, or any CSS colour.
| Prop | Type | Default | Description |
|---|---|---|---|
color | 'red' | 'amber' | 'green' | 'blue' | 'white' | string | 'red' | The LEDs’ colour: one of the presets, or any CSS colour. |
running | boolean | — | Whether it runs, for a controlled timer: it counts while true, and calls onToggle when pressed. |
defaultRunning | boolean | false | Whether it starts running, for an uncontrolled timer. The demo is then off. |
elapsed | number | — | The time shown, in ms, for a controlled time: it shows this and counts nothing itself (the lamp still follows running). |
defaultElapsed | number | 0 | The time it counts on from, in ms, for a timer that counts itself. |
onToggle | (running: boolean, elapsed: number) => void | — | Called when the reader starts or pauses it, with the new state and the time in ms. |
onReset | (elapsed: number) => void | — | Called when the reader resets it, with the time in ms it had reached. |
label | string | 'Recording timer' | The pill’s accessible name; the time, in words, follows it. |
autoplay | boolean | true | While on screen and untouched, it counts from 0:00 to 0:05 and starts over. Off when it is controlled. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s; without it, the page’s color-scheme. |
className | string | — | Classes for the stage. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './dot-timer.module.css';/* * Dot timer: a dark recording pill. On its left a round dot-matrix lamp of 21 LEDs, a 5 by 5 * grid without its corners, sweeps a band of light across itself; on its right the time counts * up, m:ss (h:mm:ss past an hour). * * - The lamp is a lit band that rolls from left to right, leaning so that the bottom leads: every * LED is lit for 16 of the cycle's 36 frames (1.2 s at 30 frames a second), glows warm for 3/* Dot timer (dot-timer.tsx): a dark pill on a soft lilac stage, a round lamp of LEDs on its left and the time on its right. Every size is in --u, a px of the pill at its own size (63 px tall). The stage sets --u from its own width (a container query), so the pill keeps about 40% of the stage's width, and never draws smaller than its own size. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The pill is dark on both; the LEDs take --led, set by the color prop. Memory. One animation on 21 small boxes' opacity, in steps; paused while stopped, off screen, in a hidden tab or under reduced motion. */@property --u { syntax: '<length>'; inherits: true; initial-value: 1px;}.root { /* The stage: lilac to pink by day, deep violet at night. */ --stage: light-dark(#d8d0fd, #16122a);'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input (never, for an idle of Infinity). `visible` * and `reduced` are returned too, for what must pause off screen whatever the demo does. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => {- The pill is a real button with aria-pressed: pressed while the timer runs. Its name is the label and the time in words, such as “Recording timer 1 minute 5 seconds”, so it is read on focus without announcing every second.
- Space or Enter starts and pauses; R, Escape, Backspace or Delete resets. A hint, read as the button’s description, says so, and that holding the pill resets it too.
- A polite live region says what changed: started or paused at what time, or reset.
- The lamp and the visible digits are aria-hidden: the button’s name carries the time.
- A visible focus ring outlines the pill; a long press does not open the context menu on touch.
- Under reduced motion the LEDs hold still on a half-lit frame and the time still counts.