# Filter table

> Status chips over a small table of tasks: pick one and the rows that leave fold away in place.

A row of status chips, each with its colour and its count, over a table of tasks. Pick a chip and the table keeps only the tasks in that status: the rows that leave fold shut and fade where they stand, the ones that come back open again, and the table grows or shrinks around them. On screen, a pointer goes through the chips by itself.

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

Category: AI.

## Anatomy

1. **Chip row**: A group of toggle buttons, “All” first, then one per status in the order given. It scrolls sideways when it runs out of room, its scrollbar hidden.
2. **Pressed chip**: The one filter on (`aria-pressed`). It lifts onto a white pill by day, a raised one at night, its colours and shadow easing in 200 ms.
3. **Status dot**: The status’s own colour, the same one the pill in the table is mixed from. “All” has none.
4. **Count**: How many tasks are in that status, in tabular figures, on a grey tile when its chip is pressed. A screen reader hears “2 tasks”.
5. **Table surface**: A raised, rounded region holding a table 420 px wide at least; narrower, it scrolls sideways and takes keyboard focus.
6. **Header**: The four column names, task, date, status and advisor, on the same grid as every row.
7. **Row**: Stays in its place whatever the filter. Filtered out, it folds to no height and fades in 300 ms, and turns inert and hidden from screen readers.
8. **Status pill**: The status’s name, its ink, fill and edge mixed in CSS from the status colour and the surface, so any colour works in both schemes.

## When to use

- A short list of records, up to a few dozen, sliced by one field with a handful of values: status, stage, priority.
- When the reader needs to know how many sit in each value before choosing: every chip carries its count.
- One filter at a time, where “All” is the natural way back and no two values combine.
- A dashboard card or a side panel, where the rows should fold in place rather than jump or reload.

## When not to use

- Several filters at once, or values that combine (To do and In Progress). Use a filter bar with menus or checkboxes.
- Hundreds of rows or a filter the server applies. Fold animations on every row cost too much; use a paged data table.
- More than five or six values: the chips run off the row and scroll. Use a select or a segmented menu.
- Searching by text. Use a search field over the table; the chips only match a field exactly.
- Sorting or reordering. The rows never move here: use a table with sortable headers.

## Installation

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

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

