# Task Rows

> Live agent task status as expandable capsules or a list.

- **Slug:** `task-rows`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/TaskRows.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/task-rows
- **All files in one bundle:** https://viewership.ai/ui/components/task-rows.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/task-rows.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/task-rows/`:

   - `TaskRows.astro` (raw: https://viewership.ai/ui/raw/task-rows/TaskRows.astro.txt)

npm packages: none.

## Usage

```astro
---
import TaskRows from "../components/viewership/task-rows/TaskRows.astro";
---

<TaskRows />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import TaskRows from "./TaskRows.astro";
import type { TaskRow } from "./TaskRows.astro";

interface Props {
  variant?: string;
}
const { variant = "Capsules" } = Astro.props;

const rows: TaskRow[] = [
  {
    key: "verify",
    label: "Pulled GA4 and Ads data",
    amount: "6 sources",
    status: "done",
    details: [
      { label: "Matched campaigns to sessions", meta: "6/6" },
      { label: "Flagged missing UTM tags", meta: "0" },
    ],
  },
  {
    key: "citations",
    label: "Check AI search citations",
    amount: "5 engines",
    status: "running",
    step: 2,
    details: [
      { label: "Reading prompt tracker export", meta: "3 files" },
      { label: "Scoring share of voice", meta: "68%" },
    ],
  },
  {
    key: "draft",
    label: "Draft client updates",
    amount: "2 messages",
    status: "sequence",
    step: 3,
    details: [
      { label: "Northwind weekly report email", meta: "draft" },
      { label: "Spring launch approval note", meta: "draft" },
    ],
  },
];
---

<TaskRows rows={rows} variant={variant === "List" ? "list" : "capsules"} />
```

## Variants

Demo variants: `Capsules`, `List`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rows` | `{ key?: string; label: string; amount?: string; status: "done" \| "running" \| "pending" \| "failed" \| "sequence"; step?: number; details?: { label: string; meta: string }[] }[]` |  | Tasks. "sequence" plays pending, failed (retrying), completed. `step` is the number in the spinner. |
| `variant` | `"capsules" \| "list"` | `"capsules"` | Capsules, or one card with a divided list. |
| `labels` | `Partial<{ completed; failed; running; pending; retrying }>` |  | Pill text and screen-reader status. |
| `autoplay` | `boolean` | `true` | Play in view. When false or under reduced motion, the settled state renders. |
| `loop` | `boolean` | `false` | Replay after a hold. Stops once a row is toggled. |

## Source

### `src/components/viewership/task-rows/TaskRows.astro`

```astro
---
/**
 * TaskRows: live agent task status as expandable rows.
 *
 * Each row has a status badge, a label, an amount and a status pill. Click a
 * row to show its steps. Layouts: separate capsules, or one divided card.
 *
 * Statuses: "done", "running", "pending", "failed", and "sequence", which
 * plays pending, failed (retrying), then completed when the rows scroll into
 * view. `loop` replays it. Fires `vs-task-rows:toggle-row` (detail { key,
 * open }) on the root.
 *
 * Ported from Beautiful UI `components/primitives/TaskRows.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * @example
 * <TaskRows rows={[{ label: "Pull GA4 data", amount: "3 properties", status: "running", step: 2 }]} />
 */
import type { HTMLAttributes } from "astro/types";

export interface TaskDetail {
  label: string;
  meta: string;
}
export interface TaskRow {
  /** Stable id, reported in events. Defaults to the row index. */
  key?: string;
  label: string;
  /** Muted figure beside the label, e.g. "12 suppliers". */
  amount?: string;
  status: "done" | "running" | "pending" | "failed" | "sequence";
  /** Number shown inside the spinner ring. Defaults to the row position. */
  step?: number;
  details?: TaskDetail[];
}
export interface TaskRowsLabels {
  completed: string;
  failed: string;
  /** Screen-reader status for running and pending rows (they have no pill). */
  running: string;
  pending: string;
  retrying: string;
}

type Props = HTMLAttributes<"div"> & {
  rows: TaskRow[];
  variant?: "capsules" | "list";
  labels?: Partial<TaskRowsLabels>;
  /** Play the status run when scrolled into view. */
  autoplay?: boolean;
  /** Replay after a hold, never while someone is using it. */
  loop?: boolean;
};

const { rows = [], variant = "capsules", labels, autoplay = true, loop = false, class: className, ...rest } = Astro.props;

const l: TaskRowsLabels = {
  completed: "Completed",
  failed: "Failed",
  running: "In progress",
  pending: "Queued",
  retrying: "retrying",
  ...labels,
};

const uid = `vs-task-${Math.random().toString(36).slice(2, 8)}`;
const R = 11; // (24 - stroke 2) / 2
const C = 2 * Math.PI * R;
---

<div
  class:list={["vs-task-rows", `vs-task-rows--${variant}`, className]}
  data-autoplay={autoplay ? "" : undefined}
  data-loop={loop ? "" : undefined}
  data-labels={JSON.stringify({ completed: l.completed, failed: l.failed, retrying: l.retrying })}
  {...rest}
>
  {rows.map((row, i) => {
    const phase = row.status === "sequence" ? "done" : row.status;
    const step = row.step ?? i + 1;
    const details = row.details ?? [];
    return (
      <div
        class="task"
        style={`--vs-tr-i: ${i}`}
        data-task={row.key ?? String(i)}
        data-phase={phase}
        data-sequence={row.status === "sequence" ? "" : undefined}
        data-running={row.status === "running" ? "" : undefined}
      >
        <button
          type="button"
          class="task-row"
          aria-expanded="false"
          aria-controls={details.length ? `${uid}-${i}` : undefined}
          disabled={!details.length}
          data-task-toggle
        >
          <span class="badge-slot" aria-hidden="true">
            {(row.status === "running" || row.status === "pending" || row.status === "sequence") && (
              <span class:list={["ring", row.status === "running" && "is-active"]}>
                <svg width="24" height="24" viewBox="0 0 24 24">
                  <circle class="ring-track" cx="12" cy="12" r={R} fill="none" stroke-width="2"></circle>
                  {row.status === "running" && (
                    <circle
                      class="ring-arc"
                      cx="12"
                      cy="12"
                      r={R}
                      fill="none"
                      stroke-width="2"
                      stroke-linecap="round"
                      stroke-dasharray={`${C * 0.28} ${C * 0.72}`}
                    ></circle>
                  )}
                </svg>
                <span class="ring-step">{step}</span>
              </span>
            )}
            {(row.status === "failed" || row.status === "sequence") && (
              <span class="badge badge--red">
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round">
                  <path d="M18 6L6 18M6 6l12 12"></path>
                </svg>
              </span>
            )}
            {(row.status === "done" || row.status === "sequence") && (
              <span class="badge badge--green">
                <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
                  <path d="M20 6L9 17l-5-5"></path>
                </svg>
              </span>
            )}
          </span>
          <span class="task-label">{row.label}</span>
          {row.status === "running" && <span class="visually-hidden">, {l.running}</span>}
          {row.status === "pending" && <span class="visually-hidden">, {l.pending}</span>}
          {row.status === "sequence" && <span class="visually-hidden sr-pending">, {l.pending}</span>}
          {row.amount && <span class="task-amount">{row.amount}</span>}
          {(row.status === "failed" || row.status === "sequence") && (
            <span class="pill pill--red">
              {l.failed}
              {row.status === "sequence" && <span class="visually-hidden">, {l.retrying}</span>}
              <svg class="retry" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
                <path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"></path>
              </svg>
            </span>
          )}
          {(row.status === "done" || row.status === "sequence") && <span class="pill pill--green">{l.completed}</span>}
          <span class="chevron" aria-hidden="true">
            {details.length > 0 && (
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
                <path d="M6 9l6 6 6-6"></path>
              </svg>
            )}
          </span>
        </button>

        {details.length > 0 && (
          <div class="detail" id={`${uid}-${i}`}>
            <div class="detail-clip" data-detail-clip>
              <div class="detail-grid">
                <span class="detail-line" aria-hidden="true"></span>
                <div class="detail-items">
                  {details.map((d, j) => (
                    <div class="detail-item" style={`--vs-tr-j: ${j}`}>
                      <span class="detail-label">{d.label}</span>
                      <span class="detail-meta">{d.meta}</span>
                    </div>
                  ))}
                </div>
              </div>
            </div>
          </div>
        )}
      </div>
    );
  })}
  <span class="visually-hidden" role="status" data-live></span>
</div>

<style>
  .vs-task-rows {
    display: flex;
    width: 100%;
    max-width: 440px;
    flex-direction: column;
  }
  .vs-task-rows--capsules {
    min-height: 196px;
    gap: 8px;
  }
  .vs-task-rows--list {
    align-self: flex-start;
    overflow: hidden;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }

  /* ── row shell ── */
  .task {
    align-self: stretch;
    overflow: hidden;
    transition:
      border-radius 300ms ease-out,
      background-color 300ms ease-out;
  }
  .task:hover {
    background: var(--vs-inset);
  }
  .vs-task-rows--capsules .task {
    border-radius: 22px;
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
  }
  .vs-task-rows--capsules .task:hover {
    background: var(--vs-inset);
  }
  .vs-task-rows--capsules .task[data-open] {
    border-radius: 14px;
  }
  .vs-task-rows--list .task {
    border-bottom: 1px solid var(--vs-line);
  }
  .vs-task-rows--list .task:last-of-type {
    border-bottom: 0;
  }
  /* rows wait offscreen, then enter staggered when the run starts */
  [data-idle] .task {
    opacity: 0;
  }
  [data-run] .task {
    animation: vs-fade-up 450ms var(--vs-ease-out) calc(var(--vs-tr-i) * 80ms) both;
  }

  .task-row {
    display: flex;
    width: 100%;
    height: 44px;
    align-items: center;
    gap: 10px;
    padding: 0 10px;
    border: none;
    background: transparent;
    color: inherit;
    font: inherit;
    border-radius: inherit;
    text-align: left;
    cursor: pointer;
  }
  .task-row:disabled {
    cursor: default;
  }
  .task-row:focus-visible {
    outline: none;
    box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-accent) 70%, transparent);
  }

  .badge-slot {
    display: flex;
    width: 24px;
    height: 24px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
  }
  .task-label {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    color: var(--vs-ink);
    font-size: 13px;
    font-weight: 500;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .task-amount {
    color: var(--vs-ink-2);
    font-size: 12.5px;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  /* spinner ring */
  .ring {
    position: relative;
    display: inline-flex;
    width: 24px;
    height: 24px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
  }
  .ring svg {
    position: absolute;
    inset: 0;
  }
  .ring.is-active svg {
    animation: vs-spin 1.1s linear infinite;
  }
  .ring-track {
    stroke: var(--vs-line);
  }
  .ring-arc {
    stroke: var(--vs-ink-3);
  }
  .ring-step {
    position: relative;
    color: var(--vs-ink);
    font-size: 10.5px;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
  }

  .badge {
    display: flex;
    width: 22px;
    height: 22px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    color: var(--vs-surface);
    animation: vs-pop-in 300ms var(--vs-ease-out) both;
  }
  .badge--red {
    background: var(--vs-red);
  }
  .badge--green {
    background: var(--vs-green);
  }

  .pill {
    display: inline-flex;
    height: 22px;
    flex-shrink: 0;
    align-items: center;
    gap: 6px;
    padding: 0 8px;
    border-radius: var(--vs-radius-pill);
    font-size: 11.5px;
    font-weight: 500;
    white-space: nowrap;
  }
  .pill--green {
    background: var(--vs-green-tint);
    color: var(--vs-green);
  }
  .pill--red {
    background: var(--vs-red-tint);
    color: var(--vs-red);
  }
  [data-sequence] .pill {
    animation: vs-fade-in 200ms ease-out both;
  }
  .retry {
    display: none;
  }
  [data-sequence] .retry {
    display: block;
    animation: vs-spin 1.2s linear infinite;
  }

  /* each phase shows only its own badge, pill and screen-reader status */
  .task:not([data-phase="pending"]):not([data-phase="running"]) .ring,
  .task:not([data-phase="pending"]) .sr-pending,
  .task:not([data-phase="failed"]) .badge--red,
  .task:not([data-phase="failed"]) .pill--red,
  .task:not([data-phase="done"]) .badge--green,
  .task:not([data-phase="done"]) .pill--green {
    display: none;
  }

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

  /* ── dropdown detail ── */
  .detail {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    transition:
      grid-template-rows 300ms var(--vs-ease-out),
      opacity 300ms var(--vs-ease-out);
  }
  [data-open] .detail {
    grid-template-rows: 1fr;
    opacity: 1;
  }
  .detail-clip {
    min-height: 0;
    overflow: hidden;
  }
  .detail-grid {
    display: grid;
    grid-template-columns: 24px 1fr;
    gap: 10px;
    margin-bottom: 10px;
    padding: 0 10px;
  }
  .detail-line {
    width: 1px;
    height: 100%;
    margin: 0 auto;
    background: var(--vs-line);
  }
  .detail-items {
    display: flex;
    flex-direction: column;
    gap: 6px;
  }
  .detail-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
  }
  [data-open] .detail-item {
    animation: vs-fade-up 300ms var(--vs-ease-out) calc(120ms + var(--vs-tr-j) * 100ms) both;
  }
  .detail-label {
    color: var(--vs-ink-2);
    font-size: 12px;
  }
  .detail-meta {
    color: var(--vs-ink-3);
    font-family: var(--vs-font-sans);
    font-size: 11.5px;
    font-variant-numeric: tabular-nums;
  }

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

  /* offscreen: hold the spinners */
  [data-paused] .ring svg,
  [data-paused] .retry {
    animation-play-state: paused;
  }

  /* hide the settled rows for the instant before the script rewinds them */
  @media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
    .vs-task-rows[data-autoplay]:not([data-vs-ready]) {
      visibility: hidden;
    }
  }
  /* without script the rows can't toggle, so show their steps */
  @media (scripting: none) {
    .detail {
      grid-template-rows: 1fr;
      opacity: 1;
    }
    .chevron svg {
      display: none;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [data-run] .task,
    .badge,
    [data-sequence] .pill,
    [data-open] .detail-item {
      animation: none;
    }
    .ring.is-active svg,
    [data-sequence] .retry {
      animation: none;
    }
  }
</style>

<script>
  /* rows enter → ring sweeps → running row opens → it closes and sequence rows fail → they resolve */
  const TICKS = [600, 900, 2400, 1400, 2400];
  const FINAL = TICKS.length;
  const LOOP_HOLD = 4000;

  /** One pending timeout that can pause and resume where it left off. */
  function createTimer() {
    let id: number | undefined;
    let fn: (() => void) | undefined;
    let left = 0;
    let due = 0;
    let paused = true;
    const arm = () => {
      due = performance.now() + left;
      id = window.setTimeout(() => {
        const run = fn;
        id = fn = undefined;
        run?.();
      }, left);
    };
    return {
      set(ms: number, next: () => void) {
        window.clearTimeout(id);
        id = undefined;
        fn = next;
        left = ms;
        if (!paused) arm();
      },
      pause() {
        if (paused) return;
        paused = true;
        if (id === undefined) return;
        window.clearTimeout(id);
        id = undefined;
        left = Math.max(0, due - performance.now());
      },
      resume() {
        if (!paused) return;
        paused = false;
        if (fn) arm();
      },
      clear() {
        window.clearTimeout(id);
        id = fn = undefined;
      },
    };
  }

  function init(root: HTMLElement) {
    const tasks = [...root.querySelectorAll<HTMLElement>("[data-task]")];
    const sequence = tasks.filter((t) => t.hasAttribute("data-sequence"));
    const autoOpen = tasks.find((t) => t.hasAttribute("data-running") && t.querySelector("[data-detail-clip]"));
    const live = root.querySelector<HTMLElement>("[data-live]")!;
    const labels = JSON.parse(root.dataset.labels || "{}") as { completed?: string; failed?: string; retrying?: string };
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const loop = root.hasAttribute("data-loop");
    const timer = createTimer();
    const manual = new Map<HTMLElement, boolean>();
    let tick = FINAL;
    let firstRun = true;
    let touched = false;

    const render = () => {
      const phase = tick < 3 ? "pending" : tick === 3 ? "failed" : "done";
      sequence.forEach((task) => {
        if (task.dataset.phase === phase) return;
        task.dataset.phase = phase;
        if (firstRun && phase !== "pending") {
          const name = task.querySelector(".task-label")?.textContent ?? "";
          live.textContent =
            phase === "failed" ? `${name}: ${labels.failed}, ${labels.retrying}` : `${name}: ${labels.completed}`;
        }
      });
      tasks.forEach((task) => {
        const open = manual.get(task) ?? (task === autoOpen && tick === 2);
        task.toggleAttribute("data-open", open);
        const button = task.querySelector("[data-task-toggle]")!;
        button.setAttribute("aria-expanded", String(open));
        const clip = task.querySelector<HTMLElement>("[data-detail-clip]");
        if (clip) clip.inert = !open;
      });
    };

    tasks.forEach((task) => {
      const button = task.querySelector<HTMLButtonElement>("[data-task-toggle]")!;
      button.addEventListener("click", () => {
        const open = !task.hasAttribute("data-open");
        touched = true;
        manual.set(task, open);
        render();
        root.dispatchEvent(new CustomEvent("vs-task-rows:toggle-row", { bubbles: true, detail: { key: task.dataset.task, open } }));
      });
    });

    const step = () => {
      tick += 1;
      render();
      if (tick < FINAL) timer.set(TICKS[tick]!, step);
      else {
        firstRun = false;
        if (loop) timer.set(LOOP_HOLD, restart);
      }
    };

    const start = () => {
      tick = 0;
      manual.clear();
      root.removeAttribute("data-run");
      void root.offsetWidth; // restart the entrance animation
      root.setAttribute("data-run", "");
      root.removeAttribute("data-idle");
      render();
      timer.set(TICKS[0]!, step);
    };

    function restart() {
      if (touched) return;
      if (root.matches(":hover") || root.contains(document.activeElement)) return timer.set(LOOP_HOLD, restart);
      start();
    }

    if (reduced || !root.hasAttribute("data-autoplay")) {
      render();
      return;
    }

    /* hold the rows back until the run is seen, then play it */
    tick = 0;
    root.setAttribute("data-idle", "");
    render();
    let started = false;
    const io = new IntersectionObserver(([entry]) => {
      if (!root.isConnected) return teardown();
      if (entry?.isIntersecting) {
        root.removeAttribute("data-paused");
        if (!started) {
          started = true;
          timer.resume();
          start();
        } else timer.resume();
      } else {
        root.setAttribute("data-paused", "");
        timer.pause();
      }
    }, { threshold: 0.2 });
    io.observe(root);

    function teardown() {
      timer.clear();
      io.disconnect();
    }
    document.addEventListener("astro:before-swap", teardown, { once: true });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-task-rows:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>
```
