Progress square
StableProgress drawn as the brand’s square, closing clockwise from the top centre, and a row of squares for a set of focus sessions.
SquareProgress is a hairline track and a lit stroke with a small solid square riding its head: hollow while paused, gone once the square closes. Pass elapsed for a still value, or read and live for a running clock that redraws every frame without re-rendering React. CycleSquares shows how far through a set of focus sessions you are.
Updated View source
States
import { SquareProgress } from '@rchursin/product/timer'
const states = [ { at: 0, state: 'running', word: 'Running' }, { at: 0.25, state: 'running', word: 'Running' }, { at: 0.7, state: 'paused', word: 'Paused' }, { at: 1, state: 'done', word: 'Done' },] as const
/** * The square closes clockwise from the top centre. The head is solid while running, hollow when * paused, and gone once the square is closed. The square is decorative: the words carry the state. */export default function SquareProgressStates() { return ( <ul className="grid w-full max-w-md grid-cols-4 gap-3 sm:gap-6"> {states.map((s) => ( <li key={s.at} className="flex min-w-0 flex-col items-center gap-2.5"> <SquareProgress size="sm" total={100} elapsed={s.at * 100} state={s.state} className="w-full max-w-[88px]"> <span className="font-mono text-[11px] text-fg-2 tabular-nums">{Math.round(s.at * 100)}%</span> </SquareProgress> <span className="font-mono text-[10px] tracking-[0.12em] text-fg-3 uppercase">{s.word}</span> </li> ))} </ul> )}Usage
import { CycleSquares, SquareProgress } from '@rchursin/product/timer'<SquareProgress size="sm" total={25 * MINUTE} elapsed={10 * MINUTE} /><SquareProgress total={cd.durationMs} read={(now) => cd.durationMs - cd.read(now)} live={running}> …</SquareProgress><CycleSquares done={1} total={4} inProgress showText />Examples
Live
A 30-second countdown. With reduced motion the square steps once a second instead of sweeping.
import { useAnnounce } from '@rchursin/product/lib/announce'import { Button } from '@rchursin/product/ui'import { SquareProgress, TimeDisplay, useCountdown } from '@rchursin/product/timer'
const status = { idle: 'Ready', running: 'Running', paused: 'Paused', finished: 'Done' } as const
/** * Live: pass `read` and `live` and the square redraws on every frame without re-rendering React * (one-second steps with reduced motion). The square is silent, so the end is announced in words. * The run is saved in this browser, so a reload carries on. */export default function SquareProgressLive() { const announce = useAnnounce() const cd = useCountdown({ storageKey: 'library:progress-square', initialMs: 30_000, milestones: [], onFinish: () => announce('Countdown finished') }) const running = cd.status === 'running'
return ( <div className="flex flex-col items-center gap-5"> <SquareProgress size="md"Cycle squares
Done is solid, the one in progress hatched, the rest outlined.
import { CycleSquares } from '@rchursin/product/timer'
const sets = [ { tag: 'New set', done: 0, inProgress: false }, { tag: 'Second running', done: 1, inProgress: true }, { tag: 'Three done', done: 3, inProgress: false }, { tag: 'Long break next', done: 4, inProgress: false },]
/** * One small square per focus session in a set: solid when done, hatched while one runs, outlined * still to come. Screen readers hear “1 of 4 focus sessions done, number 2 in progress”. */export default function CycleSquaresSets() { return ( <dl className="grid w-full max-w-xs grid-cols-[1fr_auto] items-center gap-x-6 gap-y-3"> {sets.map((s) => ( <div key={s.tag} className="contents"> <dt className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">{s.tag}</dt> <dd> <CycleSquares done={s.done} total={4} inProgress={s.inProgress} showText /> </dd> </div> ))} </dl> )}API
SquareProgress props
| Prop | Type | Default | Description |
|---|---|---|---|
totalrequired | number | – | Length of the run in ms. Progress is elapsed ÷ total. |
elapsed | number | 0 | A still elapsed value in ms. |
read | (now: number) => number | – | Live elapsed ms, read on animation frames while live. |
live | boolean | false | Keep reading read: every frame with motion allowed, whole seconds with reduced motion. |
state | 'idle' | 'running' | 'paused' | 'done' | 'running' | running: solid head · paused: hollow head · done: closed square, no head · idle: no head. |
size | 'sm' | 'md' | 'lg' | 'md' | Stroke weights for the drawn size: sm about 64–100 pixels, md 140–200, lg 260–340. |
className | string | – | Classes on the square box (give it a width; it stays square). |
children | ReactNode | – | Centred inside the square: the time, a percentage, a label. |
CycleSquares props
| Prop | Type | Default | Description |
|---|---|---|---|
donerequired | number | – | Focus sessions finished in this set. |
totalrequired | number | – | Sessions in a set (four before the long break by default). |
inProgress | boolean | false | A session of this set is running or paused: the next square is hatched. |
showText | boolean | false | Also show “1/4” beside the squares. |
size | 'sm' | 'md' | 'md' | 8 or 10 pixel squares. |
className | string | – | Classes on the wrapper. |
CSS
| Class or variable | What it does |
|---|---|
tm-track | The hairline track: the text colour at 11%. |
tm-bar, tm-bar-hi, tm-bar-lo | The lit stroke: a graphite gradient in light mode, silver with a soft glow in dark mode. |
tm-head | The square head with a 2 pixel gap in the surface colour; [data-state="paused"] makes it hollow. |
forced colours | The track turns GrayText, the stroke and head CanvasText. |
Accessibility
- SquareProgress is decorative (aria-hidden) and has no role="progressbar" on purpose: some screen readers beep on every update. Put the time or a word inside or next to it.
- States are shapes: a solid head while running, a hollow one when paused, a closed square with no head when done.
- With reduced motion a live square moves in whole-second steps, and still values don’t animate.
- CycleSquares hides the squares and says it in words: “1 of 4 focus sessions done, number 2 in progress”. The visible “1/4” from showText is hidden in favour of that sentence. The words are about focus sessions; it is made for the timer.
Guidelines
Do
- Pair the square with the time or a status word.
- Use read and live for a running clock, so only the square redraws.
- Pick the size preset for the width you draw it at.
Don’t
- Don’t use it for loads of unknown length; use a spinner.
- Don’t add role="progressbar" or a live region to it; announce milestones instead.
- Don’t colour it; the head’s shape carries the state.
In use
Related
- Time displayThe clock face: Geist Mono digits in fixed cells, lit like the buttons, still or live, with a spoken form for screen readers.
- CountdownA countdown with presets or a typed length, +1 min while it runs, a closing square and an honest finished banner.
- Focus sessionA Pomodoro session: choose a task, say what you’ll finish, then focus and break in phases, with overtime and an inline Abandon.
- Timer engineClocks that keep honest time: stopwatch and countdown hooks built on timestamps, one shared frame loop, and duration parsing.
- Fills & patternsSolid, hatched, outline and dashed: the second “colour” of a black-and-white interface, plus the dot-grid canvas.
Source
The component’s own files, exactly as they run on this site.
/** * Progress drawn as the brand's square: a hairline track, a lit stroke that closes the square clockwise from the * top centre, and a small solid square riding its head (hollow while paused). * * <SquareProgress total={durationMs} elapsed={ms} /> still * <SquareProgress total={durationMs} read={elapsedRead} live={running}>…</SquareProgress> * * The stroke is an SVG path with pathLength=100, so stroke-dashoffset is simply 100 − percent. With motion allowed * a live square is redrawn on every frame (straight to the DOM, no React render); with reduced motion it moves in * one-second steps instead. It is decorative (aria-hidden): the time inside, or a label next to it, says the same. * No role="progressbar" on purpose: some screen readers beep on every progressbar update. */import { useId, useLayoutEffect, useRef, type ReactNode } from "react"import { cx } from "../../components/cx"import { useMotionOK } from "../../components/motion"import { useFrame } from "./ticker"import "./timer.css"
/** Stroke widths are in viewBox units (0–100), so each preset suits a rendered size: sm ≈ 64–100px, md ≈ 140–200px, lg ≈ 260–340px */const sizes = {