Diff table
NewThe edits an assistant proposes to a table, shown in the table itself: removals struck through in red, additions sliding in green, each one a toggle.
diff-table/diff-table.tsx| Flavor | Category | Supplier |
|---|---|---|
| Rocky Road | Classic | aurora-scoops |
| Bubblegum | Retro | kumo-creamery |
| Mint Chip | Classic | maple-orbit |
PistachioSeasonalmaple-orbit | ||
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
- An assistant proposes edits to rows of data and the person should see them in the table itself.
- A handful of changes, up to ten or so, that can each be kept or left out on its own.
- Removals and additions to records with a short name: products, members, rows of a sheet.
- In a chat or a side panel, where the proposal waits for one press of Apply before anything changes.
When not to use
- Edits inside a cell, word by word. Use a text diff with the changed words marked.
- Hundreds of changes. Show a summary and a filterable list, not every row in a card.
- Changes that only make sense together (a rename and its references). Ask once with a confirmation.
- Edits already made. Say what changed with a toast and an Undo instead.
- Code or long files: a side-by-side or unified diff reads better than a table.
You can add this component with the shadcn CLI or by hand:
Into components/diff-table of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/diff-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 { DiffTable, type DiffRow } from './diff-table';const columns = [ { key: 'name', label: 'Name', width: '40%', kind: 'name' as const }, { key: 'team', label: 'Team', width: '28%', kind: 'tag' as const }, { key: 'email', label: 'Email', width: '32%', kind: 'text' as const },];export function ProposedCleanup({ rows, onApply }: { rows: DiffRow[]; onApply: (ids: string[]) => void }) { return ( <DiffTable title="Proposed contact cleanup" columns={columns} rows={rows} autoplay={false} onApply={({ removed, added }) => onApply([...removed, ...added].map((row) => row.id))} /> );}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 small table that takes an assistant’s proposal in place. The rows it would remove turn red and are struck through, the rows it would add open in green, and each change carries a box. A press anywhere on a changed row leaves it out or brings it back, the footer counts what is still ticked, and Apply turns the footer into a green “3 edits applied” pill.
- The proposal arrives in two beats once the table is first on screen: 900 ms in, the rows to remove tint red (background 150 ms, words 200 ms, their tag fading to 55 %), then 260 ms later the boxes, the hint and the footer appear together while the rows to add open.
- A row being added sits in a full-width cell whose grid goes from grid-template-rows: 0fr to 1fr, with its opacity, over 200 ms on cubic-bezier(0.23, 1, 0.32, 1). The row inside is clipped while it grows, so its height is never measured and any content opens the same way.
- The footer rises 8 px as it fades in (180 ms, same ease-out); once applied, its contents swap for a pill that pops in from 95 % over 180 ms.
- Each change’s box is a real checkbox, visually hidden behind an 18 px drawn box; the row around it is the target, so a press anywhere on it flips the change, and the row draws the focus ring for the checkbox inside with :has(:focus-visible).
- An unticked removal goes back to an ordinary row; an unticked addition stays in place but greyed, so the reader still sees what was offered. The box un-ticks by colour and a 0.92 scale on 150 ms.
- The counts are derived from the ticked set on every render: “2 removals · 1 addition”, “Apply 3 changes”, with singulars and a disabled button at zero. Applying hands onApply the rows themselves, removed and added, and freezes the table as it stands.
- While on screen and untouched, a demo leaves the addition out, then a removal, takes both back, presses Apply, and after a pause clears the table and proposes again. Its toggles never reach onChange or onApply; any key or press hands the table to the reader.
- Every colour is a light-dark() token on the root, so the table follows the page’s color-scheme or the colorScheme prop. Removals and additions are tints of the same red and green as their words: translucent at night, solid pale colours by day.
| Prop | Type | Default | Description |
|---|---|---|---|
rows | DiffRow[] | four sample flavours | The rows, in order: { id, cells, change?, tone? }. cells holds each column’s value by key; change is 'remove' or 'add' for a proposed edit; tone colours the dot in the row’s tags (blue, grey, green or red). |
columns | DiffColumn[] | Flavor, Category, Supplier | The columns: { key, label, width, kind? }. kind 'name' is the strong first cell, 'tag' a small pill with a dot, 'text' a quiet value; the last column carries the boxes. |
defaultState | DiffTableState | — | Opens held in a phase ('idle', 'marked', 'proposed' or 'applied'), with no sweep in, and with the ticked changes’ ids (all by default). Turns the demo off. |
onApply | (selection: DiffSelection) => void | — | Called when the reader applies, with the ticked rows: { removed, added }. |
onChange | (ids: string[]) => void | — | Called with the ids of the ticked changes whenever the reader toggles one. |
title | string | 'Proposed menu cleanup' | The card’s title. |
hint | string | 'Click changed rows to toggle' | The quiet line beside the title while the changes can be toggled. |
summaryLabel | (removals: number, additions: number) => string | “2 removals · 1 addition” | The footer’s count of the ticked changes. |
applyLabel | (count: number) => string | “Apply 3 changes” | The button’s words. |
appliedLabel | (count: number) => string | “3 edits applied” | The pill shown once the changes are applied. |
reveal | boolean | true | The proposal arrives in its two beats once the table is first on screen; false shows it at once. |
autoplay | boolean | true | While on screen and untouched, a demo toggles a few rows, applies, and proposes again. |
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, useRef, useState, type CSSProperties, type MouseEvent } from 'react';import { useAutoplay } from './use-autoplay';import styles from './diff-table.module.css';/* * Diff table: the edits an assistant proposes to a table, shown in the table itself. Rows it * would remove turn red and are struck through, rows it would add slide in green, and each * change has a box the reader unticks to leave it out before applying the rest. * * - The proposal arrives in two beats once the table is on screen: 900 ms in, the rows to remove * tint red (background 150 ms, words 200 ms, their tag fading to 55 %); 260 ms later the boxes, * the hint and the footer appear, and the rows to add open by grid-template-rows 0fr → 1fr * (200 ms on a soft ease-out), so their height is never measured. * - A changed row is one big toggle: a press anywhere on it ticks or unticks its box. The box is * a real checkbox, visually hidden, so Tab reaches it and Space or Enter flips it; the row draws * the focus ring around itself. * - An unticked removal goes back to an ordinary row; an unticked addition stays in place, greyed. * The footer counts what is still ticked and the button applies just that. * - Applying freezes the table as it stands and swaps the footer's contents for a green * "3 edits applied" pill that pops in (180 ms, from 95 %)./* Diff table (diff-table.tsx): a soft grey stage with a 380 px card in its middle, or at night a graphite stage with the card a step lighter. 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). The card's shadow is one list for both schemes, each layer coloured for one of them and clear in the other. Motion is colour and opacity on the rows, and grid-template-rows on the rows being added, so nothing is measured. */.root { /* The stage, the card, and the sunken pills and boxes. */ --canvas: oklch(96.1% 0.002 247.84); --surface: oklch(100% 0 0); --inset: oklch(97.9% 0.002 247.839); /* Words: strong, quiet, and quieter. */ --ink: oklch(24.7% 0.006 258.361); --ink-2: oklch(50.6% 0.01 264.477); --ink-3: oklch(69.5% 0.009 264.505); --line: oklch(94.6% 0.003 264.542); /* Apply, and Apply under the pointer; also the blue dots and the focus ring. */ --accent: oklch(62.6% 0.205 254.947); --accent-hover: oklch(55.6% 0.187 255.617);'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;- It is a real table with column headers, inside a section named by the title. Each change has a native checkbox named for what it does (“Remove Rocky Road”, “Add Pistachio”), so a screen reader hears the change and its state.
- Tab reaches each change; Space or Enter flips it, and the whole row shows a 1 px accent focus ring. Apply is a button, disabled when nothing is ticked, with its own focus ring.
- A polite live region says the new count after each toggle (“1 removal · 1 addition”) and “3 edits applied” once applied; the demo’s toggles are not announced.
- Once applied the checkboxes are disabled, so the table reads as final.
- Under reduced motion the proposal is shown at once, rows change colour and open without transitions, the footer and pill appear without their entrance, and the demo never plays.