Pricing feature matrix
The detailed plan comparison, with every cell paired with hidden text.
Live preview
Source
This exact file renders the preview above.
import { Check, Minus } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Table } from '@/components/ui/table'
/**
* Pricing feature matrix
*
* The detailed comparison that belongs below a tier grid, not instead of one.
* Uses the Table primitive's scroll strategy so the matrix keeps its grid on
* narrow screens inside a focusable, labelled scroll region.
*
* Ticks and dashes are paired with visually hidden text, so a screen-reader
* user hears "Included" rather than an unnamed icon.
*/
interface Row {
id: string
feature: string
solo: boolean
team: boolean
business: boolean
note?: string
}
const rows: Row[] = [
{ id: 'r1', feature: 'Every component and section', solo: true, team: true, business: true },
{ id: 'r2', feature: 'Light and dark schemes', solo: true, team: true, business: true },
{ id: 'r3', feature: 'All five palettes', solo: false, team: true, business: true },
{ id: 'r4', feature: 'Starter products', solo: false, team: true, business: true },
{ id: 'r5', feature: 'Private component registry', solo: false, team: false, business: true },
{
id: 'r6',
feature: 'Accessibility review sessions',
solo: false,
team: false,
business: true,
note: 'Quarterly',
},
{ id: 'r7', feature: 'Named support contact', solo: false, team: false, business: true },
]
function Cell({ included }: { included: boolean }) {
return included ? (
<>
<Check className="mx-auto size-4 text-success" aria-hidden="true" />
<span className="sr-only">Included</span>
</>
) : (
<>
<Minus className="mx-auto size-4 text-ink-subtle" aria-hidden="true" />
<span className="sr-only">Not included</span>
</>
)
}
export default function FeatureMatrixPricing() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Compare the plans in detail.
</h2>
<p className="mt-3 text-md text-ink-muted">
The full matrix, for the person who has already chosen a shortlist.
</p>
</div>
<div className="mt-10">
<Table
caption="Feature comparison across Solo, Team and Business plans"
hideCaption={false}
rows={rows}
rowKey={(row) => row.id}
columns={[
{
key: 'feature',
header: 'Feature',
width: '50%',
cell: (row) => (
<span className="text-ink">
{row.feature}
{row.note ? (
<span className="ml-2 text-xs text-ink-subtle">{row.note}</span>
) : null}
</span>
),
},
{
key: 'solo',
header: 'Solo',
align: 'center',
cell: (row) => <Cell included={row.solo} />,
},
{
key: 'team',
header: 'Team',
align: 'center',
cell: (row) => <Cell included={row.team} />,
},
{
key: 'business',
header: 'Business',
align: 'center',
cell: (row) => <Cell included={row.business} />,
},
]}
/>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<ButtonLink href="/forms/register">Start with Team</ButtonLink>
<ButtonLink href="/forms/sales-inquiry" variant="outline">
Talk about Business
</ButtonLink>
</div>
</Container>
</section>
)
}
components/blocks/sections/pricing/feature-matrix.tsx
import { Check, Minus } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { Table } from '@/components/ui/table'
/**
* Pricing feature matrix
*
* The detailed comparison that belongs below a tier grid, not instead of one.
* Uses the Table primitive's scroll strategy so the matrix keeps its grid on
* narrow screens inside a focusable, labelled scroll region.
*
* Ticks and dashes are paired with visually hidden text, so a screen-reader
* user hears "Included" rather than an unnamed icon.
*/
interface Row {
id: string
feature: string
solo: boolean
team: boolean
business: boolean
note?: string
}
const rows: Row[] = [
{ id: 'r1', feature: 'Every component and section', solo: true, team: true, business: true },
{ id: 'r2', feature: 'Light and dark schemes', solo: true, team: true, business: true },
{ id: 'r3', feature: 'All five palettes', solo: false, team: true, business: true },
{ id: 'r4', feature: 'Starter products', solo: false, team: true, business: true },
{ id: 'r5', feature: 'Private component registry', solo: false, team: false, business: true },
{
id: 'r6',
feature: 'Accessibility review sessions',
solo: false,
team: false,
business: true,
note: 'Quarterly',
},
{ id: 'r7', feature: 'Named support contact', solo: false, team: false, business: true },
]
function Cell({ included }: { included: boolean }) {
return included ? (
<>
<Check className="mx-auto size-4 text-success" aria-hidden="true" />
<span className="sr-only">Included</span>
</>
) : (
<>
<Minus className="mx-auto size-4 text-ink-subtle" aria-hidden="true" />
<span className="sr-only">Not included</span>
</>
)
}
export default function FeatureMatrixPricing() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Compare the plans in detail.
</h2>
<p className="mt-3 text-md text-ink-muted">
The full matrix, for the person who has already chosen a shortlist.
</p>
</div>
<div className="mt-10">
<Table
caption="Feature comparison across Solo, Team and Business plans"
hideCaption={false}
rows={rows}
rowKey={(row) => row.id}
columns={[
{
key: 'feature',
header: 'Feature',
width: '50%',
cell: (row) => (
<span className="text-ink">
{row.feature}
{row.note ? (
<span className="ml-2 text-xs text-ink-subtle">{row.note}</span>
) : null}
</span>
),
},
{
key: 'solo',
header: 'Solo',
align: 'center',
cell: (row) => <Cell included={row.solo} />,
},
{
key: 'team',
header: 'Team',
align: 'center',
cell: (row) => <Cell included={row.team} />,
},
{
key: 'business',
header: 'Business',
align: 'center',
cell: (row) => <Cell included={row.business} />,
},
]}
/>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<ButtonLink href="/forms/register">Start with Team</ButtonLink>
<ButtonLink href="/forms/sales-inquiry" variant="outline">
Talk about Business
</ButtonLink>
</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>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The comparison that belongs below a tier grid, not instead of one. Ticks and dashes are paired with visually hidden text, so a screen-reader user hears 'Included' rather than an unnamed icon.
- Show the caption on a matrix; it is the one table where the reader benefits from being told what they are looking at.
- Order rows from most to least differentiating.
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.
- Seven criteria
- Three plans
- Visible caption
Accessibility
- Scoped headers
- Plan columns use scope so cell navigation announces the right plan.
- Hidden text
- Every tick and dash carries a screen-reader-only word.
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 sectionsThree-tier pricing
Three plans with the recommended tier raised rather than merely tinted.
starterFeatured3 variantsTable
A semantic table with two responsive strategies, a focusable scroll region and a built-in empty state.
intermediateFeatured5 variantsCompetitive comparison
A three-way comparison against approaches rather than named competitors.
intermediate3 variants