Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Round tracker

New

A 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.tsx
Live · interactive

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

  • 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/round-tracker.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

tender-progress.tsx
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.
PropTypeDefaultDescription
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.
currentnumber—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.
defaultCurrentnumber1Where 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.
completedLabelstring'Completed'The pill of a done round.
activeLabelstring'In progress'The pill of the round under way.
labelstring'Progress'The tracker’s accessible name.
autoplaybooleantrueWhile 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.
classNamestring—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.

round-tracker.tsx
// 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.module.css
/* 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-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. `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.