Search docs

Jump to any table example

Discord
ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

Conditional Formatting Table

Excel-style conditional formatting — a small, reusable rule engine highlights out-of-stock rows, colors margin percentages, and re-colors status badges, all driven by the same row-level rules instead of one-off logic per column.

npx shadcn add https://shad-table.dev/r/conditional-formatting-table.json
Product
SKU
Stock
Margin
Status
Wireless MouseWM-2201
128/ 40 reorder pt.
38.5%In Stock
USB-C HubUCH-1140
12/ 25 reorder pt.
22.0%Low Stock
Mechanical KeyboardMK-7702
0/ 15 reorder pt.
18.4%Backordered
27" MonitorMN-2701
6/ 10 reorder pt.
8.2%Low Stock
Laptop StandLS-3390
240/ 50 reorder pt.
41.7%In Stock
Webcam 1080pWC-0087
4/ 10 reorder pt.
6.9%Low Stock
Bluetooth SpeakerBS-5510
0/ 20 reorder pt.
15.3%Backordered
Wired EarbudsWE-0042
18/ 30 reorder pt.
27.1%Discontinued
Desk LampDL-1123
85/ 30 reorder pt.
33.0%In Stock
Cable OrganizerCO-9981
300/ 60 reorder pt.
52.4%In Stock

How it works

1.A rule is just a predicate plus a className

matchRule takes a row and a list of { test, className } rules and returns the first one whose test passes — that's the entire engine. There's no rule-parsing DSL or condition builder; a rule is just a plain JS function, so anything you can express in TypeScript (comparing two fields, checking a date range, matching a string) is a valid rule.

src/components/conditional-formatting/rules.ts
export interface FormatRule<TData> {
test: (row: TData) => boolean
className: string
label?: string
}
export function matchRule<TData>(row: TData, rules: FormatRule<TData>[]) {
return rules.find((rule) => rule.test(row))
}

2.Rules see the whole row, not just their own cell

test receives the full row, not the cell's value in isolation — that's what lets the Stock column flag a row as low without a separate 'reorder point' column doing anything special. Excel's conditional formatting is usually single-cell; cross-field comparisons like stock < reorderPoint are exactly the case that trips up naive per-value implementations.

src/components/conditional-formatting/columns.tsx
const stockRules: FormatRule<InventoryItem>[] = [
{ test: (row) => row.stock === 0, className: '...', label: 'Out of stock' },
{ test: (row) => row.stock < row.reorderPoint, className: '...', label: 'Below reorder point' },
]

3.Order matters — first match wins, most specific first

Every row with 0 stock also satisfies stock < reorderPoint, so if the low-stock rule came first, an out-of-stock row would just look like a regular low-stock row. Listing 'out of stock' before 'below reorder point' is what keeps the more urgent condition from getting silently masked by the more general one.

src/components/conditional-formatting/columns.tsx
// 0 stock also satisfies "< reorderPoint" — order is the guard, not a
// clever predicate. This has to come first:
{ test: (row) => row.stock === 0, className: '...' },
{ test: (row) => row.stock < row.reorderPoint, className: '...' },

4.The same engine drives three visually different treatments

Stock renders a full pill background with an icon, Margin renders colored text only, and Status renders a Badge with its variant color overridden — three different visual languages, but all three cells call the same matchRule and just decide what to do with the className differently. The formatting logic never has to know how its result will be displayed.

src/components/conditional-formatting/columns.tsx
// Stock — full-bleed pill
<div className={cn('rounded px-2 py-1', rule?.className)}>
// Margin — text color only
<span className={cn('tabular-nums', rule?.className)}>
// Status — Badge, variant color replaced by the rule
<Badge variant="outline" className={cn('border-transparent', rule?.className)}>

5.No match is a valid, silent outcome

matchRule returns undefined when nothing matches, and every cell handles that with cn(..., rule?.className) — undefined is simply dropped from the class list. A healthy row (in stock, decent margin, active status) needs zero special-case code; it just renders with no rule applied, which is the correct default for conditional formatting.

src/components/conditional-formatting/columns.tsx
const rule = matchRule(row.original, marginRules)
// rule is undefined for anything between 10% and 30% margin
<span className={cn('tabular-nums', rule?.className)}>
{row.original.marginPct.toFixed(1)}%
</span>