Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Dot timer

New

A dark recording pill whose round lamp of LEDs sweeps a band of light while the time counts up.

dot-timer/dot-timer.tsx
Live · interactive
Press to start or pause. Hold, or press R, to reset.

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 elapsed and running and 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/dot-timer.json

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

record-button.tsx
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.
PropTypeDefaultDescription
color'red' | 'amber' | 'green' | 'blue' | 'white' | string'red'The LEDs’ colour: one of the presets, or any CSS colour.
runningboolean—Whether it runs, for a controlled timer: it counts while true, and calls onToggle when pressed.
defaultRunningbooleanfalseWhether it starts running, for an uncontrolled timer. The demo is then off.
elapsednumber—The time shown, in ms, for a controlled time: it shows this and counts nothing itself (the lamp still follows running).
defaultElapsednumber0The 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.
labelstring'Recording timer'The pill’s accessible name; the time, in words, follows it.
autoplaybooleantrueWhile 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.
classNamestring—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.

dot-timer.tsx
// 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.module.css
/* 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-autoplay.ts
'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.