# Tool chips

> An agent’s turn folded into compact rows of tool calls, and the files it changed as chips that open their diff.

What an agent did in one turn, in a few quiet lines. A header counts the tool calls and opens onto a row for each: a glyph, a few words and a chip with the file, the command or the first words of a thought. Open a row for the lines behind it. Under a hairline, the files the turn changed count their lines added and removed, and open an excerpt of their diff. On screen the turn plays in a row at a time, then the file chips pop in.

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

Category: AI.

## Anatomy

1. **Group header**: Counts the tool calls and messages, or says its own summary. Folds the whole turn, its chevron turning with it.
2. **Tool-call row**: One step of the run, 28 px tall. A row with lines to show is a button that opens onto them.
3. **Glyph**: What kind of step: a thought, a write, a command or a read. On hover, and while the row is open, a chevron takes its place.
4. **Label**: What the step did, in a few words; it never wraps.
5. **Chip**: The file, the command or the first words of a thought, in monospace for code. It takes the rest of the row and ends in an ellipsis.
6. **Details**: The lines a row opens onto, on a thread from its glyph: prose, output, or added lines in green. It folds open by animating its grid rows, never a measured height.
7. **File chip**: A changed file and its counts. A click opens an excerpt of its diff in a popover, under the chip or above it when there is no room.
8. **More**: Past the first few files the rest wait behind `+N more`, which turns into Show less.

## When to use

- Showing what an agent did in one turn (thoughts, edits, commands, reads) without burying its answer.
- A chat or a side panel where the steps matter to some readers and not to others: folded by default, one click away.
- Turns that change files, where a chip per file with its `+added −removed` counts says how big the change was.
- A quick look at a change before opening it: a file chip shows a short excerpt of its diff in a popover.

## When not to use

- A run still in progress, step by step. Use a live progress list that shows the current step.
- Reviewing a whole change. The popover is an excerpt; link to a real diff view instead.
- One or two steps that are the answer itself. Say them in the message rather than folding them away.
- A log of many turns or hundreds of calls. Use a searchable log or a timeline.

## Installation

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

```sh
npx shadcn@latest add https://brunodelorence.com/r/tool-chips.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 { ToolChips } from './tool-chips';

export function Turn({ turn }: { turn: AgentTurn }) {
  return (
    <ToolChips
      calls={turn.calls}
      files={turn.files}
      messages={turn.messages.length}
      autoplay={false}
    />
  );
}
```

## States

- **Folded**: Only the header: the chevron points right and the turn is out of the way.
- **Open**: The turn as it first shows once played in: every row folded, three files and `+2 more`.
- **Row hover**: The row lights up and its glyph gives way to a chevron, pointing right.
- **Thought open**: A thinking row opened onto its reasoning, in prose.
- **Edit and output open**: Added lines in green monospace, and a command’s output in monospace. Rows open independently.
- **All files**: Every file chip shown, and the link reads Show less.
- **Short turn**: One call, no messages and two files: the header says “1 tool call” and no `+N more` is needed.

## How it works

- The group and every row fold by animating grid-template-rows between 0fr and 1fr, the content fading along: 300 ms, with no height ever measured. Folded content is inert, so focus never lands in it.
- On a row, the glyph fades out for a chevron on hover (100 and 150 ms); once the row is open the chevron stays, turned down.
- A file chip opens its diff in a popover, through the Popover API: it lives in the top layer, so no card or scroller clips it, and a click outside or Escape closes it with no script of its own. It is placed under the chip as it opens (or above it when the window has no room below), kept inside the window, and follows the chip while the page scrolls.
- The popover pops in from the chip’s corner in 160 ms, a 0.95 scale and a fade, and closes at once.
- While on screen, the turn plays in once: a row every 700 ms, each rising 8 px as it fades in, then the file chips pop in 80 ms apart and the “+2 more” link fades in after them.
- After that a demo pointer opens two rows, a diff, and folds the group and opens it again, hovers included; any input of the reader’s hands it over and brings the whole turn in 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 single deep one at night.

## API reference

