# Task rows

> An agent’s tasks as they run, a row each: waiting, running, failed and retrying, or done, and the sub-steps behind them.

The status of an agent’s run at a glance. Each task is a row: a check once it is done, a cross once it fails, its number in a turning ring while it runs. Then what it works on, a pill for the outcome, and a chevron that opens the row onto its sub-steps and their figures. Show the rows as capsules or as one list, with the switcher under them. On screen the run plays once: a task fails, retries, and completes.

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

Category: AI.

## Anatomy

1. **Row**: A capsule each, 8 px apart, or a line of one card in the list layout. A row with sub-steps is a button that opens it.
2. **Status icon**: A green check, a red cross, or the task’s number in a ring whose arc turns while it runs. A new status pops in.
3. **Title**: The task in a few words, on one line; it ellipsizes before anything else gives way.
4. **Count**: What the task works on, counted, in tabular figures. In a card under 400 px it hides, kept for screen readers.
5. **Status pill**: Only once a task is done: Completed in green, or Failed in red with a turning retry arrow.
6. **Chevron**: Turns half a turn, 300 ms, as the row opens. Rows without sub-steps have none and don’t open.
7. **Sub-steps**: A hairline under the icon and the steps beside it, each with its figure in monospace. They rise in 100 ms apart.
8. **Layout switcher**: Capsules or List, under the rows. Switching keeps what is open, and the rows rise in again 80 ms apart.

## When to use

- An agent’s run of a few tasks, shown as it happens: what is done, what is running, what failed and is retrying.
- Three to seven tasks, each a short title with a count of what it works on: `12 suppliers`, `7 SKUs`.
- Tasks worth opening: a row folds out onto its sub-steps, each with a figure, so the detail is there without crowding the run.
- A side panel or a chat turn, where the capsules read as a light checklist and the list as one compact card.

## When not to use

- A single long job with a percentage. Use a progress bar, or the agent progress card.
- Dozens of tasks, or tasks to sort and filter. Use a table with a status column.
- Tasks the reader is meant to tick off by hand. Use a checklist with real checkboxes.
- The agent’s reasoning or its tool calls. Use the thinking trace or the tool chips: these rows are outcomes, not steps of thought.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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 { TaskRows } from './task-rows';

