Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Filter table

New

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

filter-table/filter-table.tsx
Live · interactive
Task nameDateStatusAdvisor
Restock mango sorbetDec 03To doMango Moon Gelato
Churn black sesameSep 22In ProgressKumo Creamery
Print summer menuJan 02To doCoral Coast Sorbet
Taste-test batch 42Nov 08In ProgressMaple Orbit
Order waffle conesApr 14CompletedAurora Scoops

All 5 tasks shown

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

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.

You can add this component with the shadcn CLI or by hand:

Into components/filter-table of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/filter-table.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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

board.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}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.

  • 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.
PropTypeDefaultDescription
rowsTaskRow[]five sample tasksThe tasks, a row each: { id, task, date, status, advisor }, status being a status id.
statusesTaskStatus[]To do, In Progress, CompletedThe 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]['Task name', 'Date', 'Status', 'Advisor']The four column headers.
allLabelstring'All'The label of the first chip, the one that shows every row.
valuestring—The chip pressed, 'all' or a status id, when the parent holds it.
defaultValuestring'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.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

filter-table.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.
filter-table.module.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));
data.ts
/** 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' },];
demo-pointer.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>;
use-autoplay.ts
'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;
  • 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.