- **calls** (`ToolCall[]`, default `a sample turn`): The turn’s tool calls, a row each: { id, icon: thinking | write | command | read, label, chip, code?, details?, tone?: text | code | added }.
- **files** (`FileChange[]`, default `a sample turn`): The files it changed, a chip each: { path, added, removed?, lines }, the lines being the excerpt of the diff its popover shows.
- **summary** (`string`): The group’s header. By default it counts the tool calls and the messages.
- **messages** (`number`, default `2`): How many messages the header counts; 0 leaves them out.
- **visibleFiles** (`number`, default `3`): How many file chips show before a “+N more” link brings in the rest.
- **defaultOpen** (`boolean`, default `true`): Whether the group starts open.
- **defaultOpenRows** (`string[]`): The rows (by call id) that start open. Setting it turns the play-in and the demo off.
- **defaultAllFiles** (`boolean`): Whether every file chip starts shown, past “+N more”. Setting it turns the play-in and the demo off.
- **onOpenFile** (`(path: string | null) => void`): Called when a file’s diff opens (with its path) or closes (null).
- **autoplay** (`boolean`, default `true`): While on screen and untouched, the turn plays in a row at a time, then a demo pointer opens rows and a diff. Without it, the turn is all there 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 header and each row are buttons with aria-expanded and aria-controls; folded content is inert, so it is neither focused nor read.
- Each file chip is a button named “Show diff for …” that opens its popover (role dialog, named after the file); Escape or a click outside closes it and focus stays on the chip.
- In a diff, added and removed lines say so to screen readers; the + and − signs are hidden from them.
- Glyphs and chevrons are decorative (aria-hidden). Focus shows a ring on every control.
- Under reduced motion the turn is all there at once, folds and popovers open instantly and the demo never plays.

## Source

### tool-chips/tool-chips.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type ToggleEvent,
} from 'react';
import { CALLS, FILES, type FileChange, type ToolCall, type ToolIcon } from './data';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './tool-chips.module.css';

export type { DiffLine, FileChange, ToolCall, ToolIcon } from './data';

/*
 * Tool chips: what an agent did in one turn, folded into a few compact lines. A group header
 * ("4 tool calls, 2 messages") opens onto one row per tool call, each a glyph, a few words and a
 * chip (the file, the command, or the first words of a thought), and under them the files the turn
 * changed, as chips that count the lines added and removed.
 *
 * - The group and each row fold by animating grid-template-rows between 0fr and 1fr, with the
 *   content fading along: no height is ever measured.
 * - On a row, the glyph gives way to a chevron on hover, which turns down once the row is open.
 * - A file chip opens its diff in a popover (the Popover API): it sits in the top layer, so no card
 *   or scroller clips it, and a click outside or Escape closes it. It is placed under the chip as
 *   it opens, or above it when the window has no room below, and follows the chip as the page
 *   scrolls.
 * - Past the first few files, the rest wait behind a "+2 more" link.
 * - While on screen and untouched, the turn plays in: one row every 700 ms, then the file chips pop
 *   in one after another. After that, a demo pointer opens rows, a diff and the group by itself.
 * - 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 turn plays in a row at a time, every BEAT ms; then the file chips. */
const BEAT = 700;
/** The file chips pop in one after the other, STAGGER ms apart. */
const STAGGER = 80;
/** A diff's popover: its width, and its gap to the chip. */
const POPOVER_WIDTH = 288;
const POPOVER_GAP = 6;
/** Its height, from its line count: the header, the body's padding, and its lines (11 px × 1.8). */
const popoverHeight = (lines: number) => 29.5 + 8 + lines * 19.8;