export function RunStatus({ run }: { run: AgentRun }) {
  return (
    <TaskRows
      tasks={run.tasks}
      defaultLayout="list"
      autoplay={false}
    />
  );
}
```

## States

- **Running, open**: The run as it starts: the running task open on its sub-steps. Its ring keeps turning: that motion can’t be stopped.
- **Folded**: Every row closed: one line a task, the chevrons pointing down.
- **Waiting**: A task not started yet: its number in a still ring, and no pill.
- **Failed, retrying**: A red cross and a Failed pill whose arrow turns while the agent tries again.
- **All completed**: The run done: three checks and three green pills.
- **List layout**: The same rows in one card, split by hairlines; an open row stays open across the switch.
- **Dark**: With `colorScheme="dark"`, or on a dark page: every colour is a `light-dark()` token.

## How it works

- A row opens by animating grid-template-rows between 0fr and 1fr, the content fading along: 300 ms, with no height ever measured. Folded sub-steps are inert, so focus never lands in them.
- As a row opens its sub-steps rise 8 px and fade in, 100 ms apart; its chevron turns over in 300 ms, and a capsule squares off from a 22 px radius to 14 px.
- The running ring is two SVG circles, a track and an arc covering 28% of it, turning once every 1.1 s. A waiting task shows the same ring, still and without the arc.
- A new status swaps the icon with a 300 ms pop (a 0.95 scale and a fade) and fades its pill in over 200 ms; a failed pill spins a retry arrow every 1.2 s.
- The layouts are one list of rows styled two ways: capsules 8 px apart, or one card split by hairlines. Switching keeps what is open, and the rows rise in again 80 ms apart.
- While on screen, the run plays once: the running task starts open and the last one waits; 1.4 s later the running task folds and the last one fails; 1.4 s after that it completes. Any input of the reader’s ends it at once.
- Every colour is a light-dark() token on the root. Shadows that differ between the schemes are written once with all their layers, the layers a scheme does not use being transparent: a long soft shadow by day, a short drop at night.

## API reference

- **tasks** (`Task[]`, default `a sample run`): The tasks, a row each, in order: { id, title, count?, status: pending | running | failed | completed, steps?: { label, value }[] }. A task without steps does not open.
- **layout** (`'capsules' | 'list'`): The layout, when the parent holds it; the switcher then hides.
- **defaultLayout** (`'capsules' | 'list'`, default `'capsules'`): The layout it starts with, when it holds its own.
- **onLayoutChange** (`(layout: TaskLayout) => void`): Called when the switcher picks a layout.
- **switcher** (`boolean`, default `true`): Shows the Capsules | List switcher under the rows.
- **defaultOpen** (`string[]`, default `[]`): The ids of the rows open at first.
- **onToggle** (`(id: string, open: boolean) => void`): Called when a row opens or folds.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, the run plays once: the running task starts open, and the last task fails, retries and completes. Without it, the tasks are as given from 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

- The rows are a list; a row with sub-steps is a button with aria-expanded and aria-controls, and its folded sub-steps are inert, so they are neither focused nor read.
- Each row’s name carries its status in words (“Running”, “Failed, retrying”, “Completed”); the icons and the chevron are decorative (aria-hidden).
- Status changes are announced once through a polite live region, “Draft supplier emails: Completed.”
- The switcher is a labelled group of two buttons with aria-pressed. Focus shows a ring on every control.
- On narrow cards the counts are hidden from sight but still read.
- Under reduced motion the run never plays, rows open instantly, and the rings stand still.

## Source

### task-rows/task-rows.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { TASKS, type Task, type TaskStatus } from './data';
import { useAutoplay } from './use-autoplay';
import styles from './task-rows.module.css';

export type { Task, TaskStatus, TaskStep } from './data';
export type TaskLayout = 'capsules' | 'list';

/*
 * Task rows: an agent's tasks as they run, one row each. A row reads its status at a glance: a
 * green check once completed, a red cross once failed (its pill then spins a retry arrow), its
 * number in a turning ring while running, and in a still ring while it waits. Then the task,
 * what it works on ("12 suppliers"), a status pill, and a chevron: the row opens onto its
 * sub-steps, each with its figure in monospace.
 *
 * - Two layouts, switched under the rows: capsules (a pill each, 8 px apart, which square off a
 *   little while open) and a list (one card, rows split by hairlines). Switching keeps what is
 *   open; the rows rise in again, 80 ms apart.
 * - A row opens by animating grid-template-rows between 0fr and 1fr, the content fading along:
 *   no height is ever measured. Its sub-steps rise in 100 ms apart.
 * - A status change swaps the icon with a small pop and fades the new pill in; screen readers
 *   hear it through a polite live region.
 * - While on screen, the run plays once by itself: the running task starts open and the last one
 *   waits; 1.4 s later the running task folds and the last one fails and retries; 1.4 s after
 *   that it completes. Any input of the reader's ends it at once.
 * - 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.
 */

/** The run's beats, from the moment it is on screen: the failure, then the retry's success. */
const FAIL_AT = 1320;
const RETRY_AFTER = 1400;
/** The rows rise in this far apart. */
const STAGGER = 80;
/** The running ring: its radius, and the share of it the turning arc covers. */
const RING = 2 * Math.PI * 11;
const ARC = 0.28;

const WORD: Record<TaskStatus, string> = {
  pending: 'Pending',
  running: 'Running',
  failed: 'Failed',
  completed: 'Completed',
};

export function TaskRows({
  tasks = TASKS,
  layout: layoutProp,
  defaultLayout = 'capsules',
  onLayoutChange,
  switcher = true,
  defaultOpen = [],
  onToggle,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The tasks, a row each, in order; pending and running rows show their number. */
  tasks?: Task[];
  /** The layout, when the parent holds it; otherwise the switcher does. */
  layout?: TaskLayout;
  /** The layout it starts with, when it holds its own. */
  defaultLayout?: TaskLayout;
  /** Called when the switcher picks a layout. */
  onLayoutChange?: (layout: TaskLayout) => void;
  /** Shows the Capsules | List switcher under the rows. */
  switcher?: boolean;
  /** The ids of the rows open at first. */
  defaultOpen?: string[];
  /** Called when a row opens or folds. */
  onToggle?: (id: string, open: boolean) => void;
  /** While on screen and untouched, the run plays once: a failure, a retry, then success. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>();
  const id = useId();
  const [ownLayout, setOwnLayout] = useState(defaultLayout);
  const layout = layoutProp ?? ownLayout;
  const [open, setOpen] = useState<string[]>(defaultOpen);

  // The run the demo plays: the first running task with sub-steps starts open, and the last task,
  // if it ends completed, first waits, then fails. Stage 0 is that start, 1 the failure, 2 the
  // tasks as given.
  const opening = tasks.find((task) => task.status === 'running' && task.steps?.length)?.id;
  const last = tasks.at(-1);
  const retrying = tasks.length > 1 && last?.status === 'completed' ? last.id : undefined;
  const [stage, setStage] = useState(autoplay && (opening || retrying) ? 0 : 2);
  /** Set when the run is skipped, so that its jump to the end is not announced. */
  const quiet = useRef(false);

  useEffect(() => {
    if (stage >= 2) return;
    if (!autoplay || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      quiet.current = Boolean(retrying);
      setStage(2);
      return;
    }
    if (!playing) return;
    const timer = window.setTimeout(() => setStage(stage + 1), stage ? RETRY_AFTER : FAIL_AT);
    return () => window.clearTimeout(timer);
  }, [autoplay, playing, stage]);

  const statusOf = (task: Task): TaskStatus =>
    task.id === retrying && stage < 2 ? (stage ? 'failed' : 'pending') : task.status;
  const isOpen = (task: Task) => open.includes(task.id) || (stage === 0 && task.id === opening);

  // Status changes, said once to screen readers (those of the run as it plays, or of `tasks`).
  const statuses = tasks.map((task) => `${task.id}:${statusOf(task)}`).join(' ');
  const said = useRef(statuses);
  const [news, setNews] = useState('');
  useEffect(() => {
    if (said.current === statuses) return;
    const before = new Set(said.current.split(' '));
    said.current = statuses;
    if (quiet.current) {
      quiet.current = false;
      return;
    }
    const changed = tasks.filter((task) => !before.has(`${task.id}:${statusOf(task)}`));
    if (changed.length)
      setNews(changed.map((task) => `${task.title}: ${WORD[statusOf(task)]}.`).join(' '));
    // statusOf only reads what `statuses` already sums up.
  }, [statuses]);

  /** Any input of the reader's ends the run, leaving open what is open. */
  const interrupt = () => {
    take();
    if (stage >= 2) return;
    if (stage === 0 && opening)
      setOpen((rows) => (rows.includes(opening) ? rows : [...rows, opening]));
    quiet.current = Boolean(retrying);
    setStage(2);
  };

  const toggle = (task: Task) => {
    const next = !isOpen(task);
    setOpen((rows) => (next ? [...rows, task.id] : rows.filter((row) => row !== task.id)));
    onToggle?.(task.id, next);
  };

  const pick = (next: TaskLayout) => {
    if (next === layout) return;
    setOwnLayout(next);
    onLayoutChange?.(next);
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={interrupt}
      onKeyDownCapture={interrupt}
    >
      <div className={styles.stage}>
        {/* Keyed by the layout, so the rows rise in again when it changes. */}
        <ul key={layout} className={styles.tasks} data-layout={layout} data-part="tasks">
          {tasks.map((task, index) => (
            <Row
              key={task.id}
              task={task}
              number={index + 1}
              status={statusOf(task)}
              open={isOpen(task)}
              delay={index * STAGGER}
              stepsId={`${id}-${task.id}`}
              onToggle={() => toggle(task)}
            />
          ))}
        </ul>
      </div>
      {switcher && !layoutProp && (
        <div className={styles.switcher} role="group" aria-label="Layout" data-part="switcher">
          {(['capsules', 'list'] as const).map((option) => (
            <button
              key={option}
              type="button"
              className={styles.option}
              aria-pressed={layout === option}
              onClick={() => pick(option)}
            >
              {option === 'capsules' ? 'Capsules' : 'List'}
            </button>
          ))}
        </div>
      )}
      <p className={styles.srOnly} aria-live="polite">
        {news}
      </p>
    </div>
  );
}

/** One task: its row, and the sub-steps it opens onto. */
function Row({
  task,
  number,
  status,
  open,
  delay,
  stepsId,
  onToggle,
}: {
  task: Task;
  number: number;
  status: TaskStatus;
  open: boolean;
  delay: number;
  stepsId: string;
  onToggle: () => void;
}) {
  const steps = task.steps ?? [];
  // The pill fades in only when the status changes under the reader's eyes.
  const first = useRef(status);
  const fresh = status !== first.current;
  const done = status === 'completed' || status === 'failed';

  const content = (
    <>
      <span className={styles.slot} aria-hidden="true" data-part="status">
        <StatusIcon key={status} status={status} number={number} />
      </span>
      <span className={styles.title} data-part="title">
        {task.title}
      </span>
      {task.count && (
        <span className={styles.count} data-part="count">
          {task.count}
        </span>
      )}
      {done ? (
        <span
          key={status}
          className={styles.pill}
          data-part="pill"
          data-status={status}
          data-fresh={fresh || undefined}
        >
          {WORD[status]}
          {status === 'failed' && (
            <>
              <span className={styles.srOnly}>, retrying</span>
              <RetryIcon />
            </>
          )}
        </span>
      ) : (
        <span className={styles.srOnly}>{WORD[status]}</span>
      )}
      {steps.length > 0 && (
        <span className={styles.chevronSlot} aria-hidden="true" data-part="chevron">
          <svg
            className={styles.chevron}
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="2.2"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M6 9.5l6 6 6-6" />
          </svg>
        </span>
      )}
    </>
  );

  return (
    <li
      className={styles.task}
      data-part="task"
      data-task={task.id}
      data-open={open || undefined}
      style={{ '--delay': `${delay}ms` } as CSSProperties}
    >
      {steps.length ? (
        <button
          type="button"
          className={styles.row}
          data-part="row"
          aria-expanded={open}
          aria-controls={stepsId}
          onClick={onToggle}
        >
          {content}
        </button>
      ) : (
        <div className={styles.row} data-part="row">
          {content}
        </div>
      )}
      {steps.length > 0 && (
        <div className={styles.fold} inert={!open}>
          <div className={styles.clip}>
            <div className={styles.steps} id={stepsId} data-part="steps">
              <span className={styles.rail} aria-hidden="true" />
              <ul className={styles.stepList}>
                {steps.map((step, index) => (
                  <li
                    key={step.label}
                    className={styles.step}
                    data-part="step"
                    style={{ '--delay': `${120 + index * 100}ms` } as CSSProperties}
                  >
                    <span className={styles.stepLabel}>{step.label}</span>
                    <span className={styles.stepValue}>{step.value}</span>
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </div>
      )}
    </li>
  );
}

/* The icons, drawn on a 24-unit grid. */

function StatusIcon({ status, number }: { status: TaskStatus; number: number }) {
  if (status === 'completed' || status === 'failed')
    return (
      <span className={styles.badge} data-status={status}>
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="3.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d={status === 'completed' ? 'M5 12.5l4.5 4.5L19 7.5' : 'M7 7l10 10M17 7L7 17'} />
        </svg>
      </span>
    );
  return (
    <span className={styles.ring} data-status={status}>
      <svg viewBox="0 0 24 24" fill="none" strokeWidth="2">
        <circle className={styles.track} cx="12" cy="12" r="11" />
        {status === 'running' && (
          <circle
            className={styles.arc}
            cx="12"
            cy="12"
            r="11"
            strokeLinecap="round"
            strokeDasharray={`${RING * ARC} ${RING}`}
          />
        )}
      </svg>
      <span className={styles.number}>{number}</span>
    </span>
  );
}

function RetryIcon() {
  return (
    <span className={styles.retry} aria-hidden="true">
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="3"
        strokeLinecap="round"
        strokeLinejoin="round"
      >
        <path d="M20.5 12A8.5 8.5 0 1 1 18 6" />
        <path d="M20.5 3.5V9H15" />
      </svg>
    </span>
  );
}
```

