Filter table
NewStatus chips over a small table of tasks: pick one and the rows that leave fold away in place.
filter-table/filter-table.tsxAll 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:
npx shadcn@latest add https://brunodelorence.com/r/filter-table.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
rows | TaskRow[] | five sample tasks | The tasks, a row each: { id, task, date, status, advisor }, status being a status id. |
statuses | TaskStatus[] | 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] | ['Task name', 'Date', 'Status', 'Advisor'] | The four column headers. |
allLabel | string | '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 | '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 | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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 (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));/** 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' },];'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 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.