# Round tracker

> A stepper for a process in rounds: green checks behind, a blue bolt ring under way, and dashes marching on to the next round.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/round-tracker. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Data.

## Anatomy

1. **Card**: The card that holds the rounds on one line: a grid of equal columns, the first node 29 px in.
2. **Done node**: A green disc with a check, for every round before the current one. The check draws its stroke as it pops.
3. **Done line**: The stretch from a done round to the next, filled solid green over the grey dashes, poured from its start.
4. **Current node**: A blue ring around a disc with a bolt: the round under way, marked `aria-current="step"`.
5. **Marching dashes**: Slanted blue dashes that open out of the current node towards the next one in a loop, only while the card is on screen.
6. **Upcoming dot**: A grey dot on a light dashed line, for every round still ahead.
7. **Words**: The round’s label in capitals, its title and its date, in monospace under the node.
8. **Status pill**: `completedLabel` in green on done rounds, `activeLabel` in blue on the current one; nothing on rounds ahead.

## 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.

## Installation

Install it with the shadcn CLI, into components/round-tracker of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/round-tracker.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```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" />;
}
```

## States

- **First round**: `current={0}`: the first ring is lit, every other round is a grey dot.
- **Under way**: Two rounds done in green, the third under way, two ahead.
- **Final round**: Every round but the last is done; the dashes have no stretch left to run along.
- **All done**: `current={steps.length}`: every node is a check and the line is solid green.
- **Column**: `orientation="vertical"`, or an auto tracker under 128 px a round: the line runs down beside the words.
- **Own pill words**: `completedLabel="Signed"` and `activeLabel="Under review"`; rounds ahead still get none.
- **Sample**: Without `steps`: four trackers stacked on a pale stage, at rounds 2 to 5. On a phone, only the first.
- **Light**: Every colour is a light-dark() token: `colorScheme="light"`, or a light page.
- **Dark**: `colorScheme="dark"`, or a dark page: a near-black card, the green and blue a step lighter.

## How it works

- 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.

## API reference

- **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`, default `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'`, default `'auto'`): One row, one column, or a row that turns into a column under 128 px a round.
- **completedLabel** (`string`, default `'Completed'`): The pill of a done round.
- **activeLabel** (`string`, default `'In progress'`): The pill of the round under way.
- **label** (`string`, default `'Progress'`): The tracker’s accessible name.
- **autoplay** (`boolean`, default `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.

## Accessibility

- 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.

## Source

### round-tracker/round-tracker.tsx

```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
 *   its start, so it pours along) and the blue dashes (clipped open in a loop while the dashes
 *   slide along by their background). The nodes hold their three looks stacked, and the round's
 *   state (data-state) picks one; CSS transitions do the rest.
 * - Moving on is choreographed by transition delays alone: the round left behind pops its check
 *   at once, the line pours into the next node, which then pops its ring and pill. Going back
 *   plays the same parts in reverse.
 * - Without `steps`, it draws its own sample: four trackers stacked at rounds 2 to 5 (one on a
 *   phone), which move on together every few seconds while on screen and untouched.
 * - Its layout follows its own width: one row, or under `steps × 128 px` a column ('auto'); the
 *   orientation prop can pin either.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type RoundStep = {
  /** The small bold line on top, e.g. "Round 1". Drawn in capitals. */
  label: string;
  /** What the round is about. */
  title: string;
  /** When it happens, as written. */
  date?: string;
};

type Orientation = 'auto' | 'horizontal' | 'vertical';

/** The sample process the component draws without `steps`. */
const SAMPLE: RoundStep[] = [
  { 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: 'Round 4', title: 'Letter Of Intent', date: '2026-03-16' },
  { label: 'Final round', title: 'Lease Signing', date: '2026-04-06' },
];
/** The sample's stacked trackers, at these rounds. */
const STACK = [1, 2, 3, 4];
/** How long the demo stays on a round before moving on, in ms. */
const BEAT = 2800;
/** Below this width per round, an 'auto' tracker turns into a column. */
const COLUMN = 128;

