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.jsonProduct | SKU | Stock | Margin | Status |
|---|---|---|---|---|
| Wireless Mouse | WM-2201 | 128/ 40 reorder pt. | 38.5% | In Stock |
| USB-C Hub | UCH-1140 | 12/ 25 reorder pt. | 22.0% | Low Stock |
| Mechanical Keyboard | MK-7702 | 0/ 15 reorder pt. | 18.4% | Backordered |
| 27" Monitor | MN-2701 | 6/ 10 reorder pt. | 8.2% | Low Stock |
| Laptop Stand | LS-3390 | 240/ 50 reorder pt. | 41.7% | In Stock |
| Webcam 1080p | WC-0087 | 4/ 10 reorder pt. | 6.9% | Low Stock |
| Bluetooth Speaker | BS-5510 | 0/ 20 reorder pt. | 15.3% | Backordered |
| Wired Earbuds | WE-0042 | 18/ 30 reorder pt. | 27.1% | Discontinued |
| Desk Lamp | DL-1123 | 85/ 30 reorder pt. | 33.0% | In Stock |
| Cable Organizer | CO-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.
export interface FormatRule<TData> {test: (row: TData) => booleanclassName: stringlabel?: 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.
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.
// 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.
// 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.
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>