Marks the cells that actually differ in a comparison table and shows blanks as "Not stated", so people don't pick the wrong plan.
StopsPicked the wrong plan from a comparison
"use client"
import { DiffTable, type DiffColumn, type DiffRow } from "@/components/ui/diff-table"
const columns: DiffColumn[] = [
{ id: "starter", label: "Starter", description: "₹0/mo" },
{ id: "team", label: "Team", description: "₹799/mo" },
{ id: "business", label: "Business", description: "₹2,499/mo" },
]
// The vendor's sheet left Team's audit log blank and put a dash for Starter's SLA.
// Storage reads "100GB" vs "100 GB": the same value, so it isn't marked.
const rows: DiffRow[] = [
{ id: "seats", label: "Seats", values: { starter: 1, team: 10, business: "Unlimited" } },
{ id: "storage", label: "Storage", values: { starter: "5 GB", team: "100GB", business: "100 GB" } },
{ id: "sso", label: "SSO", values: { starter: false, team: false, business: true } },
{ id: "audit", label: "Audit log", values: { starter: false, business: true } },
{ id: "api", label: "API access", values: { starter: true, team: true, business: true } },
{ id: "sla", label: "Uptime SLA", values: { starter: "—", team: "99.9%", business: "99.9%" } },
{ id: "domain", label: "Custom domain", values: { starter: true, team: true, business: true } },
{ id: "2fa", label: "Two-factor login", values: { starter: true, team: true, business: true } },
{ id: "support", label: "Support", values: { starter: "Email", team: "Email", business: "Priority" } },
]
export function Demo() {
return <DiffTable columns={columns} rows={rows} caption="Plan comparison" defaultDifferencesOnly className="max-w-lg" />
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uidiff-table.tsx245 lines"use client" // Bollard UI — diff-table · MIT licence · https://bollard-ui.vercel.app/components/diff-table import * as React from "react" import { Check, CircleHelp, X } from "lucide-react" import { cn } from "@/lib/utils" /** `true`/`false` read as Included/Not included. `null`, `undefined`, "" and a bare dash are missing. */ export type DiffCellValue = string | number | boolean | null | undefined export interface DiffColumn { id: string label: string /** Small line under the label, such as a price. */ description?: string } export interface DiffRow { id: string label: string /** One value per column id. Leave a column out when the source doesn't say. */ values: Record<string, DiffCellValue> } export interface RowComparison { /** `same`: every column agrees. `different`: known values disagree. `unknown`: they agree but some are missing. */ status: "same" | "different" | "unknown" /** Columns whose value stands out from the rest. */ differing: string[] /** Columns with no value, which must not be read as "no". */ missing: string[] } const isMissing = (value: DiffCellValue) => value == null || (typeof value === "string" && /^\s*[-–—]?\s*$/.test(value)) /** Case, spacing and "10 GB" vs "10GB" don't count as differences; ticks and "Yes" are the same. */ export function normalizeCell(value: DiffCellValue): string { if (typeof value === "boolean") return value ? "yes" : "no" const text = String(value).trim().toLowerCase().replace(/\s+/g, " ").replace(/(\d) (?=\p{L})/gu, "$1").replace(/(\d),(?=\d)/g, "$1") if (/^(yes|included|true|✓|✔)$/.test(text)) return "yes" if (/^(no|not included|false|✗|✕|×)$/.test(text)) return "no" return text } /** * Compares one row across columns. With three or more columns the odd ones out are * marked (a clear majority stays plain); otherwise every known value is marked. * A row with a missing value is never "same": the gap might hide a difference. */ export function compareRow(row: DiffRow, columnIds: string[]): RowComparison { const missing = columnIds.filter((id) => isMissing(row.values[id])) const known = columnIds.filter((id) => !missing.includes(id)) const groups = new Map<string, string[]>() for (const id of known) { const key = normalizeCell(row.values[id]) groups.set(key, [...(groups.get(key) ?? []), id]) } if (groups.size <= 1) return { status: missing.length ? "unknown" : "same", differing: [], missing } const sizes = [...groups.values()].map((ids) => ids.length).sort((a, b) => b - a) const majority = sizes[0] > sizes[1] && known.length >= 3 ? [...groups.values()].find((ids) => ids.length === sizes[0]) : undefined return { status: "different", differing: known.filter((id) => !majority?.includes(id)), missing } } export interface DiffTableProps extends React.ComponentProps<"div"> { /** The things being compared, left to right. */ columns: DiffColumn[] /** The attributes, top to bottom. */ rows: DiffRow[] /** Controlled "show only differences" state. */ differencesOnly?: boolean /** Initial state when uncontrolled. */ defaultDifferencesOnly?: boolean /** Called when the toggle changes. */ onDifferencesOnlyChange?: (differencesOnly: boolean) => void /** Accessible caption of the table. */ caption?: string /** Label of the toggle. */ toggleLabel?: string /** Text in a cell with no value. */ missingLabel?: string /** Note under a row with missing values. Receives the column labels that are missing. */ missingNote?: (columns: string) => string /** Screen reader text for `true` and `false`. */ yesLabel?: string /** Text read for a No cell. */ noLabel?: string /** Line under a filtered table. Receives how many identical rows are hidden. */ hiddenNote?: (count: number) => string /** Count beside the toggle. Receives how many rows are identical. */ sameCountLabel?: (count: number) => string /** Button that turns the filter off. Receives the total number of rows. */ showAllLabel?: (count: number) => string /** Screen reader text after a value that stands out. */ differsLabel?: string /** When set, numbers in cells are formatted for this locale (1000 → "1,000"). Left unset they print as given. */ locale?: string } /** * A comparison table for plans, products or versions that does the spotting for you: * cells that differ are marked, "Show only differences" hides rows that are truly * identical (and says how many), and a blank cell is shown as "Not stated", never as * a dash that reads as "No", and keeps its row visible because it may hide a difference. */ export function DiffTable({ columns, rows, differencesOnly, defaultDifferencesOnly = false, onDifferencesOnlyChange, caption = "Comparison", toggleLabel = "Show only differences", missingLabel = "Not stated", missingNote = (names) => `Not stated for ${names}. Don’t read it as “No”.`, yesLabel = "Included", noLabel = "Not included", hiddenNote = (count) => `${count} identical ${count === 1 ? "row" : "rows"} hidden.`, sameCountLabel = (count) => `(${count} same)`, showAllLabel = (count) => `Show all ${count}`, differsLabel = "(differs)", locale, className, ...props }: DiffTableProps) { const [inner, setInner] = React.useState(defaultDifferencesOnly) const filtered = differencesOnly ?? inner const ids = columns.map((c) => c.id) const compared = rows.map((row) => ({ row, ...compareRow(row, ids) })) const sameCount = compared.filter((r) => r.status === "same").length const shown = filtered ? compared.filter((r) => r.status !== "same") : compared const nameOf = (id: string) => columns.find((c) => c.id === id)?.label ?? id const setFiltered = (next: boolean) => { if (differencesOnly === undefined) setInner(next) onDifferencesOnlyChange?.(next) } const renderValue = (value: DiffCellValue) => { const key = typeof value === "number" ? "" : normalizeCell(value) if (key === "yes") return ( <> <Check aria-hidden className="size-4 text-emerald-600 dark:text-emerald-400" /> <span className="sr-only">{yesLabel}</span> </> ) if (key === "no") return ( <> <X aria-hidden className="size-4 text-muted-foreground" /> <span className="sr-only">{noLabel}</span> </> ) return typeof value === "number" && locale ? value.toLocaleString(locale) : String(value) } return ( <div data-slot="diff-table" className={cn("grid w-full min-w-0 gap-2", className)} {...props}> <label className="flex w-fit cursor-pointer items-center gap-2 text-sm font-medium"> <button type="button" role="switch" aria-checked={filtered} onClick={() => setFiltered(!filtered)} className="inline-flex h-5 w-9 shrink-0 items-center rounded-full border border-transparent bg-input p-0.5 transition-colors outline-none aria-checked:bg-primary focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/80 dark:aria-checked:bg-primary" > <span className={cn("size-4 rounded-full bg-background shadow-xs transition-transform duration-200 ease-out", filtered && "translate-x-4")} /> </button> {toggleLabel} {sameCount ? <span className="font-normal text-muted-foreground">{sameCountLabel(sameCount)}</span> : null} </label> <div className="overflow-x-auto rounded-lg border"> <table className="w-full border-collapse text-sm"> <caption className="sr-only">{caption}</caption> <thead> <tr className="border-b bg-muted"> <td className="sticky left-0 bg-muted px-3 py-2" /> {columns.map((column) => ( <th key={column.id} scope="col" className="min-w-24 px-3 py-2 text-left align-bottom font-semibold"> {column.label} {column.description ? <span className="block text-xs font-normal text-muted-foreground">{column.description}</span> : null} </th> ))} </tr> </thead> <tbody key={String(filtered)}> {shown.map(({ row, status, differing, missing }) => ( <tr key={row.id} data-status={status} className="border-b last:border-b-0"> <th scope="row" className="sticky left-0 z-10 max-w-40 min-w-24 bg-card px-3 py-2 text-left align-top font-medium"> {row.label} {missing.length ? ( <span className="mt-0.5 block text-xs font-normal text-amber-700 dark:text-amber-400">{missingNote(missing.map(nameOf).join(", "))}</span> ) : null} </th> {columns.map((column) => { const value = row.values[column.id] const differs = differing.includes(column.id) return ( <td key={column.id} data-differs={differs || undefined} className={cn( "px-3 py-2 align-top tabular-nums", differs && "bg-amber-500/10 font-medium motion-safe:animate-[cui-diff-table-flash_400ms_ease-out] dark:bg-amber-400/10", )} > {missing.includes(column.id) ? ( <span className="inline-flex items-center gap-1 rounded-md border border-dashed whitespace-nowrap border-amber-500/60 px-1.5 py-0.5 text-xs text-amber-800 dark:text-amber-300"> <CircleHelp aria-hidden className="size-3.5" /> {missingLabel} </span> ) : ( <span className="inline-flex items-center gap-1"> {renderValue(value)} {differs ? <span className="sr-only"> {differsLabel}</span> : null} </span> )} </td> ) })} </tr> ))} </tbody> </table> </div> {filtered && sameCount ? ( <p className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground" aria-live="polite"> <span className="py-1">{hiddenNote(sameCount)}</span> <button type="button" onClick={() => setFiltered(false)} className="inline-flex h-6 items-center rounded-md border px-2 font-medium text-foreground transition-colors hover:bg-accent outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {showAllLabel(rows.length)} </button> </p> ) : null} <style>{`@keyframes cui-diff-table-flash{from{background-color:rgb(245 158 11 / .35)}}`}</style> </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { DiffTable, type DiffColumn, type DiffRow } from "@/components/ui/diff-table"
return <DiffTable columns={columns} rows={rows} caption="Plan comparison" defaultDifferencesOnly className="max-w-lg" />What it catches
- Real differences hidden among identical rows
- Formatting-only differences that aren't real (case, spaces, units spacing)
- Blank cells that would be read as "No"
Props
| Prop | Type | Default |
|---|---|---|
columns* (required)The things being compared, left to right. | DiffColumn[] | — |
rows* (required)The attributes, top to bottom. | DiffRow[] | — |
differencesOnlyControlled "show only differences" state. | boolean | — |
defaultDifferencesOnlyInitial state when uncontrolled. | boolean | false |
onDifferencesOnlyChangeCalled when the toggle changes. | (differencesOnly: boolean) => void | — |
captionAccessible caption of the table. | string | "Comparison" |
toggleLabelLabel of the toggle. | string | "Show only differences" |
missingLabelText in a cell with no value. | string | "Not stated" |
missingNoteNote under a row with missing values. Receives the column labels that are missing. | (columns: string) => string | (names) => `Not stated for ${names}. Don’t read it as “No”.` |
yesLabelScreen reader text for `true` and `false`. | string | "Included" |
noLabelText read for a No cell. | string | "Not included" |
hiddenNoteLine under a filtered table. Receives how many identical rows are hidden. | (count: number) => string | (count) => `${count} identical ${count === 1 ? "row" : "r… |
sameCountLabelCount beside the toggle. Receives how many rows are identical. | (count: number) => string | (count) => `(${count} same)` |
showAllLabelButton that turns the filter off. Receives the total number of rows. | (count: number) => string | (count) => `Show all ${count}` |
differsLabelScreen reader text after a value that stands out. | string | "(differs)" |
localeWhen set, numbers in cells are formatted for this locale (1000 → "1,000"). Left unset they print as given. | string | — |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- A real <table> with a <caption>
- The filter is a role="switch" with aria-checked
- Yes/No cells use text plus icons; differing cells say so in text
- The hidden-rows note is aria-live="polite"
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Normalisation is simple; values that mean the same in different words still show as different
- Rows with blank cells stay visible even when showing only differences
What it stops
Picked the wrong plan from a comparison
How people describe it
Also known as
comparison table · pricing table · compare plans · product compare · spec sheet · differences · feature matrix · pricing