# Bulk delete

> A file list where you drag to select a run of rows; deleted rows stack into one dark bar with Undo, which deals them back.

A card of recent files. Press a row and drag up or down to select a run, or tick the boxes and Shift-click; the selected rows share one grey block and the header turns into the count and a red Delete. Delete gathers the rows into a deck that becomes a dark bar, “3 files deleted”, with a sliver of the cards under it, and the list closes up. Undo deals them back to their places one after the other; the bin on the bar empties them for good. Deleting more while the bar is up adds them to it.

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

Category: Inputs.

## Anatomy

1. **Header**: The title and how many files are left, with View all; while rows are selected, the count and Delete take its place.
2. **Delete**: A red tinted button with a bin. The Delete key does the same.
3. **Selected rows**: One grey block: rows next to each other join their corners. Press a row and drag to add a run.
4. **Checkbox**: A real checkbox named by the file, black with a white tick when on.
5. **Badge**: The file’s kind in its own pale tint: PDF red, FIG violet, PNG blue, XLS green.
6. **Row**: The file’s name over a quiet line: size, who edited it, when.

## When to use

- A short list of files or records where the reader often clears several at once.
- Deletes that are cheap to take back: the files wait in the bar until Undo or the bin.
- Lists where a run of rows is the usual pick, so a press and a drag beats ticking each box.
- A card in a side panel or a dashboard, about 320 to 400 px wide.

## When not to use

- Hundreds of rows with sorting and filters. Use a data table with a selection column.
- Deletes that cannot wait, such as a request that frees storage now. Ask in a dialog instead.
- One item at a time. A menu on the row, or a swipe on touch, is quicker.
- Lists the reader reorders by dragging: the drag would fight the selection.

## Installation

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

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

Or add the registry to components.json once, as a namespace that coding agents and the shadcn MCP server search by name:

```json
{
  "registries": {
    "@brunodelorence": "https://brunodelorence.com/r/{name}.json"
  }
}
```

```sh
npx shadcn@latest add @brunodelorence/bulk-delete
```

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

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
'use client';

import { BulkDelete, type FileItem } from './bulk-delete';

const files: FileItem[] = [
  { id: 'deck', name: 'Pitch deck.pdf', kind: 'pdf', meta: '3.2 MB · Edited by you' },
  { id: 'logo', name: 'Logo final.png', kind: 'png', meta: '640 KB · Yesterday' },
  { id: 'plan', name: 'Hiring plan.xlsx', kind: 'xls', meta: '120 KB · Edited by Sam' },
];

export function RecentFiles() {
  return (
    <BulkDelete
      files={files}
      onDelete={(gone) => console.log('trashed', gone)}
      onUndo={(back) => console.log('restored', back)}
      onPurge={(gone) => console.log('deleted for good', gone)}
      autoplay={false}
    />
  );
}
```

## States

- **Rest**: Nothing selected: the title, the count and View all.
- **Run selected**: Three rows in one grey block; the header turns into the count and Delete.
- **Two runs**: Rows that are not next to each other each get their own rounded block.
- **Deleted**: Three files in the bar, with two cards of the deck showing under it. The list has closed up.
- **One deleted**: A single file: the bar alone, no deck under it.
- **Empty**: Every file emptied for good: a quiet line where the list was.

## How it works

- Every change is FLIP: before it, each row’s and the bar’s place is read from the screen; once React has drawn the new list, a layout effect moves each one back to where it stood with a transform and lets it go with the Web Animations API. Reading the place on screen means an interrupted move carries on from where it was.
- The spring is simulated once, when the module loads, and sampled into a CSS linear() easing, so the browser plays the overshoot without script; browsers without linear() get an overshooting cubic-bezier.
- On Delete, the first selected row becomes the bar: it slides to the bar’s place as its fill turns from the selection grey to near-black, its face blurs out and the bar’s words blur in. The other rows become the deck’s cards, flying in under it and narrowing to their inset, by a scaleX taken from the widths.
- Undo plays it backwards, staggered by 45 ms: each restored row starts on its card in the deck, dark or grey and narrow, and springs to its own place with its fill, corners and face. The card’s height springs with the list.
- Emptying the bar leaves a copy of it to fade where it was, outside React, while the rows under it close up.
- Selected rows are one block because each knows whether its neighbours are selected and squares the corners it shares. The drag picks the row under the pointer by its place on screen, and selects from where it began.
- The demo drags over the first rows, one too far and back, deletes them, undoes, unticks one, deletes two and empties the bar. Any input of the reader’s stops it.

## API reference

- **files** (`readonly FileItem[]`, default `FILES`): The files, in order: an id, a name, a kind for the badge (pdf, fig, png, xls, doc, zip) and the line under the name.
- **title** (`string`, default `'Recent files'`): The header’s title.
- **onDelete** (`(files: FileItem[]) => void`): Files went into the bar. Undo can still bring them back.
- **onUndo** (`(files: FileItem[]) => void`): Undo brought these files back.
- **onPurge** (`(files: FileItem[]) => void`): The bin on the bar emptied these files for good.
- **onViewAll** (`() => void`): The header’s View all.
- **defaultSelected** (`readonly string[]`): Opens with these files selected (their ids); turns the demo off.
- **defaultDeleted** (`readonly string[]`): Opens with these files already in the bar (their ids); turns the demo off.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer selects, deletes and undoes; any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Classes for the stage.

## Accessibility

- The card is a section named by its title; the files are a list, and each row has a real checkbox (a button with role checkbox) named by the file, its line under the name as its description.
- The list is one tab stop: Up, Down, Home and End move between rows, Shift with an arrow or Shift with Space selects from the anchor, Space ticks a row, Control or Command A selects all and Escape clears.
- Delete or Backspace deletes the selection, and Control or Command Z undoes, from anywhere in the card. The focus moves to Undo after a delete, back to the first restored row after Undo, and to the list after the bin.
- Selecting, deleting, restoring and emptying are announced in a polite live region, with how many files.
- On touch, a row scrolls the page: a tap ticks it, and a drag that starts on a box selects a run.
- Every control has a visible focus ring. Under reduced motion, nothing flies or springs; the list changes in place.
- The demo pointer is decorative and hidden from assistive technology.

## Source

### bulk-delete/bulk-delete.tsx

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

'use client';

import {
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from 'react';
import styles from './bulk-delete.module.css';
import { FILES, type FileItem } from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';

export type { FileItem, FileKind } from './data';

/*
 * A list of recent files where several go at once. Press a row and drag to select a run of them
 * (or tick the boxes, Shift-click, or use the keys); Delete gathers them into a deck that becomes
 * one dark bar, "3 files deleted", with Undo and a bin that empties it for good. Undo deals the
 * cards back to their places, one after the other.
 *
 * The motion is FLIP played by the Web Animations API: before a change, every row's place on
 * screen is read; after React has drawn the new list, each row, card and the bar is moved back
 * to where it was by a transform and let go on a spring. The spring is sampled once into a CSS
 * linear() easing, so the browser plays it without script.
 */

/** A row's height, the bar's, and how far each card of the deck shows under the bar. */
const ROW = 56;
const PEEK = 4;
/** The deck's two visible cards: how far in from the bar's sides they sit. */
const INSETS = [0, 15, 21];

/** A spring, sampled into a linear() easing: stiffness and damping per unit mass. */
function spring(stiffness: number, damping: number) {
  let x = 0;
  let v = 0;
  let t = 0;
  const dt = 1 / 120;
  const points = [0];
  while (t < 2) {
    v += (stiffness * (1 - x) - damping * v) * dt;
    x += v * dt;
    t += dt;
    points.push(x);
    if (Math.abs(1 - x) < 0.002 && Math.abs(v) < 0.02) break;
  }
  points[points.length - 1] = 1;
  const kept = points.filter((_, i) => i % 2 === 0 || i === points.length - 1);
  return {
    easing: `linear(${kept.map((p) => Number(p.toFixed(4))).join(', ')})`,
    duration: Math.round(t * 1000),
  };
}

/** The cards' spring: quick, with a small overshoot, as the cards gather and part. */
const SPRING = spring(430, 30);
/** For browsers without linear(): an overshooting curve of about the same length. */
const FALLBACK = 'cubic-bezier(0.3, 1.25, 0.5, 1)';
const EASE_OUT = 'cubic-bezier(0.2, 0.8, 0.2, 1)';

type Snapshot = {
  kind: 'delete' | 'undo' | 'purge';
  /** Where each row and the bar stood, from the list's top, in its own px. */
  tops: Map<string, number>;
  cardHeight: number;
  /** The files that just went into the bar, or came back out of it (in the deck's order). */
  ids: string[];
  /** Whether the bar was up before. */
  hadBar: boolean;
  /** For a purge: a copy of the bar, left to fade where it was. */
  ghost?: HTMLElement;
};

type DemoStep =
  | { kind: 'select'; ids: string[] }
  | { kind: 'hover'; id: string | null }
  | { kind: 'over'; what: 'delete' | 'undo' | 'purge' | null }
  | { kind: 'delete' }
  | { kind: 'undo' }
  | { kind: 'purge' }
  | { kind: 'fade'; on: boolean }
  | { kind: 'reset' };

const plural = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`;