export function ToolChips({
  calls = CALLS,
  files = FILES,
  summary,
  messages = 2,
  visibleFiles = 3,
  defaultOpen = true,
  defaultOpenRows,
  defaultAllFiles,
  onOpenFile,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The turn's tool calls, a row each. */
  calls?: ToolCall[];
  /** The files the turn changed, a chip each. */
  files?: FileChange[];
  /** The group's header; by default it counts the tool calls and the messages. */
  summary?: string;
  /** How many messages the header counts, when it writes the summary itself. */
  messages?: number;
  /** How many file chips show before "+N more". */
  visibleFiles?: number;
  /** Whether the group starts open. */
  defaultOpen?: boolean;
  /** The rows (by call id) that start open; setting it turns the demo off. */
  defaultOpenRows?: string[];
  /** Whether every file chip starts shown, past "+N more"; setting it turns the demo off. */
  defaultAllFiles?: boolean;
  /** Called when a file's diff opens (its path) or closes (null). */
  onOpenFile?: (path: string | null) => void;
  /** While on screen and untouched, the turn plays in, then a demo pointer opens rows and a diff. */
  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>(8000);
  // Opening in a given state holds it: no play-in, no demo.
  const auto = autoplay && defaultOpenRows === undefined && defaultAllFiles === undefined;
  const pointer = useRef<HTMLSpanElement>(null);
  const id = useId();

  // How much of the turn is in: the first `shown` rows, and with one more step the files. With
  // autoplay it plays in once on screen; otherwise (or under reduced motion) it is all there.
  const total = calls.length + 1;
  const [shown, setShown] = useState(auto ? 0 : total);
  const [open, setOpen] = useState(defaultOpen);
  const [openRows, setOpenRows] = useState<string[]>(defaultOpenRows ?? []);
  const [allFiles, setAllFiles] = useState(defaultAllFiles ?? false);
  const [openFile, setOpenFile] = useState<string | null>(null);

  useEffect(() => {
    if (shown >= total) return;
    if (!auto || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setShown(total);
      return;
    }
    if (!playing) return;
    const timer = window.setTimeout(() => setShown((count) => count + 1), BEAT);
    return () => window.clearTimeout(timer);
  }, [auto, playing, shown, total]);

  const toggleRow = (row: string) =>
    setOpenRows((rows) => (rows.includes(row) ? rows.filter((r) => r !== row) : [...rows, row]));

  /** The popover of a file chip, and the chip it hangs from. */
  const popoverOf = (index: number) => document.getElementById(`${id}-diff-${index}`);
  const chipOf = (index: number) => document.getElementById(`${id}-file-${index}`);

  // An open diff follows its chip as the page scrolls or the window resizes.
  useEffect(() => {
    if (openFile === null) return;
    const index = files.findIndex((file) => file.path === openFile);
    const update = () => {
      const popover = popoverOf(index);
      const chip = chipOf(index);
      if (popover && chip) place(popover, chip, files[index].lines.length);
    };
    window.addEventListener('scroll', update, { capture: true, passive: true });
    window.addEventListener('resize', update);
    return () => {
      window.removeEventListener('scroll', update, { capture: true });
      window.removeEventListener('resize', update);
    };
    // popoverOf and chipOf only read the DOM by id.
  }, [openFile, files]);

  // The demo, once the turn is in: a pointer opens two rows, a diff, and folds the group.
  const demoAct = useEffectEvent(
    (
      step:
        | { type: 'row'; id: string }
        | { type: 'group' }
        | { type: 'reset' }
        | { type: 'diff'; index: number; show: boolean },
    ) => {
      if (step.type === 'reset') {
        // Each round starts from the turn as it first shows: open, its rows folded.
        setOpen(true);
        setOpenRows([]);
        setAllFiles(false);
      } else if (step.type === 'row') toggleRow(step.id);
      else if (step.type === 'group') setOpen((value) => !value);
      else {
        const popover = popoverOf(step.index);
        if (!popover) return;
        if (step.show) popover.showPopover();
        else if (popover.matches(':popover-open')) popover.hidePopover();
      }
    },
  );
  const demo = auto && playing && shown >= total;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
    let hovered: HTMLElement | null = null;
    /** Stands in for :hover, as the demo's pointer is not the reader's. */
    const hover = (element: HTMLElement | null) => {
      hovered?.removeAttribute('data-hover');
      hovered = element;
      hovered?.setAttribute('data-hover', '');
    };
    const visit = async (
      { moveTo }: Demo,
      element: HTMLElement | null,
      at?: { x: number; y: number },
    ) => {
      hover(null);
      await moveTo(element, at);
      hover(element);
    };
    const rowIds = calls.filter((call) => call.details?.length).map((call) => call.id);
    const diff = Math.min(1, files.length - 1);

    const stop = runDemo(stage, arrow, async (steps) => {
      const { wait, moveTo, press } = steps;
      for (let round = 0; ; round += 1) {
        demoAct({ type: 'reset' });
        await wait(2400);
        // Two rows, opened and folded again.
        for (const row of [rowIds[round % rowIds.length], rowIds[(round + 2) % rowIds.length]]) {
          if (!row) continue;
          await visit(steps, find(`[data-row="${row}"]`), { x: 0.3, y: 0.5 });
          await wait(450);
          await press(() => demoAct({ type: 'row', id: row }));
          await wait(1700);
          await press(() => demoAct({ type: 'row', id: row }));
          await wait(500);
        }
        // A file's diff.
        if (diff >= 0) {
          await visit(steps, find(`[data-file="${diff}"]`), { x: 0.4, y: 0.5 });
          await wait(350);
          await press(() => demoAct({ type: 'diff', index: diff, show: true }));
          await wait(2400);
          await visit(steps, stage, { x: 0.12, y: 0.62 });
          await press(() => demoAct({ type: 'diff', index: diff, show: false }));
          await wait(600);
        }
        // The group, folded and opened again.
        await visit(steps, find('[data-group]'), { x: 0.3, y: 0.5 });
        await wait(400);
        await press(() => demoAct({ type: 'group' }));
        await wait(1300);
        await press(() => demoAct({ type: 'group' }));
        await wait(700);
        await visit(steps, stage, { x: 0.88, y: 0.9 });
        hover(null);
        await wait(1600);
      }
    });
    return () => {
      stop();
      hover(null);
      for (let index = 0; index < files.length; index += 1) {
        const popover = popoverOf(index);
        if (popover?.matches(':popover-open')) popover.hidePopover();
      }
    };
    // popoverOf only reads the DOM by id.
  }, [demo, root, calls, files]);

  /** Any input of the reader's takes over, and brings in the whole turn at once. */
  const interrupt = () => {
    take();
    setShown(total);
  };

  const label =
    summary ??
    [
      `${calls.length} tool ${calls.length === 1 ? 'call' : 'calls'}`,
      messages ? `${messages} ${messages === 1 ? 'message' : 'messages'}` : '',
    ]
      .filter(Boolean)
      .join(', ');
  const rows = calls.slice(0, Math.min(shown, calls.length));
  const extra = files.length - visibleFiles;
  const fileCount = allFiles || extra <= 1 ? files.length : visibleFiles;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={interrupt}
      onKeyDownCapture={interrupt}
    >
      <div className={styles.frame}>
        <div className={styles.thread}>
          <button
            type="button"
            className={styles.group}
            data-group=""
            data-part="group"
            aria-expanded={open}
            aria-controls={`${id}-turn`}
            onClick={() => setOpen((value) => !value)}
          >
            <Chevron className={styles.groupChevron} />
            <span className={styles.summary} data-part="summary">
              {label}
            </span>
          </button>
          <div className={styles.fold} data-open={open || undefined} inert={!open}>
            <div className={styles.turn} id={`${id}-turn`}>
              <div className={styles.calls} data-part="calls">
                {rows.map((call) => (
                  <Row
                    key={call.id}
                    call={call}
                    open={openRows.includes(call.id)}
                    detailsId={`${id}-${call.id}`}
                    onToggle={() => toggleRow(call.id)}
                  />
                ))}
              </div>
              {shown > calls.length && files.length > 0 && (
                <div className={styles.files} data-part="files">
                  {files.slice(0, fileCount).map((file, index) => (
                    <FileChip
                      key={file.path}
                      file={file}
                      chipId={`${id}-file-${index}`}
                      popoverId={`${id}-diff-${index}`}
                      index={index}
                      // The chips revealed by "+N more" stagger from their own start.
                      delay={(index < visibleFiles ? index : index - visibleFiles) * STAGGER}
                      open={openFile === file.path}
                      onToggle={(isOpen) => {
                        setOpenFile((current) =>
                          isOpen ? file.path : current === file.path ? null : current,
                        );
                        onOpenFile?.(isOpen ? file.path : null);
                      }}
                    />
                  ))}
                  {extra > 1 && (
                    <button
                      type="button"
                      className={styles.more}
                      data-part="more"
                      style={{ '--delay': `${visibleFiles * STAGGER}ms` } as CSSProperties}
                      aria-expanded={allFiles}
                      onClick={() => setAllFiles((value) => !value)}
                    >
                      {allFiles ? 'Show less' : `+${extra} more`}
                    </button>
                  )}
                </div>
              )}
            </div>
          </div>
        </div>
      </div>
      <DemoPointer ref={pointer} />
    </div>
  );
}