export function Board({ tasks }: { tasks: TaskRow[] }) {
  const [status, setStatus] = useState('all');
  return (
    <FilterTable
      rows={tasks}
      value={status}
      onValueChange={setStatus}
      autoplay={false}
    />
  );
}
```

## States

- **All**: Every row shown. “All” is pressed and counts every task.
- **To do**: Only the two To do rows stay open; the others fold away and the card shrinks around them.
- **In Progress**: A different pair stays: the rows never reorder, they open and fold where they are.
- **Completed**: A single row left; the live line tells a screen reader “1 of 5 tasks shown”.
- **No match**: A status with no tasks: its chip counts 0 and the table keeps only its header.
- **Dark**: Set by `colorScheme="dark"`, or taken from the page: every colour is a `light-dark()` token.

## How it works

- Every row stays in the table, in its place: the filter only folds rows away. Each sits in a one-track grid whose grid-template-rows animates between 1fr and 0fr while it fades, 300 ms on cubic-bezier(0.23, 1, 0.32, 1), so no height is ever measured and rows never reorder.
- Rows leaving and rows coming back move in the same 300 ms, so the table changes height in one motion; the card keeps the column centred, and the chips ride along as it grows or shrinks.
- A folded row is inert and aria-hidden, so neither focus nor a screen reader lands in it.
- The chips are toggle buttons. The pressed one lifts onto the table’s surface with a hairline edge and a shadow, its count on a small grey tile; colours and shadow change in 200 ms.
- A status is just a colour: the chip’s dot, and the pill in the table, whose ink, fill and edge are mixed from it with color-mix() against the table’s surface (20 and 34 % by day, 34 % at night). Any status a caller adds is tinted the same way.
- The table keeps a 420 px minimum and scrolls sideways in its own focusable region on a narrow screen, as the chip row does above it; the demo scrolls a chip into view before it presses it.
- While on screen and untouched, a demo pointer presses To do, In Progress, Completed and All in turn and rests on a row after each; any input of the reader’s hands it over.
- 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.

## API reference

- **rows** (`TaskRow[]`, default `five sample tasks`): The tasks, a row each: { id, task, date, status, advisor }, status being a status id.
- **statuses** (`TaskStatus[]`, default `To do, In Progress, Completed`): The statuses, a chip each after “All”: { id, label, color }. The colour is the dot’s, and the pill’s ink and tints are mixed from it.
- **columns** (`[string, string, string, string]`, default `['Task name', 'Date', 'Status', 'Advisor']`): The four column headers.
- **allLabel** (`string`, default `'All'`): The label of the first chip, the one that shows every row.
- **value** (`string`): The chip pressed, 'all' or a status id, when the parent holds it.
- **defaultValue** (`string`, default `'all'`): The chip pressed at first, when the component holds it.
- **onValueChange** (`(value: string) => void`): Called with 'all' or a status id when a chip is pressed.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer goes through the chips by itself. Never under reduced motion.
- **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 chips are a labelled group of toggle buttons with aria-pressed, each read with its count (“To do, 2 tasks”); the dots are decorative.
- The table has table, row, columnheader and cell roles; its wrappers are presentational, and a folded row is inert and aria-hidden.
- A polite live line says how many tasks show after each change.
- The table’s region is focusable, so it can be scrolled sideways from the keyboard on a narrow screen. Focus shows a ring on every control.
- Under reduced motion rows fold and unfold instantly and the demo never plays.

## Source

### filter-table/filter-table.tsx

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

'use client';

import { useEffect, useEffectEvent, useId, useRef, useState, type CSSProperties } from 'react';
import { COLUMNS, ROWS, STATUSES, type TaskRow, type TaskStatus } from './data';
import { DemoPointer, runDemo, type Demo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './filter-table.module.css';

export type { TaskRow, TaskStatus } from './data';

/*
 * Filter table: a row of status chips over a small table of tasks. Pick a chip and the table keeps
 * only the tasks in that status; "All" brings every one back.
 *
 * - Every row stays in the table, in its place. A row that leaves folds away: its wrapper animates
 *   grid-template-rows from 1fr to 0fr and fades out, while the rows that come in unfold and fade
 *   in, all at once, in 300 ms on a fast ease-out. Nothing is measured, the rows never reorder, and
 *   the table's height follows on its own, the card staying centred as it grows or shrinks.
 * - A folded row is inert and hidden from screen readers; a live line says how many tasks show.
 * - The chips are toggle buttons (aria-pressed); the one pressed lifts onto a white (or, at night,
 *   raised) pill, its count on a small grey tile. Each status brings its own colour: the chip's dot,
 *   and the pill in the table, its ink and tints mixed from that colour in the CSS.
 * - The table keeps a minimum width; on a narrow screen it scrolls sideways in its own region,
 *   like the chips above it.
 * - While on screen and untouched, a demo pointer goes through the chips, To do, In Progress,
 *   Completed and back to All, and rests on a row on the way; any input of the reader's hands it
 *   over.
 * - 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 id of the chip that shows every row. */
const ALL = 'all';

export function FilterTable({
  rows = ROWS,
  statuses = STATUSES,
  columns = COLUMNS,
  allLabel = 'All',
  value,
  defaultValue = ALL,
  onValueChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The tasks, a row each, in the order they show. */
  rows?: TaskRow[];
  /** The statuses, a chip each after "All", in this order. */
  statuses?: TaskStatus[];
  /** The four column headers: task, date, status, advisor. */
  columns?: readonly [string, string, string, string];
  /** The first chip's label, the one that shows every row. */
  allLabel?: string;
  /** The chip pressed, 'all' or a status id, when the parent holds it. */
  value?: string;
  /** The chip pressed at first, when the component holds it. */
  defaultValue?: string;
  /** Called with 'all' or a status id when a chip is pressed. */
  onValueChange?: (value: string) => void;
  /** While on screen and untouched, a demo pointer goes through the chips by itself. */
  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);
  const pointer = useRef<HTMLSpanElement>(null);
  const id = useId();

  const [own, setOwn] = useState(defaultValue);
  const filter = value ?? own;
  const choose = (next: string) => {
    if (value === undefined) setOwn(next);
    onValueChange?.(next);
  };

  const chips = [
    { id: ALL, label: allLabel, color: undefined, count: rows.length },
    ...statuses.map((status) => ({
      ...status,
      count: rows.filter((row) => row.status === status.id).length,
    })),
  ];
  const shown = rows.filter((row) => filter === ALL || row.status === filter).length;

  // The demo presses chips as a click would; when a parent holds the value, it goes through
  // onValueChange like any other press.
  const demoChoose = useEffectEvent((next: string) => choose(next));
  const demo = autoplay && playing;
  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?: Point) => {
      hover(null);
      await moveTo(element, at);
      hover(element);
    };
    const order = [...statuses.map((status) => status.id), ALL];

    const stop = runDemo(stage, arrow, async (steps) => {
      const { wait, press } = steps;
      demoChoose(ALL);
      await wait(1800);
      for (;;) {
        for (const next of order) {
          const chip = find(`[data-chip="${CSS.escape(next)}"]`);
          // On a narrow screen the chip may sit past the row's edge: bring it in first.
          const bar = chip?.parentElement;
          if (chip && bar && bar.scrollWidth > bar.clientWidth) {
            const left = chip.offsetLeft - bar.offsetLeft - 4;
            const right = left + chip.offsetWidth + 8 - bar.clientWidth;
            const to = Math.min(Math.max(bar.scrollLeft, right), left);
            if (to !== bar.scrollLeft) {
              bar.scrollTo({ left: to, behavior: 'smooth' });
              await wait(350);
            }
          }
          await visit(steps, chip, { x: 0.45, y: 0.55 });
          await wait(380);
          await press(() => demoChoose(next));
          await wait(900);
          // A glance at the first row left, as a reader would.
          if (next !== ALL) {
            const row = find('[data-row][data-shown]');
            await visit(steps, row, { x: 0.32, y: 0.55 });
            await wait(1100);
          } else {
            await visit(steps, stage, { x: 0.86, y: 0.88 });
            hover(null);
            await wait(2400);
          }
        }
      }
    });
    return () => {
      stop();
      hover(null);
    };
  }, [demo, root, statuses]);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.column}>
        <div
          className={styles.chips}
          data-part="chips"
          role="group"
          aria-label="Filter tasks by status"
        >
          {chips.map((chip) => (
            <button
              key={chip.id}
              type="button"
              className={styles.chip}
              data-part="chip"
              data-chip={chip.id}
              aria-pressed={filter === chip.id}
              aria-controls={`${id}-table`}
              onClick={() => choose(chip.id)}
            >
              {chip.color && (
                <span
                  className={styles.dot}
                  data-part="dot"
                  style={{ background: chip.color }}
                  aria-hidden="true"
                />
              )}
              {chip.label}
              <span className={styles.count} data-part="count">
                <span className={styles.hidden}>, </span>
                {chip.count}
                <span className={styles.hidden}> {chip.count === 1 ? 'task' : 'tasks'}</span>
              </span>
            </button>
          ))}
        </div>
        <div
          className={styles.scroller}
          data-part="table"
          role="region"
          aria-label="Tasks"
          // The region scrolls sideways on a narrow screen: the keyboard can reach it.
          tabIndex={0}
        >
          <div className={styles.table} role="table" id={`${id}-table`} aria-rowcount={shown + 1}>
            <div className={styles.head} data-part="header" role="row">
              {columns.map((column) => (
                <span key={column} className={styles.th} role="columnheader">
                  {column}
                </span>
              ))}
            </div>
            {rows.map((row) => {
              const status = statuses.find((s) => s.id === row.status);
              const on = filter === ALL || row.status === filter;
              return (
                <div
                  key={row.id}
                  className={styles.fold}
                  role="none"
                  data-shown={on || undefined}
                  inert={!on}
                  aria-hidden={!on || undefined}
                >
                  <div className={styles.clip} role="none">
                    <div
                      className={styles.row}
                      data-part="row"
                      role="row"
                      data-row={row.id}
                      data-shown={on || undefined}
                    >
                      <span className={styles.td} role="cell">
                        <span className={styles.task}>{row.task}</span>
                      </span>
                      <span className={`${styles.td} ${styles.date}`} role="cell">
                        {row.date}
                      </span>
                      <span className={styles.td} role="cell">
                        <span
                          className={styles.pill}
                          data-part="status"
                          style={{ '--tone': status?.color ?? 'gray' } as CSSProperties}
                        >
                          {status?.label ?? row.status}
                        </span>
                      </span>
                      <span className={`${styles.td} ${styles.advisor}`} role="cell">
                        <span className={styles.truncate}>{row.advisor}</span>
                      </span>
                    </div>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
        <p className={styles.hidden} aria-live="polite">
          {shown === rows.length
            ? `All ${rows.length} tasks shown`
            : `${shown} of ${rows.length} tasks shown`}
        </p>
      </div>
      <DemoPointer ref={pointer} />
    </div>
  );
}
```

