Table
A semantic table with two responsive strategies, a focusable scroll region and a built-in empty state.
Live preview
Source
The exact file rendered in the preview above.
import { Inbox } from 'lucide-react'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { EmptyState } from '@/components/ui/empty-state'
import { Status } from '@/components/ui/status'
import { Table } from '@/components/ui/table'
import { DemoRow, DemoStage } from './_kit'
interface Deployment {
id: string
project: string
author: string
environment: string
status: 'operational' | 'degraded' | 'down' | 'pending'
duration: string
commit: string
}
const rows: Deployment[] = [
{
id: 'd1',
project: 'acme-platform',
author: 'Priya Raman',
environment: 'Production',
status: 'operational',
duration: '1m 42s',
commit: 'a91f2c8',
},
{
id: 'd2',
project: 'acme-platform',
author: 'Tomas Lindqvist',
environment: 'Staging',
status: 'pending',
duration: '—',
commit: '4b7e019',
},
{
id: 'd3',
project: 'northwind-web',
author: 'Amara Osei',
environment: 'Production',
status: 'down',
duration: '0m 51s',
commit: 'c02d3af',
},
{
id: 'd4',
project: 'northwind-web',
author: 'Jun Watanabe',
environment: 'Preview',
status: 'degraded',
duration: '2m 09s',
commit: '77aa1de',
},
]
export default function TableDemo() {
return (
<DemoStage>
<DemoRow
label="Scroll strategy"
description="Narrow the preview: the grid stays intact inside a focusable scroll region."
>
<Table
caption="Recent deployments"
rows={rows}
rowKey={(row) => row.id}
className="w-full"
columns={[
{
key: 'project',
header: 'Project',
cell: (row) => (
<div className="flex items-center gap-2.5">
<Avatar name={row.author} size="xs" decorative />
<span className="font-medium text-ink-strong">{row.project}</span>
</div>
),
},
{ key: 'env', header: 'Environment', cell: (row) => <Badge>{row.environment}</Badge> },
{
key: 'status',
header: 'Status',
cell: (row) => <Status appearance="dot" kind={row.status} label={row.status} />,
},
{
key: 'commit',
header: 'Commit',
hideOnMobile: true,
cell: (row) => <code className="font-mono text-xs">{row.commit}</code>,
},
{
key: 'duration',
header: 'Duration',
align: 'end',
cell: (row) => <span className="tabular-nums">{row.duration}</span>,
},
]}
/>
</DemoRow>
<DemoRow label="Stack strategy" description="Below md each row becomes a labelled card.">
<Table
caption="Recent deployments, stacked on mobile"
rows={rows.slice(0, 3)}
rowKey={(row) => row.id}
responsive="stack"
density="compact"
className="w-full"
columns={[
{ key: 'project', header: 'Project', cell: (row) => row.project },
{ key: 'author', header: 'Author', cell: (row) => row.author },
{
key: 'commit',
header: 'Commit',
cell: (row) => <code className="font-mono text-xs">{row.commit}</code>,
},
]}
/>
</DemoRow>
<DemoRow label="Empty">
<Table
caption="No deployments"
rows={[] as Deployment[]}
rowKey={(row) => row.id}
className="w-full"
columns={[{ key: 'project', header: 'Project', cell: (row) => row.project }]}
empty={
<EmptyState
icon={<Inbox className="size-5" />}
title="No deployments in this window"
description="Widen the date range, or clear the environment filter."
size="sm"
/>
}
/>
</DemoRow>
</DemoStage>
)
}
components/demos/table.tsx
import { Inbox } from 'lucide-react'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { EmptyState } from '@/components/ui/empty-state'
import { Status } from '@/components/ui/status'
import { Table } from '@/components/ui/table'
import { DemoRow, DemoStage } from './_kit'
interface Deployment {
id: string
project: string
author: string
environment: string
status: 'operational' | 'degraded' | 'down' | 'pending'
duration: string
commit: string
}
const rows: Deployment[] = [
{
id: 'd1',
project: 'acme-platform',
author: 'Priya Raman',
environment: 'Production',
status: 'operational',
duration: '1m 42s',
commit: 'a91f2c8',
},
{
id: 'd2',
project: 'acme-platform',
author: 'Tomas Lindqvist',
environment: 'Staging',
status: 'pending',
duration: '—',
commit: '4b7e019',
},
{
id: 'd3',
project: 'northwind-web',
author: 'Amara Osei',
environment: 'Production',
status: 'down',
duration: '0m 51s',
commit: 'c02d3af',
},
{
id: 'd4',
project: 'northwind-web',
author: 'Jun Watanabe',
environment: 'Preview',
status: 'degraded',
duration: '2m 09s',
commit: '77aa1de',
},
]
export default function TableDemo() {
return (
<DemoStage>
<DemoRow
label="Scroll strategy"
description="Narrow the preview: the grid stays intact inside a focusable scroll region."
>
<Table
caption="Recent deployments"
rows={rows}
rowKey={(row) => row.id}
className="w-full"
columns={[
{
key: 'project',
header: 'Project',
cell: (row) => (
<div className="flex items-center gap-2.5">
<Avatar name={row.author} size="xs" decorative />
<span className="font-medium text-ink-strong">{row.project}</span>
</div>
),
},
{ key: 'env', header: 'Environment', cell: (row) => <Badge>{row.environment}</Badge> },
{
key: 'status',
header: 'Status',
cell: (row) => <Status appearance="dot" kind={row.status} label={row.status} />,
},
{
key: 'commit',
header: 'Commit',
hideOnMobile: true,
cell: (row) => <code className="font-mono text-xs">{row.commit}</code>,
},
{
key: 'duration',
header: 'Duration',
align: 'end',
cell: (row) => <span className="tabular-nums">{row.duration}</span>,
},
]}
/>
</DemoRow>
<DemoRow label="Stack strategy" description="Below md each row becomes a labelled card.">
<Table
caption="Recent deployments, stacked on mobile"
rows={rows.slice(0, 3)}
rowKey={(row) => row.id}
responsive="stack"
density="compact"
className="w-full"
columns={[
{ key: 'project', header: 'Project', cell: (row) => row.project },
{ key: 'author', header: 'Author', cell: (row) => row.author },
{
key: 'commit',
header: 'Commit',
cell: (row) => <code className="font-mono text-xs">{row.commit}</code>,
},
]}
/>
</DemoRow>
<DemoRow label="Empty">
<Table
caption="No deployments"
rows={[] as Deployment[]}
rowKey={(row) => row.id}
className="w-full"
columns={[{ key: 'project', header: 'Project', cell: (row) => row.project }]}
empty={
<EmptyState
icon={<Inbox className="size-5" />}
title="No deployments in this window"
description="Widen the date range, or clear the environment filter."
size="sm"
/>
}
/>
</DemoRow>
</DemoStage>
)
}
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>
)
}
components/ui/empty-state.tsx
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
/**
* EmptyState
*
* An empty state is a piece of product writing more than a piece of UI, so the
* component enforces the three parts that make one useful: what is missing,
* why it might be missing, and the single most likely next action.
*/
export interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: ReactNode
secondaryAction?: ReactNode
/** `panel` draws a dashed enclosure; `bare` sits inside an existing panel. */
appearance?: 'panel' | 'bare'
size?: 'sm' | 'md' | 'lg'
className?: string
}
export function EmptyState({
icon,
title,
description,
action,
secondaryAction,
appearance = 'panel',
size = 'md',
className,
}: EmptyStateProps) {
const padding = { sm: 'py-8', md: 'py-12', lg: 'py-20' }[size]
return (
<div
className={cn(
'flex flex-col items-center px-6 text-center',
padding,
appearance === 'panel' &&
'rounded-lg border border-dashed border-line-strong bg-surface-sunken/60',
className,
)}
>
{icon ? (
<div className="mb-4 flex size-11 items-center justify-center rounded-full border border-line bg-surface text-ink-subtle">
{icon}
</div>
) : null}
<p className="text-md font-semibold text-ink-strong text-balance">{title}</p>
{description ? (
<p className="mt-1.5 max-w-sm text-sm text-ink-muted text-pretty">{description}</p>
) : null}
{(action || secondaryAction) && (
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
{action}
{secondaryAction}
</div>
)}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
A real `<table>` with a caption and scoped headers — the only markup a screen reader can navigate cell by cell. Two responsive strategies are offered because neither works everywhere.
- Scroll keeps the grid intact and is right for numeric, comparison-heavy data.
- Stack turns each row into a labelled card below `md`, and is right for short, wide records.
- The scroll region is keyboard focusable, because a scrollable area must be reachable without a pointer.
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.
- Scroll strategy
- Stack strategy — cards below md
- Compact density
- Column hiding
- Empty state
Accessibility
- Caption
- Every table has a caption, visually hidden by default, that names the data.
- Headers
- Column headers use `scope="col"` so cell-by-cell navigation announces the right header.
- Scroll region
- The overflow container is a labelled, focusable `role="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 componentsPanel
A titled region with a header action row and an optional flush body for tables and lists.
starter3 variantsEmpty state
The three-part empty state — what is missing, why, and the single most likely next action.
starter4 variantsPagination
Real anchors, a fixed-width page window and correctly disabled ends.
starter4 variants