export function BulkDelete({
  files = FILES,
  title = 'Recent files',
  onDelete,
  onUndo,
  onPurge,
  onViewAll,
  defaultSelected,
  defaultDeleted,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The files, in the list's order. */
  files?: readonly FileItem[];
  /** The header's title. */
  title?: string;
  /** Files were deleted: they wait in the bar, where Undo can still bring them back. */
  onDelete?: (files: FileItem[]) => void;
  /** Undo brought these files back. */
  onUndo?: (files: FileItem[]) => void;
  /** The bar's bin emptied these files for good. */
  onPurge?: (files: FileItem[]) => void;
  /** The header's View all link. */
  onViewAll?: () => void;
  /** Opens with these files selected (their ids); turns the demo off. */
  defaultSelected?: readonly string[];
  /** Opens with these files already in the bar (their ids); turns the demo off. */
  defaultDeleted?: readonly string[];
  /** While on screen and untouched, a demo pointer selects, deletes and undoes on its own. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const demo = autoplay && playing && !defaultSelected && !defaultDeleted;
  const uid = useId();

  /** The files in the bar, in the deck's order: the first is its top card. */
  const [trash, setTrash] = useState<string[]>(() => [...(defaultDeleted ?? [])]);
  /** Files emptied for good. */
  const [gone, setGone] = useState<ReadonlySet<string>>(() => new Set());
  const [selected, setSelected] = useState<ReadonlySet<string>>(
    () => new Set(defaultSelected ?? []),
  );
  /** The row that holds the focus (one tab stop for the list), and where a range starts. */
  const [active, setActive] = useState<string | null>(null);
  const anchor = useRef<string | null>(null);
  const [message, setMessage] = useState('');
  /** The demo's pointer: the row under it, the button it is about to press. */
  const [demoHover, setDemoHover] = useState<string | null>(null);
  const [demoOver, setDemoOver] = useState<'delete' | 'undo' | 'purge' | null>(null);
  const [fading, setFading] = useState(false);

  const card = useRef<HTMLElement>(null);
  const list = useRef<HTMLUListElement>(null);
  const overlay = useRef<HTMLDivElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const boxes = useRef(new Map<string, HTMLButtonElement>());
  const undoButton = useRef<HTMLButtonElement>(null);
  const pending = useRef<Snapshot | null>(null);
  const lastCount = useRef(1);
  const drag = useRef<{
    id: number;
    from: number;
    base: ReadonlySet<string>;
    add: boolean;
    touchTap?: { x: number; y: number; id: string };
  } | null>(null);

  const byId = useMemo(() => new Map(files.map((file) => [file.id, file])), [files]);
  const order = useMemo(() => new Map(files.map((file, i) => [file.id, i])), [files]);
  const inTrash = useMemo(() => new Set(trash), [trash]);
  const visible = files.filter((file) => !gone.has(file.id) && !inTrash.has(file.id));
  const deck = trash.map((id) => byId.get(id)).filter((file): file is FileItem => !!file);
  /** The bar stands where the first of its files stood. */
  const barAt = deck.length
    ? visible.filter(
        (file) => order.get(file.id)! < Math.min(...trash.map((id) => order.get(id) ?? 0)),
      ).length
    : -1;
  const picked = visible.filter((file) => selected.has(file.id));
  const focusId = active && visible.some((file) => file.id === active) ? active : visible[0]?.id;

  // ---- Measuring and moving ------------------------------------------------------------------

  /** The card's scale on screen (a gallery draws it smaller with a transform). */
  const scaleOf = () => {
    const element = card.current;
    if (!element) return 1;
    return element.getBoundingClientRect().width / element.offsetWidth || 1;
  };

  /** Reads where everything stands now, stops what is moving, and keeps it for after the change. */
  const capture = (kind: Snapshot['kind'], ids: string[]) => {
    const ul = list.current;
    const element = card.current;
    if (!ul || !element) return;
    const scale = scaleOf();
    const top = ul.getBoundingClientRect().top;
    const tops = new Map<string, number>();
    for (const child of Array.from(ul.children) as HTMLElement[]) {
      const key = child.dataset.key;
      if (key) tops.set(key, (child.getBoundingClientRect().top - top) / scale);
    }
    const cardHeight = element.getBoundingClientRect().height / scale;
    let ghost: HTMLElement | undefined;
    if (kind === 'purge') {
      const bar = ul.querySelector<HTMLElement>('[data-key="bar"]');
      if (bar && overlay.current) {
        ghost = bar.cloneNode(true) as HTMLElement;
        ghost.removeAttribute('data-key');
        ghost.querySelectorAll('[id]').forEach((each) => each.removeAttribute('id'));
        ghost.style.position = 'absolute';
        ghost.style.left = '0';
        ghost.style.right = '0';
        ghost.style.top = `${tops.get('bar') ?? 0}px`;
      }
    }
    element.getAnimations({ subtree: true }).forEach((animation) => animation.cancel());
    pending.current = { kind, tops, cardHeight, ids, hadBar: tops.has('bar'), ghost };
  };

  // After each change: move everything back to where it stood, then let it go.
  useLayoutEffect(() => {
    const snap = pending.current;
    const ul = list.current;
    const element = card.current;
    pending.current = null;
    if (!snap || !ul || !element) return;
    const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (still) return;
    const smooth = CSS.supports('animation-timing-function', 'linear(0, 1)');
    const springy: KeyframeAnimationOptions = {
      duration: smooth ? SPRING.duration : 520,
      easing: smooth ? SPRING.easing : FALLBACK,
    };
    const scale = scaleOf();
    const listTop = ul.getBoundingClientRect().top;
    const topOf = (each: Element) => (each.getBoundingClientRect().top - listTop) / scale;
    const style = getComputedStyle(element);
    const token = (name: string) => style.getPropertyValue(name).trim();
    const handled = new Set<string>();

    // The card grows or shrinks with its list.
    const height = element.offsetHeight;
    if (Math.abs(height - snap.cardHeight) > 0.5)
      element.animate({ height: [`${snap.cardHeight}px`, `${height}px`] }, springy);

    const bar = ul.querySelector<HTMLElement>('[data-key="bar"]');
    const barTop = bar ? topOf(bar) : 0;
    const width = bar?.offsetWidth ?? ul.offsetWidth;

    if (snap.kind === 'delete' && bar) {
      const face = (holder: Element) =>
        holder
          .querySelector(':scope > [data-part="face"]')
          ?.animate(
            { opacity: [1, 0], filter: ['blur(0)', 'blur(3px)'] },
            { duration: 180, easing: EASE_OUT, fill: 'both' },
          );
      if (!snap.hadBar) {
        // The first selected row turns into the bar.
        handled.add('bar');
        const from = snap.tops.get(`row:${trash[0]}`) ?? barTop;
        const top = bar.querySelector<HTMLElement>('[data-part="bar"]')!;
        top.animate(
          {
            transform: [`translateY(${from - barTop}px)`, 'none'],
            backgroundColor: [token('--selected'), token('--bar')],
            borderRadius: ['12px', '14px'],
          },
          springy,
        );
        face(top);
        top
          .querySelectorAll(':scope > :not([data-part="face"])')
          .forEach((part) =>
            part.animate(
              { opacity: [0, 1], filter: ['blur(4px)', 'blur(0)'] },
              { duration: 260, delay: 70, easing: EASE_OUT, fill: 'backwards' },
            ),
          );
      }
      // The others fly in under it and become the deck.
      ul.querySelectorAll<HTMLElement>('[data-card]').forEach((each) => {
        const id = each.dataset.card!;
        if (!snap.ids.includes(id)) return;
        const from = snap.tops.get(`row:${id}`);
        if (from === undefined) return;
        const rest = getComputedStyle(each);
        const dy = from - (barTop + each.offsetTop);
        each.animate(
          {
            transform: [`translateY(${dy}px) scaleX(${width / each.offsetWidth})`, 'none'],
            backgroundColor: [token('--selected'), rest.backgroundColor],
            opacity: [1, Number(rest.opacity)],
          },
          springy,
        );
        face(each);
      });
    }

    if (snap.kind === 'undo') {
      // The deck deals its cards back to their places, the top one first.
      const barWas = snap.tops.get('bar') ?? 0;
      snap.ids.forEach((id, k) => {
        const row = ul.querySelector<HTMLElement>(`[data-key="row:${id}"]`);
        if (!row) return;
        handled.add(`row:${id}`);
        const slot = Math.min(k, 2);
        const rest = getComputedStyle(row);
        const dy = barWas + slot * PEEK - topOf(row);
        const delay = k * 45;
        row.style.zIndex = String(snap.ids.length - k + 1);
        const move = row.animate(
          {
            transform: [
              `translateY(${dy}px) scaleX(${(width - 2 * INSETS[slot]) / row.offsetWidth})`,
              'none',
            ],
            backgroundColor: [token(k === 0 ? '--bar' : `--deck-${slot}`), rest.backgroundColor],
          },
          { ...springy, delay, fill: 'backwards' },
        );
        // The corners settle quickly, so the rows join without seams while they still move.
        row.animate(
          { borderRadius: ['14px', rest.borderRadius] },
          { duration: 220, delay, easing: EASE_OUT, fill: 'backwards' },
        );
        move.finished.then(
          () => (row.style.zIndex = ''),
          () => (row.style.zIndex = ''),
        );
        row
          .querySelector('[data-part="face"]')
          ?.animate(
            { opacity: [0, 1], filter: ['blur(3px)', 'blur(0)'] },
            { duration: 240, delay: delay + 90, easing: EASE_OUT, fill: 'backwards' },
          );
      });
    }

    if (snap.kind === 'purge' && snap.ghost && overlay.current) {
      const ghost = snap.ghost;
      overlay.current.append(ghost);
      ghost
        .animate(
          { opacity: [1, 0], transform: ['none', 'scale(0.97)'], filter: ['blur(0)', 'blur(2px)'] },
          { duration: 220, easing: EASE_OUT, fill: 'forwards' },
        )
        .finished.then(
          () => ghost.remove(),
          () => ghost.remove(),
        );
    }

    // Everything else slides from where it stood.
    for (const child of Array.from(ul.children) as HTMLElement[]) {
      const key = child.dataset.key;
      if (!key || handled.has(key)) continue;
      const from = snap.tops.get(key);
      if (from === undefined) continue;
      const dy = from - topOf(child);
      if (Math.abs(dy) > 0.5)
        child.animate({ transform: [`translateY(${dy}px)`, 'none'] }, springy);
    }
  });

  // ---- Changes -------------------------------------------------------------------------------

  /** Whether the focus is inside the card (so it should follow what moves). */
  const focusInside = () => !!card.current?.contains(document.activeElement);

  const remove = (ids: string[]) => {
    if (!ids.length) return;
    const keep = focusInside();
    capture('delete', ids);
    setTrash((now) => [...now, ...ids.filter((id) => !now.includes(id))]);
    setSelected(new Set());
    anchor.current = null;
    const total = trash.length + ids.length;
    setMessage(`${plural(total)} deleted. Undo, or press Control Z, to bring them back.`);
    onDelete?.(ids.map((id) => byId.get(id)!).filter(Boolean));
    if (keep) requestAnimationFrame(() => undoButton.current?.focus({ preventScroll: true }));
  };

  const undo = () => {
    if (!trash.length) return;
    const keep = focusInside();
    const ids = [...trash].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0));
    capture('undo', trash);
    setTrash([]);
    setSelected(new Set(ids));
    setActive(ids[0]);
    anchor.current = ids[0];
    setMessage(`${plural(ids.length)} restored.`);
    onUndo?.(ids.map((id) => byId.get(id)!).filter(Boolean));
    if (keep)
      requestAnimationFrame(() => boxes.current.get(ids[0])?.focus({ preventScroll: true }));
  };

  const purge = () => {
    if (!trash.length) return;
    const keep = focusInside();
    const ids = trash;
    capture('purge', ids);
    setTrash([]);
    setGone((now) => new Set([...now, ...ids]));
    setMessage(`${plural(ids.length)} deleted for good.`);
    onPurge?.(ids.map((id) => byId.get(id)!).filter(Boolean));
    if (keep)
      requestAnimationFrame(() => {
        const first = list.current?.querySelector<HTMLElement>('[role="checkbox"][tabindex="0"]');
        (first ?? card.current)?.focus({ preventScroll: true });
      });
  };

  const say = (next: ReadonlySet<string>) => {
    setSelected(next);
    setMessage(next.size ? `${next.size} selected.` : 'Selection cleared.');
  };

  /** The ids of the visible rows from one index to another, both included. */
  const span = (a: number, b: number) =>
    visible.slice(Math.min(a, b), Math.max(a, b) + 1).map((file) => file.id);

  const toggle = (id: string) => {
    const next = new Set(selected);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    anchor.current = id;
    setActive(id);
    say(next);
  };

  /** Shift: everything from the anchor to this row joins the selection. */
  const extend = (id: string) => {
    const from = visible.findIndex((file) => file.id === anchor.current);
    const to = visible.findIndex((file) => file.id === id);
    if (to < 0) return;
    if (from < 0) return toggle(id);
    setActive(id);
    say(new Set([...selected, ...span(from, to)]));
  };

  // ---- Pointer: press a row and drag over others -----------------------------------------------

  const rowAt = (y: number) => {
    const ul = list.current;
    if (!ul) return -1;
    const rows = visible.map((file) =>
      ul.querySelector<HTMLElement>(`[data-key="row:${file.id}"]`),
    );
    let best = -1;
    rows.forEach((row, i) => {
      if (!row) return;
      const rect = row.getBoundingClientRect();
      if (best < 0 || y >= rect.top) best = i;
    });
    return best;
  };

  const onRowDown = (id: string) => (event: ReactPointerEvent<HTMLLIElement>) => {
    if (event.button !== 0) return;
    const index = visible.findIndex((file) => file.id === id);
    if (index < 0) return;
    const onBox = !!(event.target as Element).closest('[role="checkbox"]');
    if (event.shiftKey && anchor.current) {
      event.preventDefault();
      extend(id);
      return;
    }
    // On touch, the row scrolls the page: a tap toggles it, and a drag from the box selects.
    if (event.pointerType === 'touch' && !onBox) {
      drag.current = {
        id: event.pointerId,
        from: index,
        base: selected,
        add: true,
        touchTap: { x: event.clientX, y: event.clientY, id },
      };
      return;
    }
    event.preventDefault();
    const add = !selected.has(id);
    drag.current = { id: event.pointerId, from: index, base: selected, add };
    event.currentTarget.setPointerCapture(event.pointerId);
    anchor.current = id;
    setActive(id);
    // The focus follows the press, without a ring: the ring is for the keys.
    boxes.current.get(id)?.focus({ preventScroll: true, focusVisible: false } as FocusOptions);
    const next = new Set(selected);
    if (add) next.add(id);
    else next.delete(id);
    setSelected(next);
  };

  const onRowMove = (event: ReactPointerEvent<HTMLLIElement>) => {
    const now = drag.current;
    if (!now || now.id !== event.pointerId || now.touchTap) return;
    const index = rowAt(event.clientY);
    if (index < 0) return;
    const ids = span(now.from, index);
    const next = new Set(now.base);
    for (const id of ids) {
      if (now.add) next.add(id);
      else next.delete(id);
    }
    if (next.size !== selected.size || [...next].some((id) => !selected.has(id))) {
      setSelected(next);
      setActive(visible[index].id);
    }
  };

  const onRowUp = (event: ReactPointerEvent<HTMLLIElement>) => {
    const now = drag.current;
    if (!now || now.id !== event.pointerId) return;
    drag.current = null;
    if (now.touchTap) {
      const moved = Math.hypot(event.clientX - now.touchTap.x, event.clientY - now.touchTap.y);
      if (moved < 10) toggle(now.touchTap.id);
      return;
    }
    setMessage(selected.size ? `${selected.size} selected.` : 'Selection cleared.');
  };

  // ---- Keys --------------------------------------------------------------------------------

  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    take();
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === 'z' && !event.shiftKey) {
      if (trash.length) {
        event.preventDefault();
        undo();
      }
      return;
    }
    const onRow = (event.target as HTMLElement).getAttribute('role') === 'checkbox';
    const inCard = card.current?.contains(event.target as Node);
    if (!inCard) return;
    if ((event.key === 'Delete' || event.key === 'Backspace') && picked.length) {
      event.preventDefault();
      remove(picked.map((file) => file.id));
      return;
    }
    if (event.key === 'Escape' && selected.size) {
      event.preventDefault();
      say(new Set());
      return;
    }
    if (!onRow) return;
    const id = (event.target as HTMLElement).dataset.id!;
    const index = visible.findIndex((file) => file.id === id);
    if (mod && event.key.toLowerCase() === 'a') {
      event.preventDefault();
      say(new Set(visible.map((file) => file.id)));
      return;
    }
    if (event.key === ' ' && event.shiftKey) {
      event.preventDefault();
      extend(id);
      return;
    }
    const moves: Record<string, number> = {
      ArrowDown: index + 1,
      ArrowUp: index - 1,
      Home: 0,
      End: visible.length - 1,
    };
    if (!(event.key in moves)) return;
    event.preventDefault();
    const to = Math.max(0, Math.min(visible.length - 1, moves[event.key]));
    const next = visible[to].id;
    setActive(next);
    boxes.current.get(next)?.focus();
    if (event.shiftKey) {
      if (!anchor.current) anchor.current = id;
      const from = visible.findIndex((file) => file.id === anchor.current);
      say(new Set([...selected, ...span(from < 0 ? index : from, to)]));
    } else anchor.current = next;
  };

  // ---- The demo ----------------------------------------------------------------------------

  const demoStep = useEffectEvent((step: DemoStep) => {
    switch (step.kind) {
      case 'select':
        setSelected(new Set(step.ids));
        break;
      case 'hover':
        setDemoHover(step.id);
        break;
      case 'over':
        setDemoOver(step.what);
        break;
      case 'delete':
        remove(picked.map((file) => file.id));
        break;
      case 'undo':
        undo();
        break;
      case 'purge':
        purge();
        break;
      case 'fade':
        setFading(step.on);
        break;
      case 'reset':
        setTrash([]);
        setGone(new Set());
        setSelected(new Set());
        setMessage('');
        anchor.current = null;
        break;
    }
  });

  const dirty = useEffectEvent(() => trash.length > 0 || gone.size > 0 || selected.size > 0);
  const ids = useEffectEvent(() => visible.map((file) => file.id));

  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;

    /** A point on an element, in the stage's px: `fx`, `fy` are shares of its box. */
    const on = (element: Element | null | undefined, fx = 0.5, fy = 0.5): Point => {
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = element?.getBoundingClientRect() ?? frame;
      return {
        x: (rect.left + rect.width * fx - frame.left) / scale,
        y: (rect.top + rect.height * fy - frame.top) / scale,
      };
    };
    const row = (id: string) => list.current?.querySelector(`[data-key="row:${id}"]`);
    const part = (name: string) => card.current?.querySelector(`[data-part="${name}"]`);

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const press = async (what: 'delete' | 'undo' | 'purge', target: string, fx = 0.5) => {
        glyph('arrow');
        await glide(on(part(target), fx, 0.55), 560);
        glyph('hand');
        demoStep({ kind: 'over', what });
        await wait(260);
        demoStep({ kind: what });
        demoStep({ kind: 'over', what: null });
      };
      for (;;) {
        if (dirty()) {
          demoStep({ kind: 'fade', on: true });
          await wait(320);
          demoStep({ kind: 'reset' });
          await wait(60);
          demoStep({ kind: 'fade', on: false });
        }
        const all = ids();
        if (all.length < 3) return;
        await wait(700);
        // Press the first row and drag down over the next ones, one too far, and back.
        glyph('arrow');
        await glide(on(row(all[0]), 0.62, 0.52), 760);
        glyph('hand');
        demoStep({ kind: 'hover', id: all[0] });
        await wait(160);
        demoStep({ kind: 'select', ids: [all[0]] });
        await wait(260);
        for (let i = 1; i < Math.min(all.length, 4); i += 1) {
          await glide(on(row(all[i]), 0.6, 0.5), 260);
          demoStep({ kind: 'hover', id: all[i] });
          demoStep({ kind: 'select', ids: all.slice(0, i + 1) });
          await wait(120);
        }
        if (all.length > 3) {
          await wait(200);
          await glide(on(row(all[2]), 0.62, 0.55), 300);
          demoStep({ kind: 'hover', id: all[2] });
          demoStep({ kind: 'select', ids: all.slice(0, 3) });
        }
        demoStep({ kind: 'hover', id: null });
        await wait(700);
        await press('delete', 'delete');
        await wait(1300);
        await press('undo', 'undo');
        await wait(1000);
        // Untick the third, and delete the two left.
        glyph('arrow');
        await glide(on(row(all[2])?.querySelector('[role="checkbox"]'), 0.5, 0.5), 600);
        glyph('hand');
        demoStep({ kind: 'hover', id: all[2] });
        await wait(220);
        demoStep({ kind: 'select', ids: all.slice(0, 2) });
        await wait(700);
        demoStep({ kind: 'hover', id: null });
        await press('delete', 'delete');
        await wait(1400);
        await press('purge', 'purge');
        await wait(900);
        glyph('arrow');
        await glide(on(card.current, 0.78, 1.25), 700);
        await wait(1400);
      }
    });
    return () => {
      stop();
      setDemoHover(null);
      setDemoOver(null);
      setFading(false);
    };
  }, [demo, root]);

  // ---- Drawing -----------------------------------------------------------------------------

  const count = picked.length;
  // The header keeps the last count while it fades back to the title.
  if (count) lastCount.current = count;
  const items: ReactNode[] = visible.map((file, i) => {
    const on = selected.has(file.id);
    const prev = i === barAt ? undefined : visible[i - 1];
    const next = i + 1 === barAt ? undefined : visible[i + 1];
    return (
      <li
        key={file.id}
        data-key={`row:${file.id}`}
        className={styles.row}
        data-part="row"
        data-selected={on || undefined}
        data-join-top={(on && prev && selected.has(prev.id)) || undefined}
        data-join-bottom={(on && next && selected.has(next.id)) || undefined}
        data-hover={demoHover === file.id || undefined}
        onPointerDown={onRowDown(file.id)}
        onPointerMove={onRowMove}
        onPointerUp={onRowUp}
        onPointerCancel={() => (drag.current = null)}
      >
        <Face file={file}>
          <button
            ref={(element) => {
              if (element) boxes.current.set(file.id, element);
              else boxes.current.delete(file.id);
            }}
            type="button"
            role="checkbox"
            aria-checked={on}
            aria-label={file.name}
            aria-describedby={`${uid}-${file.id}-meta`}
            data-id={file.id}
            tabIndex={file.id === focusId ? 0 : -1}
            className={styles.box}
            data-part="checkbox"
            onFocus={() => setActive(file.id)}
            onClick={(event) => {
              // A pointer already did its work on press; this is Space or Enter.
              if (event.detail === 0) toggle(file.id);
            }}
          >
            <Check />
          </button>
        </Face>
        <span id={`${uid}-${file.id}-meta`} className={styles.hidden}>
          {file.meta}
        </span>
      </li>
    );
  });

  if (deck.length) {
    const shown = Math.min(deck.length - 1, 2);
    items.splice(
      barAt,
      0,
      <li
        key="bar"
        data-key="bar"
        className={styles.slot}
        style={{ height: ROW + shown * PEEK }}
        data-part="deck"
      >
        {deck.slice(1).map((file, i) => {
          const slot = Math.min(i + 1, 2);
          return (
            <div
              key={file.id}
              className={styles.card}
              data-card={file.id}
              data-slot={i + 1 > 2 ? 'hidden' : slot}
              aria-hidden="true"
            >
              <Face file={file}>
                <span className={styles.box} data-on>
                  <Check />
                </span>
              </Face>
            </div>
          );
        })}
        <div className={styles.bar} data-part="bar" role="group" aria-label="Deleted files">
          <Face file={deck[0]} aria-hidden>
            <span className={styles.box} data-on>
              <Check />
            </span>
          </Face>
          <p className={styles.barText}>
            <span className={styles.barCount}>{plural(deck.length)}</span> deleted
          </p>
          <button
            ref={undoButton}
            type="button"
            className={styles.undo}
            data-part="undo"
            data-over={demoOver === 'undo' || undefined}
            onClick={undo}
            aria-keyshortcuts="Control+Z Meta+Z"
          >
            Undo
          </button>
          <button
            type="button"
            className={styles.purge}
            data-part="purge"
            data-over={demoOver === 'purge' || undefined}
            onClick={purge}
            aria-label={`Delete ${plural(deck.length)} for good`}
          >
            <Bin />
          </button>
        </div>
      </li>,
    );
  }

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={onKeyDown}
      onPointerMove={(event) => {
        // The reader's own pointer over the card takes over from the demo's.
        if (event.pointerType !== 'touch') take();
      }}
    >
      <section
        ref={card}
        className={styles.panel}
        data-part="card"
        data-fading={fading || undefined}
        aria-labelledby={`${uid}-title`}
      >
        <header className={styles.header} data-part="header">
          <div className={styles.head} data-shown={count === 0 || undefined} inert={count > 0}>
            <h2 id={`${uid}-title`} className={styles.title}>
              {title}
              <span className={styles.count} data-part="count" key={visible.length}>
                {visible.length}
              </span>
            </h2>
            <button type="button" className={styles.viewAll} onClick={onViewAll}>
              View all
            </button>
          </div>
          <div className={styles.head} data-shown={count > 0 || undefined} inert={count === 0}>
            <p className={styles.title}>
              <span className={styles.selCount} key={count}>
                {count || lastCount.current}
              </span>
              selected
            </p>
            <button
              type="button"
              className={styles.delete}
              data-part="delete"
              data-over={demoOver === 'delete' || undefined}
              onClick={() => remove(picked.map((file) => file.id))}
              aria-keyshortcuts="Delete"
            >
              <Bin />
              Delete
            </button>
          </div>
        </header>
        <div className={styles.listWrap}>
          <ul
            ref={list}
            className={styles.list}
            aria-label={`${title}: press a row and drag to select several`}
            data-part="list"
          >
            {items}
          </ul>
          <div ref={overlay} className={styles.overlay} aria-hidden="true" />
        </div>
        {!items.length && <p className={styles.empty}>No recent files</p>}
      </section>

      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** A row's face: its box, the kind's badge, the name and the line under it. */
