Product Card Grid
Responsive product cards with sale badges, ratings and a wishlist button layered above the card link so it stays clickable.
Three products side by side with the row labels pinned through a horizontal scroll, an "only show differences" toggle that says how many rows it hid, and "best" marked only where better means something.
components/product-compare-table.tsxnpx hoverlab add product-compare-table
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
2 rows are identical across all 3 and hidden.
| Specification | Field Over-Ear£249 | Field Over-Ear Lite£179 | Studio Reference£329 |
|---|---|---|---|
| Price | £249 | £179best | £329 |
| Battery life | 38 hours | 44 hoursbest | 30 hours |
| Weight | 286 g | 241 gbest | 324 g |
| Noise cancelling | Adaptive, 3 levels | Passive only | Adaptive, 5 levels |
| Driver | 40 mm dynamic | 40 mm dynamic | 50 mm planar |
| Water resistance | IPX4 | IPX4 | None |
| Warranty | 2 years | 2 years | 5 yearsbest |
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <ProductCompareTable> — four products, side by side, differences first.
*
* Product Listings had the grid, the rail, the filter sidebar and the
* collection toolbar: four ways of narrowing a catalogue down. Comparison
* is what happens after the narrowing works and somebody is left holding
* three plausible options, and it is the step where most storefronts hand
* the shopper a spreadsheet or nothing at all.
*
* THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
*
* "Only show differences" is the whole feature. Twenty-two rows of specs,
* fourteen of them identical across every column, is not a comparison — it
* is a data dump that hides the four rows the decision turns on. One
* toggle collapses the matching rows and says how many it hid, so nothing
* is silently gone.
*
* THE ATTRIBUTE COLUMN IS PINNED
*
* Scroll a comparison sideways on a phone and the row labels leave the
* screen; every number after that is unattributed. `position: sticky` on
* the row headers, with an opaque background — sticky over a transparent
* cell is the bug where text from the scrolling column shows through the
* one that is meant to be fixed.
*
* REMOVING A PRODUCT IS UNDOABLE
*
* Comparisons are assembled by hand, item by item, and a column dropped by
* mistake means rebuilding the set. The remove control puts it back.
*
* BETTER IS MARKED, BUT ONLY WHERE BETTER MEANS SOMETHING
*
* A tick on the highest number is nonsense for weight and useful for
* battery life, so the direction lives on the row rather than being
* assumed. Rows where "best" is a matter of taste carry no mark at all —
* quietly declaring a winner on a subjective row is how a comparison table
* stops being trusted.
*
* ACCESSIBILITY: `<th scope="col">` per product and `scope="row"` per
* attribute, so every cell is announced with both of its coordinates.
* The differences toggle is a labelled checkbox with a live region
* reporting what changed, and "best" is a word in the cell, not a colour.
*/
import * as React from 'react'
import { Check, Plus, X } from 'lucide-react'
export interface CompareProduct {
id: string
name: string
price: string
values: Record<string, string>
}
export interface CompareRow {
label: string
/** How to read the row, or 'none' where "best" is a matter of taste. */
better: 'higher' | 'lower' | 'none'
/**
* Extracts the comparable number from a display value.
*
* Optional, and in practice you should leave it out. This block is
* `'use client'`, so a function here cannot reach it from a server
* component — React refuses with "Functions cannot be passed directly to
* Client Components", at build time, during prerender. Every page in the
* catalog is a server component, so that is nearly every caller.
*
* `DEFAULT_ROWS` below still uses functions because module-level defaults
* never cross a boundary. Anyone passing `rows` gets `defaultNumeric`,
* which handles the shapes these tables actually hold.
*/
numeric?: (value: string) => number
}
/**
* Pull the comparable number out of a display value.
*
* The fallback for every row that does not carry its own `numeric` — which
* is every row a server component can pass, since a function cannot cross
* that boundary into a client block. Before this existed, `better: 'higher'`
* from a server page silently highlighted nothing, and supplying the
* obvious fix (a `numeric` function) failed the production build rather
* than the dev render.
*
* Strips everything but digits and a decimal point, so "€490/mo" reads 490,
* "94%" reads 94 and "6 weeks" reads 6. A value with no digits yields NaN,
* and `bestIds` already refuses to mark a winner on any row where a cell
* does not parse — so guessing here cannot produce a wrong highlight, only
* no highlight.
*/
function defaultNumeric(value: string): number {
return parseFloat(value.replace(/[^\d.]/g, ''))
}
export interface ProductCompareTableProps {
products?: CompareProduct[]
rows?: CompareRow[]
className?: string
}
const DEFAULT_ROWS: CompareRow[] = [
{ label: 'Price', better: 'lower', numeric: (v) => Number(v.replace(/[^\d.]/g, '')) },
{ label: 'Battery life', better: 'higher', numeric: (v) => parseFloat(v) },
{ label: 'Weight', better: 'lower', numeric: (v) => parseFloat(v) },
{ label: 'Noise cancelling', better: 'none' },
{ label: 'Driver', better: 'none' },
{ label: 'Charging', better: 'none' },
{ label: 'Water resistance', better: 'none' },
{ label: 'Warranty', better: 'higher', numeric: (v) => parseFloat(v) },
{ label: 'Replaceable battery', better: 'none' },
]
const DEFAULT_PRODUCTS: CompareProduct[] = [
{
id: 'a',
name: 'Field Over-Ear',
price: '£249',
values: {
Price: '£249',
'Battery life': '38 hours',
Weight: '286 g',
'Noise cancelling': 'Adaptive, 3 levels',
Driver: '40 mm dynamic',
Charging: 'USB-C, 3.5 mm passive',
'Water resistance': 'IPX4',
Warranty: '2 years',
'Replaceable battery': 'Yes, by post',
},
},
{
id: 'b',
name: 'Field Over-Ear Lite',
price: '£179',
values: {
Price: '£179',
'Battery life': '44 hours',
Weight: '241 g',
'Noise cancelling': 'Passive only',
Driver: '40 mm dynamic',
Charging: 'USB-C, 3.5 mm passive',
'Water resistance': 'IPX4',
Warranty: '2 years',
'Replaceable battery': 'Yes, by post',
},
},
{
id: 'c',
name: 'Studio Reference',
price: '£329',
values: {
Price: '£329',
'Battery life': '30 hours',
Weight: '324 g',
'Noise cancelling': 'Adaptive, 5 levels',
Driver: '50 mm planar',
Charging: 'USB-C, 3.5 mm passive',
'Water resistance': 'None',
Warranty: '5 years',
'Replaceable battery': 'Yes, by post',
},
},
]
export function ProductCompareTable({
products = DEFAULT_PRODUCTS,
rows = DEFAULT_ROWS,
className = '',
}: ProductCompareTableProps) {
const [removed, setRemoved] = React.useState<string[]>([])
const [differencesOnly, setDifferencesOnly] = React.useState(true)
const shown = products.filter((p) => !removed.includes(p.id))
const isSame = (row: CompareRow) =>
shown.length > 1 && new Set(shown.map((p) => p.values[row.label])).size === 1
const visibleRows = differencesOnly ? rows.filter((r) => !isSame(r)) : rows
const hidden = rows.length - visibleRows.length
/* Direction comes from the row, so nothing declares a winner by guess. */
const bestIds = (row: CompareRow) => {
const read = row.numeric ?? defaultNumeric
if (row.better === 'none' || shown.length < 2) return []
const scored = shown.map((p) => ({ id: p.id, n: read(p.values[row.label] ?? '') }))
if (scored.some((s) => Number.isNaN(s.n))) return []
const target =
row.better === 'higher'
? Math.max(...scored.map((s) => s.n))
: Math.min(...scored.map((s) => s.n))
return scored.filter((s) => s.n === target).map((s) => s.id)
}
return (
<section className={`mx-auto w-full max-w-4xl px-4 py-16 sm:px-6 ${className}`}>
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">
Comparing {shown.length} of {products.length}
</h2>
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={differencesOnly}
onChange={(event) => setDifferencesOnly(event.target.checked)}
/*
accent-primary, not a hand-rolled box: these tokens are
oklch(), so hsl(var(--primary)) is not a colour and the
check renders browser-blue.
*/
className="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
Only show differences
</label>
</div>
{/* Nothing is silently gone. */}
<p aria-live="polite" className="mt-1 min-h-5 text-xs text-muted-foreground">
{differencesOnly && hidden > 0
? `${hidden} ${hidden === 1 ? 'row is' : 'rows are'} identical across all ${shown.length} and hidden.`
: 'Showing every row, identical or not.'}
</p>
{removed.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{removed.map((id) => {
const product = products.find((p) => p.id === id)
if (!product) return null
return (
<button
key={id}
type="button"
onClick={() => setRemoved((r) => r.filter((x) => x !== id))}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-dashed border-border px-2.5 text-xs font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Plus aria-hidden className="h-3.5 w-3.5" />
Put {product.name} back
</button>
)
})}
</div>
) : null}
{/*
`relative` is load-bearing. Tailwind's `sr-only` is `position:
absolute`, and an overflow box only clips an absolutely positioned
descendant when it is also that descendant's containing block.
Without it the per-column "Remove <product>" labels laid out at their static position,
escaped this scroller and dragged the page sideways on a phone,
pointing at nothing anyone could see. Same fix as /compare.
*/}
<div className="relative mt-4 -mx-1 overflow-x-auto px-1">
<table className="w-full min-w-[36rem] border-collapse text-start text-sm">
<caption className="sr-only">
Specifications compared across {shown.length} products.
{differencesOnly && hidden > 0
? ` ${hidden} identical rows are hidden.`
: ''}
</caption>
<thead>
<tr className="border-b border-border align-bottom">
{/* Opaque, or the scrolling column shows through it. */}
<th
scope="col"
className="sticky start-0 z-10 w-40 bg-card px-3 py-3 text-xs font-medium text-muted-foreground"
>
Specification
</th>
{shown.map((product) => (
<th key={product.id} scope="col" className="px-3 py-3">
<span className="flex items-start justify-between gap-2">
<span className="min-w-0">
<span className="block text-sm font-semibold text-foreground">
{product.name}
</span>
<span className="block text-xs font-normal text-muted-foreground">
{product.price}
</span>
</span>
<button
type="button"
onClick={() => setRemoved((r) => [...r, product.id])}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-3.5 w-3.5" />
<span className="sr-only">
Remove {product.name} from the comparison
</span>
</button>
</span>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{visibleRows.map((row) => {
const best = bestIds(row)
return (
<tr key={row.label}>
<th
scope="row"
className="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground"
>
{row.label}
</th>
{shown.map((product) => {
const isBest = best.includes(product.id)
return (
<td key={product.id} className="px-3 py-2.5 text-foreground">
<span className="flex items-center gap-1.5">
{product.values[row.label] ?? '—'}
{isBest ? (
/* A word, not only a tick and a colour. */
<span className="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
<Check aria-hidden className="h-3 w-3" />
best
</span>
) : null}
</span>
</td>
)
})}
</tr>
)
})}
</tbody>
</table>
</div>
{shown.length < 2 ? (
<p className="mt-4 rounded-xl border border-border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
One product left. Put another back to compare against it.
</p>
) : null}
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/product-compare-table.tsx and import it where you need the section:
import { ProductCompareTable } from '@/components/product-compare-table'Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
products | CompareProduct[] | DEFAULT_PRODUCTS |
rows | CompareRow[] | DEFAULT_ROWS |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Product Comparison Table — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-4xl px-4 py-16 sm:px-6 ">
<div class="rounded-2xl border border-border bg-card p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-base font-semibold text-foreground">Comparing 3 of 3</h2>
<label class="flex cursor-pointer items-center gap-2 text-sm text-foreground"><input type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />Only show differences</label>
</div>
<p aria-live="polite" class="mt-1 min-h-5 text-xs text-muted-foreground">2 rows are identical across all 3 and hidden.</p>
<div class="relative mt-4 -mx-1 overflow-x-auto px-1">
<table class="w-full min-w-[36rem] border-collapse text-start text-sm">
<caption class="sr-only">Specifications compared across 3 products. 2 identical rows are hidden.</caption>
<thead>
<tr class="border-b border-border align-bottom">
<th scope="col" class="sticky start-0 z-10 w-40 bg-card px-3 py-3 text-xs font-medium text-muted-foreground">Specification</th>
<th scope="col" class="px-3 py-3">
<span class="flex items-start justify-between gap-2">
<span class="min-w-0">
<span class="block text-sm font-semibold text-foreground">Field Over-Ear</span>
<span class="block text-xs font-normal text-muted-foreground">£249</span>
</span>
<button type="button" class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3.5 w-3.5" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
<span class="sr-only">Remove Field Over-Ear from the comparison</span>
</button>
</span>
</th>
<th scope="col" class="px-3 py-3">
<span class="flex items-start justify-between gap-2">
<span class="min-w-0">
<span class="block text-sm font-semibold text-foreground">Field Over-Ear Lite</span>
<span class="block text-xs font-normal text-muted-foreground">£179</span>
</span>
<button type="button" class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3.5 w-3.5" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
<span class="sr-only">Remove Field Over-Ear Lite from the comparison</span>
</button>
</span>
</th>
<th scope="col" class="px-3 py-3">
<span class="flex items-start justify-between gap-2">
<span class="min-w-0">
<span class="block text-sm font-semibold text-foreground">Studio Reference</span>
<span class="block text-xs font-normal text-muted-foreground">£329</span>
</span>
<button type="button" class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3.5 w-3.5" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
<span class="sr-only">Remove Studio Reference from the comparison</span>
</button>
</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr>
<th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Price</th>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">£249</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">£179<span class="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>best</span></span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">£329</span>
</td>
</tr>
<tr>
<th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Battery life</th>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">38 hours</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">44 hours<span class="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>best</span></span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">30 hours</span>
</td>
</tr>
<tr>
<th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Weight</th>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">286 g</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">241 g<span class="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>best</span></span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">324 g</span>
</td>
</tr>
<tr>
<th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Noise cancelling</th>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">Adaptive, 3 levels</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">Passive only</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">Adaptive, 5 levels</span>
</td>
</tr>
<tr>
<th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Driver</th>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">40 mm dynamic</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">40 mm dynamic</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">50 mm planar</span>
</td>
</tr>
<tr>
<th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Water resistance</th>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">IPX4</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">IPX4</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">None</span>
</td>
</tr>
<tr>
<th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Warranty</th>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">2 years</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">2 years</span>
</td>
<td class="px-3 py-2.5 text-foreground">
<span class="flex items-center gap-1.5">5 years<span class="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>best</span></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Product ListingsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Responsive product cards with sale badges, ratings and a wishlist button layered above the card link so it stays clickable.
Category, size, colour and price facets as proper fieldsets, with colour swatches that carry their name as well as their hue.
Result count, sort and density switch — with the count in an aria-live region, since it is the only feedback a filter change gives some users.
A "you might also like" row on native scroll-snap — touch swiping, momentum and keyboard support for free, with no carousel library.
Availability per variant stated before the cart, because discovering it at checkout is where the basket gets abandoned.
The editorial header above a collection grid, carrying the four facts that decide a considered purchase.
Results on one side, a drawn map on the other, scrolling independently. The map is explicitly decorative and out of the tab order — the list is the content, not its companion.
A restaurant menu typeset as a bill of fare rather than a product grid — dotted leaders to the price, dietary marks as abbreviations with a key, and prices as strings so “market price” still fits.