/** One tool call: its row, and the lines it opens onto. */
function Row({
  call,
  open,
  detailsId,
  onToggle,
}: {
  call: ToolCall;
  open: boolean;
  detailsId: string;
  onToggle: () => void;
}) {
  const details = call.details ?? [];
  const content = (
    <>
      <span className={styles.glyph} data-part="glyph" aria-hidden="true">
        <Glyph icon={call.icon} />
        {details.length > 0 && <Chevron className={styles.rowChevron} />}
      </span>
      <span className={styles.label} data-part="label">
        {call.label}
      </span>
      <span className={styles.chip} data-part="chip" data-code={call.code || undefined}>
        {call.chip}
      </span>
    </>
  );
  return (
    <div className={styles.call}>
      {details.length ? (
        <button
          type="button"
          className={styles.row}
          data-part="row"
          data-row={call.id}
          aria-expanded={open}
          aria-controls={detailsId}
          onClick={onToggle}
        >
          {content}
        </button>
      ) : (
        <div className={styles.row} data-part="row" data-row={call.id}>
          {content}
        </div>
      )}
      {details.length > 0 && (
        <div className={styles.rowFold} data-open={open || undefined} inert={!open}>
          <div className={styles.clip}>
            <div
              className={styles.details}
              data-part="details"
              id={detailsId}
              data-tone={call.tone ?? 'text'}
            >
              {details.map((line) => (
                <span key={line} className={styles.detail}>
                  {line}
                </span>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

/** A changed file: its chip, and the popover with an excerpt of its diff. */
function FileChip({
  file,
  chipId,
  popoverId,
  index,
  delay,
  open,
  onToggle,
}: {
  file: FileChange;
  chipId: string;
  popoverId: string;
  index: number;
  delay: number;
  open: boolean;
  onToggle: (open: boolean) => void;
}) {
  return (
    <span className={styles.fileSlot}>
      <button
        type="button"
        id={chipId}
        className={styles.file}
        data-part="file"
        data-file={index}
        style={{ '--delay': `${delay}ms` } as CSSProperties}
        popoverTarget={popoverId}
        aria-expanded={open}
        aria-label={`Show diff for ${file.path}`}
      >
        <span className={styles.path}>{file.path}</span>
        <span className={styles.added} data-part="added">
          +{file.added}
        </span>
        {file.removed ? (
          <span className={styles.removed} data-part="removed">
            −{file.removed}
          </span>
        ) : null}
      </button>
      <div
        id={popoverId}
        popover="auto"
        role="dialog"
        aria-label={`Diff of ${file.path}`}
        className={styles.popover}
        data-part="diff"
        onBeforeToggle={(event: ToggleEvent<HTMLDivElement>) => {
          const chip = document.getElementById(chipId);
          if (event.newState === 'open' && chip)
            place(event.currentTarget, chip, file.lines.length);
        }}
        onToggle={(event: ToggleEvent<HTMLDivElement>) => onToggle(event.newState === 'open')}
      >
        <div className={styles.popHead}>
          <span className={styles.popPath}>{file.path}</span>
          <span className={styles.popCounts}>
            <span className={styles.added}>+{file.added}</span>
            {file.removed ? <span className={styles.removed}> −{file.removed}</span> : null}
          </span>
        </div>
        <div className={styles.popBody}>
          {file.lines.map((line, number) => (
            <div key={number} className={styles.line} data-kind={line.kind}>
              <span className={styles.sign} aria-hidden="true">
                {line.kind === 'added' ? '+' : line.kind === 'removed' ? '−' : ' '}
              </span>
              <span className={styles.code}>
                {line.kind === 'added' ? (
                  <span className={styles.srOnly}>Added: </span>
                ) : line.kind === 'removed' ? (
                  <span className={styles.srOnly}>Removed: </span>
                ) : null}
                {line.text}
              </span>
            </div>
          ))}
        </div>
      </div>
    </span>
  );
}

/**
 * Places a diff's popover (fixed, in the top layer) under its chip, left edges lined up, or above
 * the chip when the window has no room below; never past the window's sides.
 */
function place(popover: HTMLElement, chip: HTMLElement, lines: number) {
  const box = chip.getBoundingClientRect();
  const width = Math.min(POPOVER_WIDTH, window.innerWidth - 16);
  const height = popoverHeight(lines);
  const left = Math.max(8, Math.min(box.left, window.innerWidth - width - 8));
  let top = box.bottom + POPOVER_GAP;
  let origin = 'left top';
  if (top + height > window.innerHeight - 8 && box.top - POPOVER_GAP - height >= 8) {
    top = box.top - POPOVER_GAP - height;
    origin = 'left bottom';
  }
  popover.style.left = `${left}px`;
  popover.style.top = `${top}px`;
  popover.style.width = `${width}px`;
  popover.style.transformOrigin = origin;
}

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

function Glyph({ icon }: { icon: ToolIcon }) {
  if (icon === 'thinking')
    return (
      <svg className={styles.icon} viewBox="0 0 24 24" fill="currentColor">
        <path d="M12 2.2l2.35 7.45L21.8 12l-7.45 2.35L12 21.8l-2.35-7.45L2.2 12l7.45-2.35z" />
      </svg>
    );
  const paths: Record<Exclude<ToolIcon, 'thinking'>, string[]> = {
    write: ['M16.9 3.1a2.83 2.83 0 0 1 4 4L7.6 20.4 2 22l1.6-5.6z'],
    command: ['M4 7l6 5-6 5', 'M12 19h8'],
    read: [
      'M14 2H6.2A2.2 2.2 0 0 0 4 4.2v15.6A2.2 2.2 0 0 0 6.2 22h11.6a2.2 2.2 0 0 0 2.2-2.2V8z',
      'M14 2v6h6',
    ],
  };
  return (
    <svg
      className={styles.icon}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {paths[icon].map((d) => (
        <path key={d} d={d} />
      ))}
    </svg>
  );
}

function Chevron({ className }: { className: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M6 9.5l6 6 6-6" />
    </svg>
  );
}
```

### tool-chips/tool-chips.module.css

```css
/* Tool chips (tool-chips.tsx): a soft grey card, and in its middle a column 320 px wide at most:
   the group's header, its rows, and the file chips under a hairline.

   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. The folds animate grid-template-rows between 0fr and 1fr, so nothing is measured; rows
   rise in by a transform and the chips pop in by a scale. Under reduced motion all of it is
   instant. The diffs' popovers live in the top layer: they inherit these tokens from the root,
   their parent in the document, whatever is drawn around the card. */

.root {
  /* The card, and the ink in three strengths. */
  --canvas: oklch(96.1% 0.002 247.84);
  --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);
  /* Hairlines: the card's edge, the row chips' and the rule above the files. */
  --line: oklch(94.6% 0.003 264.542);
  /* A row's chip; the file chips and the popovers; their hovers. */
  --field: oklch(96.1% 0.001 286.375);
  --surface: oklch(100% 0 0);
  --hover: oklch(97% 0.002 247.839);
  --hover-2: oklch(93.3% 0.003 247.86);
  /* Lines added and removed, and their tints in a diff. */
  --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 file chip's edge and lift: a soft halo by day, a short drop at night. */
  --button-edge: oklch(91.2% 0.005 258.326);
  --button-halo: #0000000a;
  --button-drop: transparent;
  /* A popover's edge, its long soft shadow by day, and its single deep one at night. */
  --overlay-edge: oklch(94.6% 0.003 264.542);
  --overlay-1: #0000000d;
  --overlay-2: #0000000a;
  --overlay-3: #00000008;
  --overlay-4: #00000005;
  --overlay-night: transparent;
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --sans: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);
  --code: var(--mono, ui-monospace, 'SF Mono', monospace);

  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;
  -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));
    --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));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --hover-2: light-dark(oklch(93.3% 0.003 247.86), oklch(31.8% 0.007 274.747));
    --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));
    --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));
    --overlay-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.15));
    --overlay-1: light-dark(#0000000d, transparent);
    --overlay-2: light-dark(#0000000a, transparent);
    --overlay-3: light-dark(#00000008, transparent);
    --overlay-4: light-dark(#00000005, transparent);
    --overlay-night: light-dark(transparent, oklch(0% 0 0 / 0.34));
  }
}

/* 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: left;
  cursor: pointer;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}

/* The column: 480 px at most, and the thread 320 px in its middle. */
.frame {
  width: 100%;
  max-width: 480px;
}
.thread {
  box-sizing: border-box;
  width: 100%;
  max-width: 320px;
  min-height: 220px;
  margin-inline: auto;
  padding-bottom: 4px;
}

/* The group's header. */
.group {
  display: flex;
  align-items: center;
  gap: 6px;
  width: fit-content;
  margin: 0 -6px;
  padding: 4px 6px;
  border-radius: 8px;
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 18.75px;
  transition: background-color 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
.groupChevron {
  flex: none;
  width: 12px;
  height: 12px;
  transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.group[aria-expanded='false'] .groupChevron {
  transform: rotate(-90deg);
}
.summary {
  font-variant-numeric: tabular-nums;
}

/* A fold: its one row is 0fr shut and 1fr open, and its content fades along. */
.fold,
.rowFold {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.rowFold {
  transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.fold[data-open],
.rowFold[data-open] {
  grid-template-rows: 1fr;
  opacity: 1;
}
/* The group's content, a little wider than the column so the rows' hovers and the chips' edges
   are not cut. */
.turn {
  min-height: 0;
  margin: 0 -4px;
  padding: 0 6px 4px;
  overflow: hidden;
}
.clip {
  min-height: 0;
  overflow: hidden;
}

.calls {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 6px;
}
.call {
  animation: rise 0.3s cubic-bezier(0.23, 1, 0.32, 1) both;
}

/* A row: its glyph, its words and its chip. */
.row {
  display: flex;
  align-items: center;
  gap: 8px;
  box-sizing: border-box;
  width: calc(100% + 6px);
  min-width: 0;
  height: 28px;
  margin: 0 -3px;
  padding: 0 3px;
  border-radius: 8px;
  transition: background-color 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
div.row {
  cursor: default;
}
.glyph {
  position: relative;
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  color: var(--ink-3);
}
.icon {
  display: block;
  width: 13px;
  height: 13px;
  transition: opacity 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
.rowChevron {
  position: absolute;
  width: 12px;
  height: 12px;
  opacity: 0;
  transform: rotate(-90deg);
  transition:
    opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1),
    transform 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Open, the row keeps its chevron, turned down. */
.row[aria-expanded='true'] .icon {
  opacity: 0;
}
.row[aria-expanded='true'] .rowChevron {
  opacity: 1;
  transform: none;
}
.label {
  flex: none;
  color: var(--ink);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 18.75px;
  white-space: nowrap;
}
.chip {
  display: block;
  flex: 1 1 0%;
  box-sizing: border-box;
  min-width: 0;
  height: 22px;
  padding: 0 6px;
  overflow: hidden;
  border-radius: 6px;
  background: var(--field);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink-2);
  font-size: 11.5px;
  line-height: 22px;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: background-color 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
button.row .chip {
  cursor: pointer;
}
.chip[data-code] {
  font-family: var(--code);
}

/* What a row opens onto: a few lines on a thread from its glyph. */
.details {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin: 2px 0 4px 8px;
  padding: 2px 0 2px 14px;
  border-left: 1px solid var(--line);
}
.detail {
  overflow: hidden;
  color: var(--ink-2);
  font-size: 11.5px;
  line-height: 1.6;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.details[data-tone='code'] .detail,
.details[data-tone='added'] .detail {
  font-family: var(--code);
}
.details[data-tone='added'] .detail {
  color: var(--green);
}

/* The files, under a hairline. */
.files {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  max-width: 100%;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--line);
}
.fileSlot {
  position: relative;
  max-width: 100%;
}
.file {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  box-sizing: border-box;
  max-width: 100%;
  height: 28px;
  padding: 0 8px;
  border-radius: 6px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--button-edge),
    0 1px 2px var(--button-drop),
    0 0 4px var(--button-halo);
  color: var(--ink);
  font-family: var(--code);
  font-size: 11.5px;
  line-height: 17.25px;
  transition: background-color 0.1s cubic-bezier(0.4, 0, 0.2, 1);
  animation: pop 0.25s cubic-bezier(0.23, 1, 0.32, 1) var(--delay, 0ms) both;
}
.path {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.added,
.removed {
  flex: none;
  font-variant-numeric: tabular-nums;
}
.added {
  color: var(--green);
}
.removed {
  color: var(--red);
}
.more {
  display: inline-flex;
  align-items: center;
  height: 28px;
  padding: 0 6px;
  border-radius: 6px;
  color: var(--ink-3);
  font-family: var(--code);
  font-size: 11.5px;
  line-height: 17.25px;
  text-decoration: underline;
  text-decoration-color: transparent;
  text-underline-offset: 2px;
  transition:
    color 0.1s cubic-bezier(0.4, 0, 0.2, 1),
    text-decoration-color 0.1s cubic-bezier(0.4, 0, 0.2, 1);
  animation: appear 0.3s ease-out var(--delay, 0ms) both;
}

/* Hovers: the reader's pointer, where it can hover, or the demo's ([data-hover]). */
@media (hover: hover) {
  .group:hover,
  button.row:hover,
  .chip:hover {
    background-color: var(--hover-2);
  }
  button.row:hover .icon {
    opacity: 0;
  }
  button.row:hover .rowChevron {
    opacity: 1;
  }
  .file:hover {
    background-color: var(--hover);
  }
  .more:hover {
    color: var(--ink-2);
    text-decoration-color: currentColor;
  }
}
.group[data-hover],
button.row[data-hover] {
  background-color: var(--hover-2);
}
button.row[data-hover] .icon {
  opacity: 0;
}
button.row[data-hover] .rowChevron {
  opacity: 1;
}
.file[data-hover] {
  background-color: var(--hover);
}

/* A diff, in the top layer under its chip (placed by tool-chips.tsx). */
.popover {
  position: fixed;
  inset: auto;
  box-sizing: border-box;
  width: 288px;
  margin: 0;
  padding: 0;
  overflow: hidden;
  border: 0;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--overlay-edge),
    0 25px 50px var(--overlay-1),
    0 12px 24px var(--overlay-2),
    0 6px 12px var(--overlay-3),
    0 3px 6px var(--overlay-4),
    0 1.5px 3px var(--overlay-4),
    0 8px 28px var(--overlay-night);
  color: var(--ink);
  font-family: var(--code);
  font-size: 11px;
  letter-spacing: -0.14px;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
.popover:popover-open {
  animation: pop 0.16s cubic-bezier(0.23, 1, 0.32, 1) both;
}
.popHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 10px;
  border-bottom: 1px solid var(--line);
  line-height: 16.5px;
}
.popPath {
  min-width: 0;
  overflow: hidden;
  color: var(--ink-2);
  text-overflow: ellipsis;
  white-space: nowrap;
}
.popCounts {
  flex: none;
  font-variant-numeric: tabular-nums;
}
.popBody {
  padding: 4px 0;
  line-height: 1.8;
}
.line {
  display: flex;
  gap: 8px;
  padding: 0 10px;
  color: var(--ink-2);
  white-space: pre;
}
.line[data-kind='added'] {
  background: var(--green-tint);
  color: var(--green);
}
.line[data-kind='removed'] {
  background: var(--red-tint);
  color: var(--red);
}
.sign {
  flex: none;
  width: 12px;
  user-select: none;
}
.code {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  /* One line each, cut short with an ellipsis; leading spaces fold away as in a one-line chip. */
  white-space: nowrap;
}
.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  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;
  }
}

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

### tool-chips/data.ts

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

/*
 * What the chips show: the steps of an agent's run (tool calls) and the files it changed. The
 * sample is an ice-cream shop's code, planning which flavours to churn and when.
 */

/** The glyph at the start of a row. */
export type ToolIcon = 'thinking' | 'write' | 'command' | 'read';

export type ToolCall = {
  id: string;
  icon: ToolIcon;
  /** What the step did, in a few words: "Write 204 lines". */
  label: string;
  /** Its chip: a file, a command, or the first words of a thought. */
  chip: string;
  /** Sets the chip in monospace, as a file name or a command. */
  code?: boolean;
  /** The lines the row opens onto. */
  details?: string[];
  /** How they read: prose, output in monospace, or lines added to a file. */
  tone?: 'text' | 'code' | 'added';
};

export type DiffLine = { kind: 'context' | 'added' | 'removed'; text: string };

export type FileChange = {
  path: string;
  added: number;
  removed?: number;
  /** An excerpt of the change, shown in the chip's popover. */
  lines: DiffLine[];
};

export const CALLS: ToolCall[] = [
  {
    id: 'thinking',
    icon: 'thinking',
    label: 'Thinking',
    chip: 'Planning the churn schedule…',
    details: [
      'Weekend demand carries pistachio, so it churns first.',
      'Batch capacity leaves two evening freezer windows.',
    ],
  },
  {
    id: 'write',
    icon: 'write',
    label: 'Write 204 lines',
    chip: 'ChurnSchedule.tsx',
    code: true,
    tone: 'added',
    details: [
      '+ const windows = slots.filter((s) => s.temp <= -12)',
      '+ return schedule(windows, { hero: "pistachio" })',
    ],
  },
  {
    id: 'rebuild',
    icon: 'command',
    label: 'Rebuild and verify',
    chip: 'npm run freeze',
    code: true,
    tone: 'code',
    details: ['✓ built in 1.2s', '✓ 34 checks passed'],
  },
  {
    id: 'image',
    icon: 'read',
    label: 'Read image',
    chip: 'flavor-chart.png',
    code: true,
    details: ['1280 × 720 · line chart, three summers.', 'Mint chip trends up 12% through July.'],
  },
];

export const FILES: FileChange[] = [
  {
    path: 'flavors.css',
    added: 13,
    lines: [
      { kind: 'context', text: '.scoop-card {' },
      { kind: 'removed', text: '  gap: 14px;' },
      { kind: 'added', text: '  gap: 12px;' },
      { kind: 'added', text: '  container-type: inline-size;' },
      { kind: 'context', text: '}' },
    ],
  },
  {
    path: 'ChurnSchedule.tsx',
    added: 74,
    removed: 41,
    lines: [
      { kind: 'context', text: 'const slots = coldSlots(week);' },
      { kind: 'removed', text: 'const windows = slots;' },
      { kind: 'added', text: 'const windows = slots.filter(' },
      { kind: 'added', text: '  (s) => s.temp <= -12,' },
      { kind: 'added', text: ');' },
    ],
  },
  {
    path: 'menu.ts',
    added: 8,
    removed: 2,
    lines: [
      { kind: 'removed', text: 'export const hero = "mint-chip";' },
      { kind: 'added', text: 'export const hero = "pistachio";' },
    ],
  },
  {
    path: 'scoops.json',
    added: 21,
    lines: [
      { kind: 'context', text: '"pistachio": {' },
      { kind: 'added', text: '  "batch": 18,' },
      { kind: 'added', text: '  "window": "evening",' },
      { kind: 'context', text: '}' },
    ],
  },
  {
    path: 'freezer.test.ts',
    added: 9,
    removed: 3,
    lines: [
      { kind: 'removed', text: 'expect(windows).toHaveLength(3);' },
      { kind: 'added', text: 'expect(windows).toHaveLength(2);' },
      { kind: 'added', text: 'expect(windows[0].temp).toBeLessThan(-12);' },
    ],
  },
];
```

### tool-chips/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide
 * the pointer onto a control, press it and call the component's own handler, as a click would.
 * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once:
 * its promise rejects, which ends the script right there, and the pointer fades out.
 *
 * The pointer is an HTML box around a still drawing: it glides by an animation the compositor
 * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a
 * layout.
 */

export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, in its 22 × 22 box: the point the pointer acts on. */
const TIP: Point = { x: 3.3, y: 2.2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.96 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  /** Glides to `to`, in the stage's own px. */
  const glide = (to: Point) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen px to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return glide({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x}px ${TIP.y}px`,
};

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow's colours, like its shadow's, are the stage's tokens (tool-chips.module.css). */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };

/** The arrow the demo moves around. It is decorative: the demo's effects show on the controls. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
    </span>
  );
}
```

### tool-chips/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 };
}
```