Product comparison
Three products across only the attributes that differ.
Live preview
Source
This exact file renders the preview above.
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Table } from '@/components/ui/table'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'
/**
* Product comparison
*
* Three products across the attributes that actually differ. Uses the Table
* primitive's scroll strategy, so the comparison grid survives a 390px screen
* inside a labelled, focusable scroll region rather than collapsing into
* unreadable stacks.
*/
interface Row {
id: string
attribute: string
values: string[]
}
const compared = products.slice(0, 3)
const rows: Row[] = [
{ id: 'price', attribute: 'Price', values: compared.map((p) => formatCurrency(p.priceCents)) },
{ id: 'material', attribute: 'Material', values: compared.map((p) => p.material) },
{ id: 'category', attribute: 'Category', values: compared.map((p) => p.category) },
{
id: 'rating',
attribute: 'Rating',
values: compared.map((p) => `${p.rating} / 5 (${p.reviews})`),
},
{
id: 'stock',
attribute: 'Availability',
values: compared.map((p) => (p.inStock ? 'In stock' : 'Out of stock')),
},
]
export default function ProductComparisonRow() {
return (
<section className="border-b border-line bg-surface-sunken py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Compare the three layers.
</h2>
<p className="mt-3 text-md text-ink-muted">
Only the attributes that differ — a comparison table where every row matches teaches
nothing.
</p>
</div>
<div className="mt-10">
<Table
caption="Comparison of three products across price, material, category, rating and availability"
rows={rows}
rowKey={(row) => row.id}
columns={[
{
key: 'attribute',
header: 'Attribute',
width: '25%',
cell: (row) => <span className="font-medium text-ink-strong">{row.attribute}</span>,
},
...compared.map((product, index) => ({
key: product.slug,
header: (
<span className="flex flex-col items-start gap-1">
<span className="text-ink-strong normal-case">{product.name}</span>
{product.badge ? <Badge size="sm">{product.badge}</Badge> : null}
</span>
),
cell: (row: Row) => <span className="text-ink">{row.values[index]}</span>,
})),
]}
/>
</div>
</Container>
</section>
)
}
components/blocks/sections/product/comparison-row.tsx
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Table } from '@/components/ui/table'
import { formatCurrency } from '@/lib/format'
import { products } from '@/content/demo'
/**
* Product comparison
*
* Three products across the attributes that actually differ. Uses the Table
* primitive's scroll strategy, so the comparison grid survives a 390px screen
* inside a labelled, focusable scroll region rather than collapsing into
* unreadable stacks.
*/
interface Row {
id: string
attribute: string
values: string[]
}
const compared = products.slice(0, 3)
const rows: Row[] = [
{ id: 'price', attribute: 'Price', values: compared.map((p) => formatCurrency(p.priceCents)) },
{ id: 'material', attribute: 'Material', values: compared.map((p) => p.material) },
{ id: 'category', attribute: 'Category', values: compared.map((p) => p.category) },
{
id: 'rating',
attribute: 'Rating',
values: compared.map((p) => `${p.rating} / 5 (${p.reviews})`),
},
{
id: 'stock',
attribute: 'Availability',
values: compared.map((p) => (p.inStock ? 'In stock' : 'Out of stock')),
},
]
export default function ProductComparisonRow() {
return (
<section className="border-b border-line bg-surface-sunken py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Compare the three layers.
</h2>
<p className="mt-3 text-md text-ink-muted">
Only the attributes that differ — a comparison table where every row matches teaches
nothing.
</p>
</div>
<div className="mt-10">
<Table
caption="Comparison of three products across price, material, category, rating and availability"
rows={rows}
rowKey={(row) => row.id}
columns={[
{
key: 'attribute',
header: 'Attribute',
width: '25%',
cell: (row) => <span className="font-medium text-ink-strong">{row.attribute}</span>,
},
...compared.map((product, index) => ({
key: product.slug,
header: (
<span className="flex flex-col items-start gap-1">
<span className="text-ink-strong normal-case">{product.name}</span>
{product.badge ? <Badge size="sm">{product.badge}</Badge> : null}
</span>
),
cell: (row: Row) => <span className="text-ink">{row.values[index]}</span>,
})),
]}
/>
</div>
</Container>
</section>
)
}
components/ui/table.tsx
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
/**
* Table
*
* A real `<table>` with `<caption>`, scoped headers and semantic rows — the
* only markup screen readers can navigate cell by cell.
*
* Two responsive strategies are supported, because neither works everywhere:
*
* `scroll` keeps the grid and puts it in a labelled, keyboard-focusable
* scroll region (a scrollable area must be reachable by keyboard).
* `stack` collapses each row into a labelled card below `md`, which reads
* better for short, wide records.
*/
export interface Column<Row> {
key: string
header: ReactNode
/** Cell renderer. Receives the row and its index. */
cell: (row: Row, index: number) => ReactNode
align?: 'start' | 'center' | 'end'
/** Hides the column below `md` in scroll mode. */
hideOnMobile?: boolean
width?: string
}
export interface TableProps<Row> {
caption: string
/** Hides the caption visually while leaving it for assistive tech. */
hideCaption?: boolean
columns: Array<Column<Row>>
rows: Row[]
rowKey: (row: Row, index: number) => string
responsive?: 'scroll' | 'stack'
empty?: ReactNode
className?: string
density?: 'compact' | 'default'
}
const alignClass = { start: 'text-left', center: 'text-center', end: 'text-right' } as const
export function Table<Row>({
caption,
hideCaption = true,
columns,
rows,
rowKey,
responsive = 'scroll',
empty,
className,
density = 'default',
}: TableProps<Row>) {
if (rows.length === 0 && empty) {
return <div className={className}>{empty}</div>
}
const cellPadding = density === 'compact' ? 'px-3 py-1.5' : 'px-4 py-2.5'
const table = (
<table className="w-full border-collapse text-sm">
<caption className={cn('text-left text-xs text-ink-muted', hideCaption ? 'sr-only' : 'pb-3')}>
{caption}
</caption>
<thead>
<tr className="border-b border-line bg-surface-sunken">
{columns.map((column) => (
<th
key={column.key}
scope="col"
style={column.width ? { width: column.width } : undefined}
className={cn(
'label-caps text-ink-muted',
cellPadding,
alignClass[column.align ?? 'start'],
responsive === 'scroll' && column.hideOnMobile && 'hidden md:table-cell',
)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr
key={rowKey(row, index)}
className="border-b border-line-subtle last:border-0 hover:bg-surface-sunken/60"
>
{columns.map((column) => (
<td
key={column.key}
className={cn(
'text-ink',
cellPadding,
alignClass[column.align ?? 'start'],
responsive === 'scroll' && column.hideOnMobile && 'hidden md:table-cell',
)}
>
{column.cell(row, index)}
</td>
))}
</tr>
))}
</tbody>
</table>
)
if (responsive === 'stack') {
return (
<div className={className}>
{/* Below md: one labelled card per record. */}
<ul className="flex flex-col gap-2 md:hidden">
{rows.map((row, index) => (
<li key={rowKey(row, index)} className="rounded-lg border border-line bg-surface p-3">
<dl className="flex flex-col gap-1.5">
{columns.map((column) => (
<div key={column.key} className="flex items-baseline justify-between gap-3">
<dt className="label-caps shrink-0 text-ink-subtle">{column.header}</dt>
<dd className="min-w-0 text-right text-sm text-ink">
{column.cell(row, index)}
</dd>
</div>
))}
</dl>
</li>
))}
</ul>
<div className="hidden overflow-hidden rounded-lg border border-line md:block">{table}</div>
</div>
)
}
return (
<div className={cn('overflow-hidden rounded-lg border border-line', className)}>
<div
tabIndex={0}
role="region"
aria-label={caption}
className="thin-scrollbar overflow-x-auto focus-visible:outline-2 focus-visible:-outline-offset-2"
>
{table}
</div>
</div>
)
}
lib/format.ts
/** Small formatting helpers shared across catalogue and starter surfaces. */
export function titleCase(value: string): string {
return value
.split(/[-_\s]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
export function pluralise(count: number, singular: string, plural = `${singular}s`): string {
return `${count} ${count === 1 ? singular : plural}`
}
const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
})
export function formatCurrency(cents: number): string {
return currencyFormatter.format(cents / 100)
}
const compactFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
})
export function formatCompact(value: number): string {
return compactFormatter.format(value)
}
/**
* Dates in Foundry are authored as ISO date strings so that server and client
* renders agree byte-for-byte. Formatting is pinned to `en-US` + UTC for the
* same reason — no hydration drift from the visitor's locale or timezone.
*/
export function formatDate(iso: string): string {
const date = new Date(`${iso}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
}).format(date)
}
export function formatShortDate(iso: string): string {
const date = new Date(`${iso}T00:00:00Z`)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
}).format(date)
}
export function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return '?'
if (parts.length === 1) return (parts[0] ?? '?').slice(0, 2).toUpperCase()
return `${(parts[0] ?? '')[0] ?? ''}${(parts[parts.length - 1] ?? '')[0] ?? ''}`.toUpperCase()
}
export function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
A comparison table where every row matches teaches nothing, so only differing attributes are shown.
- Use the scroll strategy; a comparison that collapses into stacked cards is no longer a comparison.
- Put the recommended product first, and say why with a badge.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Three products
- Five attributes
- Scroll strategy
Accessibility
- Scoped headers
- Product columns use scope, so cell navigation announces the product.
- Focusable scroll
- The overflow container is a labelled, focusable region.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All sectionsPricing feature matrix
The detailed plan comparison, with every cell paired with hidden text.
intermediate3 variantsTable
A semantic table with two responsive strategies, a focusable scroll region and a built-in empty state.
intermediateFeatured5 variantsProduct detail split
Gallery on one side, the buying decision on the other, in sequence.
intermediateFeatured3 variants