### task-rows/task-rows.module.css

```css
/* Task rows (task-rows.tsx): a soft grey card, and in its middle a column 440 px wide at most:
   the rows, as capsules or as a list, and under them the Capsules | List switcher.

   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). Shadows that differ between the schemes are
   written once with every layer, the layers a scheme does not use being transparent.

   Motion. Rows rise in by a transform, 80 ms apart; a row opens by animating grid-template-rows
   between 0fr and 1fr, so nothing is measured; a new status pops its icon in and fades its pill
   in. Under reduced motion all of it is instant and the rings stand still. */

.root {
  /* The card, the rows on it, and a row under the pointer. */
  --canvas: oklch(96.1% 0.002 247.84);
  --surface: oklch(100% 0 0);
  --inset: oklch(97.9% 0.002 247.839);
  /* The ink in three strengths, and the hairlines. */
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  --line: oklch(94.6% 0.003 264.542);
  /* The switcher's track. */
  --field: oklch(96.1% 0.001 286.375);
  /* Completed and failed, and their pills' tints. */
  --green: oklch(60.3% 0.155 150.883);
  --red: oklch(62.1% 0.192 23.042);
  --green-tint: oklch(95.8% 0.017 159.118);
  --red-tint: oklch(95.6% 0.017 17.462);
  /* A row's edge and lift: a long soft shadow by day, a short drop at night. */
  --card-edge: oklch(94.6% 0.003 264.542);
  --card-day-1: #00000008;
  --card-day-2: #00000005;
  --card-day-3: #00000003;
  --card-night: transparent;
  /* The switcher's picked option: a halo by day, a drop at night. */
  --button-edge: oklch(91.2% 0.005 258.326);
  --button-halo: #0000000a;
  --button-drop: transparent;
  --focus: #387bff;
  --sans: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);
  --code: var(--mono, ui-monospace, 'SF Mono', monospace);
  --out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease: cubic-bezier(0.4, 0, 0.2, 1);

  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 272px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  container-type: inline-size;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
    --red: light-dark(oklch(62.1% 0.192 23.042), oklch(66.6% 0.18 21.433));
    --green-tint: light-dark(oklch(95.8% 0.017 159.118), oklch(70.5% 0.154 153.814 / 0.14));
    --red-tint: light-dark(oklch(95.6% 0.017 17.462), oklch(66.6% 0.18 21.433 / 0.14));
    --card-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
    --card-day-1: light-dark(#00000008, transparent);
    --card-day-2: light-dark(#00000005, transparent);
    --card-day-3: light-dark(#00000003, transparent);
    --card-night: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --button-edge: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --button-halo: light-dark(#0000000a, transparent);
    --button-drop: light-dark(transparent, oklch(0% 0 0 / 0.3));
  }
}

/* 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;
}

/* A bare reset, weaker than any class below. */
:where(.root) button {
  margin: 0;
  border: 0;
  padding: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  text-align: inherit;
  cursor: pointer;
}
:where(.root) ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

/* The column: 480 px at most, the rows 440 px in its middle. Its 24 px margins above and below
   keep it centred as before in the 272 px card, and keep open rows clear of the switcher. */
.stage {
  display: flex;
  justify-content: center;
  width: 100%;
  max-width: 480px;
  margin-block: 24px;
}
.tasks {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 440px;
}

/* Capsules: a pill each, 8 px apart; the column keeps the height of three of them and a little. */
.tasks[data-layout='capsules'] {
  min-height: 196px;
  gap: 8px;
}
.tasks[data-layout='capsules'] .task {
  border-radius: 22px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 1px 2px var(--card-night),
    0 2px 6px var(--card-night),
    0 18px 47px var(--card-day-1),
    0 7.5px 19px var(--card-day-2),
    0 4px 10.5px var(--card-day-2),
    0 2.3px 5.8px var(--card-day-3),
    0 1.2px 3.1px var(--card-day-3),
    0 0.5px 1.3px var(--card-day-3);
}
.tasks[data-layout='capsules'] .task[data-open] {
  border-radius: 14px;
}

/* List: one card, its rows split by hairlines. */
.tasks[data-layout='list'] {
  align-self: flex-start;
  overflow: hidden;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 1px 2px var(--card-night),
    0 2px 6px var(--card-night),
    0 18px 47px var(--card-day-1),
    0 7.5px 19px var(--card-day-2),
    0 4px 10.5px var(--card-day-2),
    0 2.3px 5.8px var(--card-day-3),
    0 1.2px 3.1px var(--card-day-3),
    0 0.5px 1.3px var(--card-day-3);
}
.tasks[data-layout='list'] .task:not(:last-child) {
  border-bottom: 1px solid var(--line);
}

/* A task: it rises in, 80 ms after the one above, and darkens a touch under the pointer. */
.task {
  overflow: hidden;
  transition:
    border-radius 300ms var(--ease),
    background-color 300ms var(--ease);
  animation: rise 450ms var(--out) var(--delay) both;
}
@media (hover: hover) {
  .task:hover {
    background-color: var(--inset);
  }
}

.row {
  display: flex;
  align-items: center;
  gap: 10px;
  box-sizing: border-box;
  width: 100%;
  height: 44px;
  padding: 0 10px;
}
.slot {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
}
.title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.count {
  flex-shrink: 0;
  color: var(--ink-2);
  font-size: 12.5px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Completed or failed: a pill in its colour's tint; failed spins a retry arrow. A pill that
   comes in with a new status fades in. */
.pill {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  gap: 6px;
  height: 22px;
  padding: 0 8px;
  border-radius: 999px;
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1.5;
  white-space: nowrap;
}
.pill[data-status='completed'] {
  background: var(--green-tint);
  color: var(--green);
}
.pill[data-status='failed'] {
  background: var(--red-tint);
  color: var(--red);
}
.pill[data-fresh] {
  animation: appear 200ms ease-out both;
}
.retry {
  display: flex;
  animation: spin 1.2s linear infinite;
}
.retry svg {
  width: 12px;
  height: 12px;
}

.chevronSlot {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  margin-left: -8px;
  border-radius: 999px;
  color: var(--ink-3);
}
.chevron {
  width: 15px;
  height: 15px;
  transition: transform 300ms var(--ease);
}
.task[data-open] .chevron {
  transform: rotate(180deg);
}

/* The status icon. Done: a filled disc, popping in. Waiting or running: the task's number in a
   ring, whose arc turns while it runs. */
.badge {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border-radius: 999px;
  color: #fff;
  animation: pop 300ms var(--out) both;
}
.badge[data-status='completed'] {
  background: var(--green);
}
.badge[data-status='failed'] {
  background: var(--red);
}
.badge svg {
  width: 13px;
  height: 13px;
}
.badge[data-status='failed'] svg {
  width: 12px;
  height: 12px;
}
.ring {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
}
.ring svg {
  position: absolute;
  inset: 0;
  width: 24px;
  height: 24px;
  overflow: visible;
}
.ring[data-status='running'] svg {
  animation: spin 1.1s linear infinite;
}
.track {
  stroke: var(--line);
}
.arc {
  stroke: var(--ink-3);
}
.number {
  position: relative;
  font-size: 10.5px;
  font-weight: 600;
  line-height: 1.5;
  font-variant-numeric: tabular-nums;
}

/* The fold: grid-template-rows from 0fr to 1fr, the content fading along. */
.fold {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows 300ms var(--out),
    opacity 300ms var(--out);
}
.task[data-open] .fold {
  grid-template-rows: 1fr;
  opacity: 1;
}
.clip {
  min-height: 0;
  overflow: hidden;
}

/* The sub-steps: a hairline under the icon, and the steps beside it, rising in as the row opens. */
.steps {
  display: grid;
  grid-template-columns: 24px 1fr;
  gap: 10px;
  margin-bottom: 10px;
  padding: 0 10px;
}
.rail {
  width: 1px;
  height: 100%;
  margin: 0 auto;
  background: var(--line);
}
.stepList {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}
.step {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.task[data-open] .step {
  animation: rise 300ms var(--out) var(--delay) both;
}
.stepLabel {
  min-width: 0;
  overflow: hidden;
  color: var(--ink-2);
  font-size: 12px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.stepValue {
  flex-shrink: 0;
  color: var(--ink-3);
  font-family: var(--code);
  font-size: 11.5px;
  line-height: 1.5;
  font-variant-numeric: tabular-nums;
}

/* The switcher: two options on a track, 10 px above the card's foot. */
.switcher {
  position: absolute;
  bottom: 10px;
  left: 50%;
  display: flex;
  padding: 2px;
  border-radius: 999px;
  background: var(--field);
  transform: translateX(-50%);
}
.option {
  padding: 2px 8px;
  border-radius: 999px;
  color: var(--ink-3);
  font-size: 11.5px;
  font-weight: 500;
  line-height: 1.5;
  transition:
    background-color 150ms var(--ease),
    color 150ms var(--ease),
    box-shadow 150ms var(--ease);
}
@media (hover: hover) {
  .option:hover {
    color: var(--ink-2);
  }
}
.option[aria-pressed='true'] {
  background: var(--surface);
  color: var(--ink);
  box-shadow:
    0 0 0 1px var(--button-edge),
    0 1px 2px var(--button-drop),
    0 0 4px var(--button-halo);
}
.option:focus-visible {
  outline-offset: 0;
}

/* Narrow cards: the counts give their room to the titles, and stay for screen readers. */
@container (max-width: 400px) {
  .count {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
  }
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}
@keyframes appear {
  from {
    opacity: 0;
  }
}
@keyframes spin {
  to {
    transform: rotate(1turn);
  }
}

@media (prefers-reduced-motion: reduce) {
  .root * {
    animation: none !important;
    transition: none !important;
  }
}
```

