Round tracker
NewA stepper for a process in rounds: green checks behind, a blue bolt ring under way, and dashes marching on to the next round.
round-tracker/round-tracker.tsxThe 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
- A process that runs in a fixed number of rounds, where the reader needs to see which are behind, which one is under way and what is left.
- Rounds with names and dates worth reading: a tender, a hiring loop, a funding round, a review cycle.
- A status shown above the work of the current round, read at a glance and rarely changed from here.
- Letting the reader jump back to a past round to look at it, by clicking it or with the arrow keys.
When not to use
- A form split into pages the reader fills in order. Use a wizard with Back and Next and its own step header.
- Progress with no named rounds, or more than seven or eight of them. Use a progress bar with a count.
- Steps that run in parallel or branch. Use a checklist, or a timeline of events.
- Moving between sections the reader can visit in any order. Use tabs.
- A live log of what an agent is doing right now. Use a list of steps with spinners and ticks.
You can add this component with the shadcn CLI or by hand:
Into components/round-tracker of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/round-tracker.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 { RoundTracker } from './round-tracker';const rounds = [ { label: 'Round 1', title: 'Proposal Intake', date: '2026-01-12' }, { label: 'Round 2', title: 'Bidder Shortlist', date: '2026-02-09' }, { label: 'Round 3', title: 'Best & Final Bids', date: '2026-02-23' }, { label: 'Final round', title: 'Lease Signing', date: '2026-04-06' },];export function TenderProgress({ round }: { round: number }) { return <RoundTracker steps={rounds} current={round} label="Tender progress" />;}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 white card with each round of a process on one line: its label, title, date and a status pill. Rounds behind are green discs with a check joined by a solid green line; the round under way is a blue ring around a bolt, with blue dashes pouring out of it towards the next round; rounds ahead are grey dots on a light dashed line. Moving on plays in order: the check pops, the line pours into the next node, then its ring and pill pop in, round after round for a jump. On a narrow screen the line turns into a column.
- Each round is a list item: its node, its words, and the stretch of line to the next round, laid out on a grid of equal columns. The stretch runs from the node’s edge to the column’s end, so it meets the next node wherever the columns fall.
- A node holds its three looks stacked (grey dot, green check, blue ring) and the round’s data-state picks one; the others shrink and fade. The check draws its stroke by its dash offset.
- A stretch holds three layers: the grey dashed track, the green fill, scaled from its start so it pours along in 500 ms, and the blue dashes, slanted stripes of a repeating gradient that slide along by their background while a clip opens them along the stretch, over and over.
- Moving on is transition delays alone: the round left behind checks at once, the line pours, and 440 ms later the next ring and its pill pop in with a slight overshoot. The list carries where the tracker was (--from), so a jump of several rounds plays them one after the other, 380 ms apart.
- An auto tracker measures its own width: under 128 px a round, it turns into a column, the line running down between the nodes and the words beside them.
- Without steps it draws its own sample: four trackers stacked at rounds 2 to 5, which move on together every 2.8 s while on screen and untouched; on a phone, only the first. The marching dashes stop off screen.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. At night the card turns near-black and the green and blue lighten a step.
| Prop | Type | Default | Description |
|---|---|---|---|
steps | { label: string; title: string; date?: string }[] | — | The rounds, in order; labels are drawn in capitals. Without them, the component draws its own sample, four trackers stacked. |
current | number | — | The round under way, from 0: the rounds before it are done, and steps.length means all are. Given, the tracker is controlled; without it, it keeps its own and a click moves it. |
defaultCurrent | number | 1 | Where an uncontrolled tracker starts. |
onStepClick | (index: number) => void | — | Called with a round’s index when the reader picks it. Makes the rounds buttons. |
orientation | 'auto' | 'horizontal' | 'vertical' | 'auto' | One row, one column, or a row that turns into a column under 128 px a round. |
completedLabel | string | 'Completed' | The pill of a done round. |
activeLabel | string | 'In progress' | The pill of the round under way. |
label | string | 'Progress' | The tracker’s accessible name. |
autoplay | boolean | true | While on screen and untouched, an uncontrolled tracker moves on by itself every 2.8 s, and from the end back to the start. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
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, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './round-tracker.module.css';/* * Round tracker: a process told in rounds, in a white card. Each round is a node on one line, * with its label, title and date below: rounds behind are green discs with a check, joined by * solid green lines; the round under way is a blue ring around a disc with a bolt, and a blue * dashed stroke keeps marching out of it into the next stretch; rounds ahead are grey dots on a * light dashed line. Done rounds wear a green "Completed" pill, the live one a blue * "In progress". * * - Every stretch of line holds three layers: the grey dashed track, the green fill (scaled from/* Round tracker (round-tracker.tsx): a white card with the rounds on one line, or in a column when narrow. Without steps, a pale stage holds four of them stacked. 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). Motion. Moving on is transitions with delays: the check pops at once, the green line pours over 500 ms, then the next ring and its pill pop in. The blue dashes run on two animations, a clip that opens along the stretch in a loop and a background that slides the dashes; both only while the card is on screen (data-flowing), never under reduced motion. Everything moves by transform, opacity or clip-path, so nothing lays the page out again. */.root { /* The pale stage of the sample, and its light streaks. */ --stage: #f3f7fb; --streak: rgb(255 255 255 / 0.75); /* The card, its edge and lift. */ --surface: #fff; --edge: #e6e7ec; --shadow: rgb(16 24 40 / 0.035); /* Labels, titles and dates. */ --ink: #111; /* Done: the discs, the line and the pill. */'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. `visible` alone says whether looping * animations are worth running at all. */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]) => {- Each tracker is a nav landmark named by its label, around an ordered list; the round under way carries aria-current="step".
- Rounds that can be picked are buttons named in full (“Round 3, Best & Final Bids, 2026-02-23, In progress”); Tab reaches them, the arrow keys walk them, Home and End jump to the ends, with a visible focus ring. Hover or focus turns a round’s title bold.
- Rounds that can’t be picked state their status in hidden text, as the pills are hidden from screen readers. A pick is announced in a polite live region.
- Nodes and lines are decorative (aria-hidden). Under reduced motion every change is instant, the dashes stand still and the demo never plays.