function Face({
  file,
  children,
  'aria-hidden': hidden,
}: {
  file: FileItem;
  children: ReactNode;
  'aria-hidden'?: boolean;
}) {
  return (
    <div className={styles.face} data-part="face" aria-hidden={hidden || undefined}>
      {children}
      <span className={styles.badge} data-kind={file.kind} data-part="badge" aria-hidden="true">
        {file.kind.toUpperCase()}
      </span>
      <span className={styles.text}>
        <span className={styles.name}>{file.name}</span>
        <span className={styles.meta} aria-hidden="true">
          {file.meta}
        </span>
      </span>
    </div>
  );
}

function Check() {
  return (
    <svg className={styles.check} viewBox="0 0 12 12" aria-hidden="true">
      <path
        d="M2.6 6.3l2.2 2.2 4.6-4.9"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.9"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

/** A bin: a lid with its handle, and a body with two slots. */
function Bin() {
  return (
    <svg className={styles.bin} viewBox="0 0 14 14" aria-hidden="true">
      <path
        fill="currentColor"
        fillRule="evenodd"
        d="M5.4 1.2h3.2c.5 0 .9.4.9.9v.6h2.4c.4 0 .7.3.7.7s-.3.7-.7.7H2.1c-.4 0-.7-.3-.7-.7s.3-.7.7-.7h2.4v-.6c0-.5.4-.9.9-.9zm.5 1.5h2.2v-.2H5.9zM2.9 5h8.2l-.5 6.6c-.1.8-.7 1.3-1.5 1.3H4.9c-.8 0-1.4-.5-1.5-1.3zm2.6 1.6c-.3 0-.5.2-.5.5v3.4c0 .3.2.5.5.5s.5-.2.5-.5V7.1c0-.3-.2-.5-.5-.5zm3 0c-.3 0-.5.2-.5.5v3.4c0 .3.2.5.5.5s.5-.2.5-.5V7.1c0-.3-.2-.5-.5-.5z"
      />
    </svg>
  );
}
```

### bulk-delete/bulk-delete.module.css

```css
/* Bulk delete (bulk-delete.tsx): a white card of recent files on a warm light-grey stage, or a
   dark card on a near-black one. The card is 360 px wide: a 42 px header, then 56 px rows on a
   6 px inset. Selected rows share one grey block (rows next to each other join their corners);
   deleted ones become a dark bar with the deck's edges showing under it.

   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). */

.root {
  /* The stage and the card. */
  --stage: #eeefed;
  --card: #fdfdfd;
  --card-edge: rgb(0 0 0 / 0.045);
  --card-shadow: rgb(0 0 0 / 0.05);
  /* Text. */
  --ink: #111111;
  --ink-2: #767676;
  /* Rows: under the pointer, and selected. */
  --hover: #f6f6f6;
  --selected: #efefef;
  /* The box, empty and ticked. */
  --box: #ffffff;
  --box-edge: #cccccc;
  --box-on: #1e1e1e;
  --check: #ffffff;
  /* The header's Delete. */
  --danger: #e5484d;
  --danger-bg: #fcefef;
  --danger-hover: #f9e1e1;
  /* The bar, its buttons and the two cards of the deck under it. */
  --bar: #1e1e1e;
  --bar-ink: #ffffff;
  --bar-ink-2: #bbbbbb;
  --undo: #ffffff;
  --undo-hover: #e4e4e4;
  --undo-ink: #111111;
  --purge: #332628;
  --purge-hover: #4a2a2d;
  --purge-ink: #f5535e;
  --deck-1: #858585;
  --deck-2: #c1c1c1;
  /* The badges, a pale tint per kind. */
  --pdf: #fae8e9;
  --pdf-ink: #d93b4b;
  --fig: #efe7fe;
  --fig-ink: #8a4ff0;
  --png: #e4eefc;
  --png-ink: #3a78e0;
  --xls: #e4f6e9;
  --xls-ink: #2c9d4d;
  --doc: #e6ebfb;
  --doc-ink: #4a5fd6;
  --zip: #fbf1dd;
  --zip-ink: #b7790c;
  /* The same on both schemes: the focus ring, and the demo's pointer, as the system draws it. */
  --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);

  position: relative;
  display: grid;
  justify-items: center;
  align-items: start;
  width: 100%;
  min-height: 440px;
  padding: 72px 16px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#eeefed, #121212);
    --card: light-dark(#fdfdfd, #1c1c1e);
    --card-edge: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.07));
    --card-shadow: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.45));
    --ink: light-dark(#111111, #ededef);
    --ink-2: light-dark(#767676, #8e8e93);
    --hover: light-dark(#f6f6f6, #232325);
    --selected: light-dark(#efefef, #2a2a2d);
    --box: light-dark(#ffffff, #1c1c1e);
    --box-edge: light-dark(#cccccc, #4a4a4e);
    --box-on: light-dark(#1e1e1e, #f0f0f2);
    --check: light-dark(#ffffff, #141414);
    --danger: light-dark(#e5484d, #ff6369);
    --danger-bg: light-dark(#fcefef, #3b1d1f);
    --danger-hover: light-dark(#f9e1e1, #4a2225);
    --bar: light-dark(#1e1e1e, #f0f0f2);
    --bar-ink: light-dark(#ffffff, #111111);
    --bar-ink-2: light-dark(#bbbbbb, #6e6e73);
    --undo: light-dark(#ffffff, #1c1c1e);
    --undo-hover: light-dark(#e4e4e4, #333336);
    --undo-ink: light-dark(#111111, #ffffff);
    --purge: light-dark(#332628, #f6dede);
    --purge-hover: light-dark(#4a2a2d, #f0cccc);
    --purge-ink: light-dark(#f5535e, #d9363e);
    --deck-1: light-dark(#858585, #9c9ca0);
    --deck-2: light-dark(#c1c1c1, #545458);
    --pdf: light-dark(#fae8e9, #3a1d21);
    --pdf-ink: light-dark(#d93b4b, #ff7d8a);
    --fig: light-dark(#efe7fe, #2b2142);
    --fig-ink: light-dark(#8a4ff0, #b894ff);
    --png: light-dark(#e4eefc, #1a2a42);
    --png-ink: light-dark(#3a78e0, #7fb0ff);
    --xls: light-dark(#e4f6e9, #173123);
    --xls-ink: light-dark(#2c9d4d, #5fd38a);
    --doc: light-dark(#e6ebfb, #1d2540);
    --doc-ink: light-dark(#4a5fd6, #93a5ff);
    --zip: light-dark(#fbf1dd, #3a2c14);
    --zip-ink: light-dark(#b7790c, #f1bd5b);
  }
}
/* 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;
}

.root :where(h2, p, ul, li) {
  margin: 0;
  padding: 0;
}
.root :where(button) {
  margin: 0;
  border: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

/* ---- The card --------------------------------------------------------------------------------- */

.panel {
  position: relative;
  width: min(360px, 100%);
  border-radius: 20px;
  background: var(--card);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 8px 24px var(--card-shadow),
    0 1px 3px var(--card-shadow);
}
.panel > * {
  transition: opacity 0.3s ease;
}
.panel[data-fading] > * {
  opacity: 0;
}

/* The header: the title and View all, or the count and Delete, one over the other. */
.header {
  display: grid;
  height: 42px;
  padding: 10px 16px 4px;
}
.head {
  grid-area: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-width: 0;
  opacity: 0;
  filter: blur(3px);
  pointer-events: none;
  transition:
    opacity 0.2s ease,
    filter 0.2s ease;
}
.head[data-shown] {
  opacity: 1;
  filter: none;
  pointer-events: auto;
}
.title {
  display: flex;
  align-items: baseline;
  min-width: 0;
  font-size: 13px;
  line-height: 18px;
  font-weight: 500;
  white-space: nowrap;
}
.count {
  margin-left: 6px;
  color: var(--ink-2);
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  animation: arrive 0.24s ease-out;
}
.selCount {
  display: inline-block;
  margin-right: 5px;
  font-variant-numeric: tabular-nums;
  animation: arrive 0.24s ease-out;
}
@keyframes arrive {
  from {
    opacity: 0;
    filter: blur(3px);
    translate: 0 3px;
  }
}
.viewAll {
  padding: 4px 0;
  background: none;
  color: var(--ink-2);
  font-size: 13px;
  line-height: 18px;
  border-radius: 6px;
}
.viewAll:hover {
  color: var(--ink);
}
.delete {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: 28px;
  padding: 0 10px;
  border-radius: 8px;
  background: var(--danger-bg);
  color: var(--danger);
  font-size: 13px;
  font-weight: 500;
  transition: background-color 0.15s ease;
}
.delete:hover,
.delete[data-over] {
  background: var(--danger-hover);
}
.delete .bin {
  width: 12px;
  height: 12px;
}

/* ---- Rows ------------------------------------------------------------------------------------- */

.listWrap {
  position: relative;
}
.list {
  position: relative;
  list-style: none;
  padding: 0 6px 6px;
}
.overlay {
  position: absolute;
  inset: 0 6px;
  pointer-events: none;
}
.row {
  position: relative;
  height: 56px;
  border-radius: 12px;
  user-select: none;
  -webkit-user-select: none;
  touch-action: pan-y;
  transition:
    background-color 0.16s ease,
    border-radius 0.16s ease;
}
.row[data-selected] {
  background: var(--selected);
}
.row[data-join-top] {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
.row[data-join-bottom] {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}
.row[data-hover]:not([data-selected]) {
  background: var(--hover);
}
@media (hover: hover) {
  .row:not([data-selected]):hover {
    background: var(--hover);
  }
}
.row:has(.box:focus-visible) {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
  z-index: 1;
}

.face {
  display: grid;
  grid-template-columns: 18px 34px minmax(0, 1fr);
  column-gap: 12px;
  align-items: center;
  height: 100%;
  padding: 0 10px;
}

/* The box: a real checkbox for the row, drawn; a plain copy of it on the deck's cards. */
.box {
  position: relative;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  padding: 0;
  border-radius: 5px;
  background: var(--box);
  box-shadow: inset 0 0 0 1.5px var(--box-edge);
  color: var(--check);
  touch-action: none;
  transition:
    background-color 0.12s ease,
    box-shadow 0.12s ease;
}
/* A larger target for the finger than the box shows. */
.box::before {
  content: '';
  position: absolute;
  inset: -12px -10px;
}
.box:focus-visible {
  outline: none;
}
.row[data-selected] .box,
.box[data-on] {
  background: var(--box-on);
  box-shadow: inset 0 0 0 1.5px var(--box-on);
}
.check {
  width: 12px;
  height: 12px;
  opacity: 0;
  scale: 0.6;
  transition:
    opacity 0.12s ease,
    scale 0.2s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.row[data-selected] .check,
.box[data-on] .check {
  opacity: 1;
  scale: 1;
}

.badge {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 9px;
  font-size: 9px;
  line-height: 1;
  font-weight: 700;
  letter-spacing: 0.04em;
}
.badge[data-kind='pdf'] {
  background: var(--pdf);
  color: var(--pdf-ink);
}
.badge[data-kind='fig'] {
  background: var(--fig);
  color: var(--fig-ink);
}
.badge[data-kind='png'] {
  background: var(--png);
  color: var(--png-ink);
}
.badge[data-kind='xls'] {
  background: var(--xls);
  color: var(--xls-ink);
}
.badge[data-kind='doc'] {
  background: var(--doc);
  color: var(--doc-ink);
}
.badge[data-kind='zip'] {
  background: var(--zip);
  color: var(--zip-ink);
}
.text {
  display: grid;
  gap: 1px;
  min-width: 0;
}
.name,
.meta {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.name {
  font-size: 14px;
  line-height: 18px;
  font-weight: 500;
  color: var(--ink);
}
.meta {
  font-size: 12px;
  line-height: 16px;
  color: var(--ink-2);
}

/* ---- The bar and its deck ---------------------------------------------------------------------- */

.slot {
  position: relative;
}
.card,
.bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 56px;
  border-radius: 14px;
  transform-origin: 50% 0;
}
.card {
  overflow: hidden;
}
.card[data-slot='1'] {
  top: 4px;
  left: 15px;
  right: 15px;
  z-index: 2;
  background: var(--deck-1);
}
.card[data-slot='2'],
.card[data-slot='hidden'] {
  top: 8px;
  left: 21px;
  right: 21px;
  z-index: 1;
  background: var(--deck-2);
}
.card[data-slot='hidden'] {
  opacity: 0;
}
/* A card's face shows only while it flies into the deck. */
.card > .face,
.bar > .face {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
}
.bar {
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 0 10px 0 16px;
  background: var(--bar);
  color: var(--bar-ink);
}
.barText {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 14px;
  line-height: 20px;
  color: var(--bar-ink-2);
}
.barCount {
  color: var(--bar-ink);
  font-weight: 500;
}
.undo {
  height: 32px;
  padding: 0 12px;
  border-radius: 8px;
  background: var(--undo);
  color: var(--undo-ink);
  font-size: 14px;
  font-weight: 500;
  transition: background-color 0.15s ease;
}
.undo:hover,
.undo[data-over] {
  background: var(--undo-hover);
}
.purge {
  display: grid;
  place-items: center;
  flex: none;
  width: 32px;
  height: 32px;
  padding: 0;
  border-radius: 8px;
  background: var(--purge);
  color: var(--purge-ink);
  transition: background-color 0.15s ease;
}
.purge:hover,
.purge[data-over] {
  background: var(--purge-hover);
}
.purge .bin {
  width: 14px;
  height: 14px;
}
.bin {
  display: block;
}

.viewAll:focus-visible,
.delete:focus-visible,
.undo:focus-visible,
.purge:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.empty {
  padding: 18px 16px 24px;
  color: var(--ink-2);
  font-size: 13px;
  text-align: center;
}

/* ---- The demo's pointer ---------------------------------------------------------------------- */

.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}

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

@media (prefers-reduced-motion: reduce) {
  .head,
  .row,
  .box,
  .check,
  .panel > * {
    transition: none;
  }
  .count,
  .selCount {
    animation: none;
  }
}
```

### bulk-delete/data.ts

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

/** The kinds of file the list draws a badge for: the badge reads the kind, in its own tint. */
export type FileKind = 'pdf' | 'fig' | 'png' | 'xls' | 'doc' | 'zip';

export type FileItem = {
  /** Unique within the list. */
  id: string;
  /** The file's name, with its extension. */
  name: string;
  kind: FileKind;
  /** The quiet line under the name: size, who edited it, when. */
  meta: string;
};

/** The demo's recent files. */
export const FILES: readonly FileItem[] = [
  { id: 'brand', name: 'Brand guidelines.pdf', kind: 'pdf', meta: '2.4 MB · Edited by Mia' },
  { id: 'landing', name: 'Landing page v3.fig', kind: 'fig', meta: 'Edited by Leo · 2h ago' },
  { id: 'moodboard', name: 'Moodboard.png', kind: 'png', meta: '5.1 MB · Edited by you' },
  { id: 'budget', name: 'Q4 budget.xlsx', kind: 'xls', meta: '86 KB · Yesterday' },
];

/** Two more, for longer lists in the docs. */
export const MORE_FILES: readonly FileItem[] = [
  { id: 'contract', name: 'Supplier contract.doc', kind: 'doc', meta: '312 KB · Edited by Ana' },
  { id: 'assets', name: 'Launch assets.zip', kind: 'zip', meta: '48 MB · Last week' },
];
```

### bulk-delete/demo-pointer.tsx

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

'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, or
 * glide the pointer somewhere. 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.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it. The arrow and the
 * hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

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

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

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

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** 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;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  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.8, y: stage.offsetHeight * 0.9 };

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

  const glide: Demo['glide'] = (to, ms, along) => {
    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.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        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,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).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. Its size comes from its class, its colours
 * from the stage's tokens (bulk-delete.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over what it can press. It is
 * decorative: what it does shows on the list itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <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 className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### bulk-delete/use-autoplay.ts

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

'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, visible, reduced, playing: visible && !reduced && !taken };
}
```