### filter-table/filter-table.module.css

```css
/* Filter table (filter-table.tsx): a soft grey card, and in its middle a column 420 px wide at
   most: the status chips, and under them the table on a raised surface.

   Colours are the tokens below, light-dark(light, dark): 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. A row folds and unfolds by animating its wrapper's grid-template-rows between 0fr and
   1fr, with a fade, so no height is ever measured; the chips change by transitions of their
   colours and shadow. Under reduced motion all of it is instant. */

.root {
  /* The card, and the ink in three strengths. */
  --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));
  /* Hairlines: the card's edge; the table's rules, a little lighter. */
  --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
  --grid-line: color-mix(in srgb, var(--line) 78%, transparent);
  /* The table and the pressed chip; the pressed chip's count; hovers. */
  --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
  --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
  --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
  /* The pressed chip's lift: an edge and a soft halo by day, a lighter edge and a short drop at
     night. */
  --chip-edge: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
  --chip-halo: light-dark(#0000000a, transparent);
  --chip-drop: light-dark(transparent, oklch(0% 0 0 / 0.3));
  /* The table's: a long soft shadow by day, two short deep ones at night. */
  --card-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
  --card-1: light-dark(#00000008, transparent);
  --card-2: light-dark(#00000005, transparent);
  --card-3: light-dark(#00000003, transparent);
  --card-night: light-dark(transparent, oklch(0% 0 0 / 0.2));
  /* 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);
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease: cubic-bezier(0.4, 0, 0.2, 1);

  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 298px;
  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;
}
.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 *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}

/* A bare reset, weaker than any class below. */
:where(.root) button {
  margin: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  cursor: pointer;
}

.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 0;
}

/* The column: 420 px at most. */
.column {
  width: 100%;
  max-width: 420px;
  min-width: 0;
}

/* The chips: a row that scrolls sideways when it runs out of room, its scrollbar hidden. Its
   padding keeps the pressed chip's shadow and the focus ring clear of the scroll's clip. */
.chips {
  display: flex;
  align-items: center;
  gap: 4px;
  margin: 0 -4px 4px;
  padding: 4px;
  overflow-x: auto;
  scrollbar-width: none;
}
.chips::-webkit-scrollbar {
  display: none;
}

.chip {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  gap: 6px;
  height: 26px;
  padding: 0 10px;
  border-radius: 999px;
  color: var(--ink-2);
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
  white-space: nowrap;
  transition:
    background-color 200ms var(--ease),
    box-shadow 200ms var(--ease),
    color 200ms var(--ease);
}
.chip[aria-pressed='true'] {
  background-color: var(--surface);
  box-shadow:
    0 0 0 1px var(--chip-edge),
    0 0 4px 0 var(--chip-halo),
    0 1px 2px 0 var(--chip-drop);
  color: var(--ink);
}

.dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
}

.count {
  padding: 0 4px;
  border-radius: 4px;
  color: var(--ink-3);
  font-size: 10.5px;
  line-height: 1.5;
  font-variant-numeric: tabular-nums;
}
.chip[aria-pressed='true'] .count {
  background-color: var(--field);
  color: var(--ink-2);
}

/* The table's surface: rounded and raised, it scrolls sideways when narrower than the table. */
.scroller {
  overflow-x: auto;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 18px 47px 0 var(--card-1),
    0 7.5px 19px 0 var(--card-2),
    0 4px 10.5px 0 var(--card-2),
    0 2.3px 5.8px 0 var(--card-3),
    0 1.2px 3.1px 0 var(--card-3),
    0 0.5px 1.3px 0 var(--card-3),
    0 1px 2px 0 var(--card-night),
    0 2px 6px 0 var(--card-night);
  scrollbar-width: none;
}
.scroller::-webkit-scrollbar {
  display: none;
}
.scroller:focus-visible {
  outline-offset: 2px;
}

.table {
  min-width: 420px;
}

/* The header and every row share their columns. */
.head,
.row {
  display: grid;
  grid-template-columns:
    minmax(0, 1.3fr) minmax(0, 0.6fr) minmax(0, 0.95fr)
    minmax(0, 0.9fr);
  border-bottom: 1px solid var(--grid-line);
}

.head {
  color: var(--ink-2);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.5;
}

.th,
.td {
  padding: 8px 12px;
  border-right: 1px solid var(--grid-line);
}
.th:last-child,
.td:last-child {
  border-right: 0;
}

/* A row's fold: open, its one track is as tall as the row; folded, nothing. */
.fold {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition:
    grid-template-rows 300ms var(--ease-out),
    opacity 300ms var(--ease-out);
}
.fold[data-shown] {
  grid-template-rows: 1fr;
  opacity: 1;
}
.clip {
  min-height: 0;
  overflow: hidden;
}

.row {
  font-size: 13px;
  line-height: 1.5;
  transition: background-color 100ms var(--ease);
}

.td {
  display: flex;
  align-items: center;
  min-width: 0;
}

.task,
.truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.task {
  color: var(--ink);
  font-weight: 500;
}

.date {
  color: var(--ink-2);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.advisor {
  color: var(--ink-2);
}

/* A status: its ink and its two tints are mixed from its colour (--tone, set by the row) and
   the table's surface. */
.pill {
  --pill-ink: light-dark(
    color-mix(in srgb, var(--tone) 93.5%, black),
    color-mix(in srgb, var(--tone) 86%, white)
  );
  --pill-fill: light-dark(
    color-mix(in srgb, var(--tone) 20%, var(--surface)),
    color-mix(in srgb, var(--tone) 34%, var(--surface))
  );
  --pill-edge: light-dark(color-mix(in srgb, var(--tone) 34%, var(--surface)), var(--pill-fill));

  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  height: 23px;
  padding: 0 7px;
  border: 1px solid var(--pill-edge);
  border-radius: 8px;
  background: var(--pill-fill);
  color: var(--pill-ink);
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
}

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

/* Hovers: the reader's pointer, where it can hover, or the demo's ([data-hover]). */
@media (hover: hover) {
  .chip[aria-pressed='false']:hover,
  .row:hover {
    background-color: var(--hover);
  }
}
.chip[aria-pressed='false'][data-hover],
.row[data-hover] {
  background-color: var(--hover);
}

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

### filter-table/data.ts

```tsx
/** A status a task can be in: its id, its name on the chip and the pill, and its colour. */
export type TaskStatus = {
  id: string;
  label: string;
  /** Any CSS colour: the chip's dot, and the pill's ink and tints are mixed from it. */
  color: string;
};