export function RoundTracker({
  steps,
  current,
  defaultCurrent = 1,
  onStepClick,
  orientation = 'auto',
  completedLabel = 'Completed',
  activeLabel = 'In progress',
  label = 'Progress',
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The rounds, in order. Without them, the component draws its own sample, four times stacked. */
  steps?: RoundStep[];
  /**
   * The round under way, from 0. Rounds before it are done; `steps.length` means all are done.
   * Given, the tracker is controlled; without it, it keeps its own and a click moves it.
   */
  current?: number;
  /** Where an uncontrolled tracker starts. */
  defaultCurrent?: number;
  /** Called with a round's index when the reader picks it. Makes the rounds buttons. */
  onStepClick?: (index: number) => void;
  /** One row, one column, or a row that turns into a column when narrow ('auto'). */
  orientation?: Orientation;
  /** The pill of a done round. */
  completedLabel?: string;
  /** The pill of the round under way. */
  activeLabel?: string;
  /** The tracker's accessible name. */
  label?: string;
  /** While on screen and untouched, an uncontrolled tracker moves on by itself every few seconds. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const sample = !steps;
  const rounds = steps ?? SAMPLE;
  const { ref, playing, visible, take } = useAutoplay<HTMLDivElement>(6000);
  /** The sample's trackers, each moved by the reader or the demo; or the one uncontrolled. */
  const [own, setOwn] = useState<number[]>(() => (sample ? STACK : [defaultCurrent]));
  const [message, setMessage] = useState('');
  const controlled = current !== undefined;
  const demo = autoplay && playing && !controlled;

  // The demo: every beat, each tracker moves on one round, and from all done back to the start.
  const count = rounds.length;
  useEffect(() => {
    if (!demo) return;
    const timer = window.setInterval(
      () => setOwn((now) => now.map((at) => (at + 1) % (count + 1))),
      BEAT,
    );
    return () => window.clearInterval(timer);
  }, [demo, count]);

  const pick = (tracker: number, index: number) => {
    take();
    onStepClick?.(index);
    if (!controlled) setOwn((now) => now.map((at, i) => (i === tracker ? index : at)));
    const step = rounds[index];
    const text = `${step.label}, ${step.title}: ${activeLabel.toLowerCase()}`;
    setMessage((now) => (now === text ? `${text} ` : text));
  };

  const shared = {
    steps: rounds,
    orientation,
    completedLabel,
    activeLabel,
    flowing: visible,
    interactive: !!onStepClick || !controlled,
  };

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className}`}
      data-part="root"
      data-sample={sample || undefined}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      {sample ? (
        <div className={styles.stack} data-part="stack">
          {own.map((at, i) => (
            <Tracker
              key={i}
              {...shared}
              current={at}
              label={`${label}, tracker ${i + 1}`}
              onPick={(index) => pick(i, index)}
            />
          ))}
        </div>
      ) : (
        <Tracker
          {...shared}
          current={controlled ? current : own[0]}
          label={label}
          onPick={(index) => pick(0, index)}
        />
      )}
      <p className={styles.live} aria-live="polite">
        {message}
      </p>
    </div>
  );
}

const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

function Tracker({
  steps,
  current,
  orientation,
  completedLabel,
  activeLabel,
  label,
  flowing,
  interactive,
  onPick,
}: {
  steps: RoundStep[];
  current: number;
  orientation: Orientation;
  completedLabel: string;
  activeLabel: string;
  label: string;
  flowing: boolean;
  interactive: boolean;
  onPick: (index: number) => void;
}) {
  const card = useRef<HTMLElement>(null);
  /** Where the tracker was before its last move, so a jump plays round after round. */
  const [from, setFrom] = useState(current);
  const [seen, setSeen] = useState(current);
  if (current !== seen) {
    setSeen(current);
    setFrom(seen);
  }
  const [narrow, setNarrow] = useState(false);
  const layout = orientation === 'auto' ? (narrow ? 'vertical' : 'horizontal') : orientation;

  // An 'auto' tracker reads its own width: a row while each round gets COLUMN px, else a column.
  useIsoLayoutEffect(() => {
    const element = card.current;
    if (!element || orientation !== 'auto') return;
    const measure = () => setNarrow(element.clientWidth < steps.length * COLUMN);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, [orientation, steps.length]);

  /** Arrow keys walk the rounds' buttons, Home and End jump to the ends. */
  const onKeyDown = (event: ReactKeyboardEvent<HTMLOListElement>) => {
    const buttons = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button')];
    const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
    if (at < 0) return;
    const next =
      event.key === 'ArrowRight' || event.key === 'ArrowDown'
        ? Math.min(buttons.length - 1, at + 1)
        : event.key === 'ArrowLeft' || event.key === 'ArrowUp'
          ? Math.max(0, at - 1)
          : event.key === 'Home'
            ? 0
            : event.key === 'End'
              ? buttons.length - 1
              : -1;
    if (next < 0) return;
    event.preventDefault();
    buttons[next].focus();
  };

  return (
    <nav
      ref={card}
      className={styles.card}
      data-part="card"
      data-layout={layout}
      data-flowing={flowing || undefined}
      aria-label={label}
    >
      <ol
        className={styles.steps}
        data-part="steps"
        style={{ '--from': from } as CSSProperties}
        onKeyDown={interactive ? onKeyDown : undefined}
      >
        {steps.map((step, index) => {
          const state = index < current ? 'done' : index === current ? 'current' : 'todo';
          const last = index === steps.length - 1;
          const status =
            state === 'done' ? completedLabel : state === 'current' ? activeLabel : 'Upcoming';
          const body = (
            <>
              <span className={styles.node} data-part="node" aria-hidden="true">
                <span className={styles.dot} data-part="node-upcoming" />
                <span className={styles.check} data-part="node-done">
                  <svg viewBox="0 0 12 9" focusable="false">
                    <path d="M1.3 4.7 4.4 7.6 10.7 1.4" pathLength={1} />
                  </svg>
                </span>
                <span className={styles.ring} data-part="node-current">
                  <span className={styles.disc}>
                    <svg viewBox="0 0 10 15" focusable="false">
                      <path d="M7.6.3.7 8.7h4L3 14.7l6.6-8.6H5.5L7.6.3Z" />
                    </svg>
                  </span>
                </span>
              </span>
              <span className={styles.text} data-part="text">
                <span className={styles.label} data-part="label">
                  {step.label}
                </span>
                <span className={styles.title} data-part="title">
                  {step.title}
                </span>
                {step.date && (
                  <span className={styles.date} data-part="date">
                    {step.date}
                  </span>
                )}
                <span className={styles.status} data-part="status">
                  <span className={styles.pill} data-tone="done" aria-hidden="true">
                    {completedLabel}
                  </span>
                  <span className={styles.pill} data-tone="current" aria-hidden="true">
                    {activeLabel}
                  </span>
                  <span className={styles.hidden}>{status}</span>
                </span>
              </span>
            </>
          );
          return (
            <li
              key={index}
              className={styles.step}
              data-part="step"
              data-state={state}
              data-last={last || undefined}
              aria-current={state === 'current' ? 'step' : undefined}
              style={{ '--index': index } as CSSProperties}
            >
              {!last && (
                <span className={styles.connector} data-part="connector" aria-hidden="true">
                  <span className={styles.track} />
                  <span className={styles.fill} data-part="connector-fill" />
                  <span className={styles.flow} data-part="connector-flow" />
                </span>
              )}
              {interactive ? (
                <button
                  type="button"
                  className={styles.hit}
                  aria-label={[step.label, step.title, step.date, status]
                    .filter(Boolean)
                    .join(', ')}
                  onClick={() => onPick(index)}
                >
                  {body}
                </button>
              ) : (
                <div className={styles.hit}>{body}</div>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}
```

### round-tracker/round-tracker.module.css

```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. */
  --done: #5ddaa0;
  /* Under way: the ring, the disc, the dashes and the pill. */
  --live: #2a5ec0;
  /* What sits on the coloured parts: the check, the bolt, the pills' words, the ring's gap. */
  --on: #fff;
  /* Ahead: the dots and the dashed line. */
  --todo: #e7e7e7;
  --todo-line: #e5e5e5;
  /* The same on both schemes: the focus ring. */
  --focus: #387bff;
  container-type: inline-size;
  width: 100%;
  font-family:
    ui-monospace, 'SF Mono', SFMono-Regular, 'JetBrains Mono', Menlo, Consolas, 'Liberation Mono',
    monospace;
  color: var(--ink);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f3f7fb, #0c0e12);
    --streak: light-dark(rgb(255 255 255 / 0.75), rgb(255 255 255 / 0.025));
    --surface: light-dark(#fff, #16181d);
    --edge: light-dark(#e6e7ec, #272a31);
    --shadow: light-dark(rgb(16 24 40 / 0.035), rgb(0 0 0 / 0.3));
    --ink: light-dark(#111, #ececef);
    --done: light-dark(#5ddaa0, #3fc488);
    --live: light-dark(#2a5ec0, #4b7fe4);
    --on: light-dark(#fff, #fff);
    --todo: light-dark(#e7e7e7, #33363d);
    --todo-line: light-dark(#e5e5e5, #363941);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

/* The sample's stage: a pale field crossed by a few soft streaks of light. */
.root[data-sample] {
  background:
    linear-gradient(118deg, transparent 8%, var(--streak) 9%, transparent 10.5%),
    linear-gradient(118deg, transparent 34%, var(--streak) 35%, transparent 36.5%),
    linear-gradient(118deg, transparent 58%, var(--streak) 59.2%, transparent 60.5%),
    linear-gradient(62deg, transparent 70%, var(--streak) 71%, transparent 72.5%), var(--stage);
  border-radius: 18px;
}
.stack {
  display: grid;
  gap: clamp(14px, 2.64cqi, 41px);
  /* Insets of the sample's stage, in its width (the root is the container). */
  padding: clamp(14px, 4.4cqi, 68px) clamp(14px, 9cqi - 47px, 93px);
}
/* On a phone the column would run four screens long: one tracker tells it. */
@container (max-width: 599px) {
  .stack > :not(:first-child) {
    display: none;
  }
}

/* The card. Its insets are the drawing's: the first node 29 px in, and the last round's column
   running to the edge. */
.card {
  box-sizing: border-box;
  min-width: 0;
  padding: 34.5px 0 29px 28px;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--surface);
  box-shadow: 0 1px 3px var(--shadow);
}
.steps {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  margin: 0;
  padding: 0;
  list-style: none;
}
.step {
  /* A jump of several rounds plays them one after the other: each round's part waits for the
     rounds between where the tracker was (--from, on the list) and it. --lag is this round's
     wait, --lead that of the stretch pouring into it. */
  --lag: max(0ms, (var(--index) - var(--from, 0)) * 380ms);
  --lead: max(0ms, (var(--index) - var(--from, 0) - 1) * 380ms);
  position: relative;
  min-width: 0;
}

/* A round: its node, then its words. A button when the rounds can be picked. */
.hit {
  display: grid;
  justify-items: start;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
}
button.hit {
  cursor: pointer;
}
button.hit:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}
/* Hovered or focused, a round's title turns bold. */
button.hit:hover .title,
button.hit:focus-visible .title {
  font-weight: 700;
}

/* The node: three looks stacked, the round's state picks one. */
.node {
  position: relative;
  display: grid;
  width: 34px;
  height: 34px;
}
.dot,
.check,
.ring {
  grid-area: 1 / 1;
  place-self: center;
  border-radius: 50%;
  transition:
    transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1),
    opacity 160ms ease;
}
.dot {
  width: 22px;
  height: 22px;
  background: var(--todo);
  opacity: 0;
  transform: scale(0.4);
}
.check {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  background: var(--done);
  opacity: 0;
  transform: scale(0.3);
}
.check svg {
  width: 12px;
  height: 9px;
  overflow: visible;
  fill: none;
  stroke: var(--on);
  stroke-width: 2.1;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.check path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transition: stroke-dashoffset 0ms linear;
}
.ring {
  box-sizing: border-box;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border: 2.5px solid var(--live);
  background: var(--surface);
  opacity: 0;
  transform: scale(0.3);
}
.disc {
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--live);
}
.disc svg {
  width: 10.5px;
  height: 15.75px;
  fill: var(--on);
}

.step[data-state='todo'] .dot,
.step[data-state='done'] .check,
.step[data-state='current'] .ring {
  opacity: 1;
  transform: none;
}
.step[data-state='done'] .check,
.step[data-state='done'] .dot {
  transition-delay: var(--lag);
}
.step[data-state='done'] .check path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 300ms cubic-bezier(0.65, 0, 0.35, 1) calc(var(--lag) + 140ms);
}
/* The next round waits for the line to pour into it: its dot stays until the ring pops over it. */
.step[data-state='current'] .ring,
.step[data-state='current'] .dot {
  transition-delay: calc(var(--lead) + 440ms);
}

/* The stretch of line from a node to the next: from this node's edge to the column's end. */
.connector {
  position: absolute;
  top: 17px;
  left: 34px;
  right: 0;
  height: 3px;
  translate: 0 -50%;
  pointer-events: none;
}
.track,
.fill,
.flow {
  position: absolute;
  inset: 0;
}
.track {
  inset: 0.5px 0;
  background: repeating-linear-gradient(90deg, var(--todo-line) 0 8.5px, transparent 8.5px 11px);
}
.fill {
  background: var(--done);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 500ms cubic-bezier(0.65, 0, 0.35, 1);
}
.step[data-state='done'] .fill {
  transform: none;
  transition-delay: calc(var(--lag) + 60ms);
}
.flow {
  /* Slanted dashes, as if leaning forward: the stripes run at 45° to the line. */
  background: repeating-linear-gradient(135deg, var(--live) 0 5.66px, transparent 5.66px 7.78px);
  clip-path: inset(0 30% 0 0);
  opacity: 0;
  transition: opacity 160ms ease;
}
.step[data-state='current'] .flow {
  opacity: 1;
  transition: opacity 240ms ease calc(var(--lead) + 520ms);
}
.card[data-flowing] .step[data-state='current'] .flow {
  animation:
    rt-pour 2.6s cubic-bezier(0.33, 0, 0.2, 1) calc(var(--lead) + 520ms) infinite both,
    rt-march 0.6s linear infinite;
}
@keyframes rt-pour {
  0% {
    clip-path: inset(0 100% 0 0);
    opacity: 1;
  }
  72% {
    clip-path: inset(0 26% 0 0);
    opacity: 1;
  }
  90%,
  100% {
    clip-path: inset(0 26% 0 0);
    opacity: 0;
  }
}
@keyframes rt-march {
  to {
    background-position-x: 11px;
  }
}

/* The words. */
.text {
  display: grid;
  justify-items: start;
  min-width: 0;
  max-width: 100%;
  padding: 8px 6px 0 3px;
}
.label {
  font-size: 12px;
  font-weight: 700;
  line-height: 16px;
  text-transform: uppercase;
}
.title {
  margin-top: 3.5px;
  font-size: 14px;
  font-weight: 400;
  line-height: 20px;
}
.date {
  margin-top: 5.5px;
  font-size: 12px;
  line-height: 16px;
}
.status {
  display: grid;
  margin: 7.5px 0 0 -2px;
  min-height: 19px;
}
.pill {
  grid-area: 1 / 1;
  justify-self: start;
  padding: 0 10px;
  border-radius: 999px;
  color: var(--on);
  font-size: 12px;
  line-height: 19px;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(3px) scale(0.94);
  transform-origin: left center;
  transition:
    transform 260ms cubic-bezier(0.34, 1.4, 0.64, 1),
    opacity 160ms ease;
}
.pill[data-tone='done'] {
  background: var(--done);
}
.pill[data-tone='current'] {
  background: var(--live);
}
.step[data-state='done'] .pill[data-tone='done'],
.step[data-state='current'] .pill[data-tone='current'] {
  opacity: 1;
  transform: none;
}
.step[data-state='done'] .pill[data-tone='done'] {
  transition-delay: calc(var(--lag) + 100ms);
}
.step[data-state='current'] .pill[data-tone='current'] {
  transition-delay: calc(var(--lead) + 480ms);
}

/* A column: the node on the left, the words beside it, and the line running down between nodes. */
.card[data-layout='vertical'] {
  padding: 22px 16px 22px 20px;
}
.card[data-layout='vertical'] .steps {
  grid-auto-flow: row;
}
.card[data-layout='vertical'] .step:not([data-last]) {
  padding-bottom: 18px;
}
.card[data-layout='vertical'] .hit {
  grid-template-columns: 34px minmax(0, 1fr);
  column-gap: 12px;
  align-items: start;
}
.card[data-layout='vertical'] .text {
  padding: 0;
}
.card[data-layout='vertical'] .title {
  margin-top: 3px;
}
.card[data-layout='vertical'] .status {
  margin-top: 8px;
}
.card[data-layout='vertical'] .connector {
  top: 34px;
  bottom: 0;
  left: 17px;
  right: auto;
  width: 3px;
  height: auto;
  translate: -50% 0;
}
.card[data-layout='vertical'] .track {
  inset: 0 0.5px;
  background: repeating-linear-gradient(180deg, var(--todo-line) 0 8.5px, transparent 8.5px 11px);
}
.card[data-layout='vertical'] .fill {
  transform: scaleY(0);
  transform-origin: top;
}
.card[data-layout='vertical'] .step[data-state='done'] .fill {
  transform: none;
}
.card[data-layout='vertical'] .flow {
  inset: 0 0.3px;
  background: repeating-linear-gradient(180deg, var(--live) 0 8px, transparent 8px 11px);
  clip-path: inset(0 0 30% 0);
}
.card[data-layout='vertical'][data-flowing] .step[data-state='current'] .flow {
  animation:
    rt-pour-down 2.6s cubic-bezier(0.33, 0, 0.2, 1) calc(var(--lead) + 520ms) infinite both,
    rt-march-down 0.6s linear infinite;
}
@keyframes rt-pour-down {
  0% {
    clip-path: inset(0 0 100% 0);
    opacity: 1;
  }
  72% {
    clip-path: inset(0 0 26% 0);
    opacity: 1;
  }
  90%,
  100% {
    clip-path: inset(0 0 26% 0);
    opacity: 0;
  }
}
@keyframes rt-march-down {
  to {
    background-position-y: 11px;
  }
}

/* Words for screen readers only: each round's state, and what a pick did. */
.hidden,
.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .dot,
  .check,
  .ring,
  .pill,
  .fill,
  .flow,
  .check path {
    transition-duration: 1ms !important;
    transition-delay: 0ms !important;
  }
  .card[data-flowing] .step[data-state='current'] .flow,
  .card[data-layout='vertical'][data-flowing] .step[data-state='current'] .flow {
    animation: none;
  }
}
```

### round-tracker/use-autoplay.ts

```tsx
'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]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, playing: visible && !reduced && !taken };
}
```