### task-rows/data.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * What the rows show: the tasks of an agent's run, each with its status and the sub-steps it opens
 * onto. The sample is an ice-cream shop's agent, getting its suppliers and its reorders in order.
 */

export type TaskStatus = 'pending' | 'running' | 'failed' | 'completed';

export type TaskStep = {
  /** What the step did: "Reading POS export". */
  label: string;
  /** Its figure, set in monospace on the right: "3 files", "68%", "draft". */
  value: string;
};

export type Task = {
  id: string;
  /** The task, in a few words. */
  title: string;
  /** What it works on, counted: "12 suppliers". */
  count?: string;
  status: TaskStatus;
  /** The sub-steps the row opens onto; without them the row does not open. */
  steps?: TaskStep[];
};

export const TASKS: Task[] = [
  {
    id: 'vendors',
    title: 'Verified vendor records',
    count: '12 suppliers',
    status: 'completed',
    steps: [
      { label: 'Matched tax and contact IDs', value: '12/12' },
      { label: 'Flagged stale records', value: '0' },
    ],
  },
  {
    id: 'reorder',
    title: 'Build reorder task list',
    count: '7 SKUs',
    status: 'running',
    steps: [
      { label: 'Reading POS export', value: '3 files' },
      { label: 'Scoring stockout risk', value: '68%' },
    ],
  },
  {
    id: 'emails',
    title: 'Draft supplier emails',
    count: '2 messages',
    status: 'completed',
    steps: [
      { label: 'Cone supplier follow-up', value: 'draft' },
      { label: 'Pistachio reorder note', value: 'draft' },
    ],
  },
];
```

### task-rows/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.
 */
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, playing: visible && !reduced && !taken };
}
```