/** One row of the table. */
export type TaskRow = {
  id: string;
  task: string;
  date: string;
  /** The id of one of the statuses. */
  status: string;
  advisor: string;
};

export const STATUSES: TaskStatus[] = [
  { id: 'todo', label: 'To do', color: '#f09a2f' },
  { id: 'progress', label: 'In Progress', color: '#16a6c7' },
  { id: 'done', label: 'Completed', color: '#25a878' },
];

export const ROWS: TaskRow[] = [
  {
    id: 'sorbet',
    task: 'Restock mango sorbet',
    date: 'Dec 03',
    status: 'todo',
    advisor: 'Mango Moon Gelato',
  },
  {
    id: 'sesame',
    task: 'Churn black sesame',
    date: 'Sep 22',
    status: 'progress',
    advisor: 'Kumo Creamery',
  },
  {
    id: 'menu',
    task: 'Print summer menu',
    date: 'Jan 02',
    status: 'todo',
    advisor: 'Coral Coast Sorbet',
  },
  {
    id: 'batch',
    task: 'Taste-test batch 42',
    date: 'Nov 08',
    status: 'progress',
    advisor: 'Maple Orbit',
  },
  {
    id: 'cones',
    task: 'Order waffle cones',
    date: 'Apr 14',
    status: 'done',
    advisor: 'Aurora Scoops',
  },
];

/** The column headers, in order: task, date, status, advisor. */
export const COLUMNS = ['Task name', 'Date', 'Status', 'Advisor'] as const;
```

### filter-table/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 (filter-table.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>
  );
}
```

### filter-table/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 };
}
```