Dashboard data table
A flush panel with a table that becomes labelled cards below md.
Live preview
Source
This exact file renders the preview above.
import { Download, Filter } from 'lucide-react'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { Status } from '@/components/ui/status'
import { Table } from '@/components/ui/table'
import { formatCurrency } from '@/lib/format'
/**
* Dashboard data table
*
* A panel with a flush table body, so rows reach the panel edge. The table uses
* the stack strategy: below `md` each order becomes a labelled card, which reads
* far better than a five-column grid squeezed into 390px.
*/
interface Order {
id: string
reference: string
customer: string
status: 'operational' | 'pending' | 'degraded' | 'down'
statusLabel: string
totalCents: number
placed: string
}
const orders: Order[] = [
{
id: 'o1',
reference: 'QRY-4821',
customer: 'Priya Raman',
status: 'operational',
statusLabel: 'Fulfilled',
totalCents: 32800,
placed: '14 Mar',
},
{
id: 'o2',
reference: 'QRY-4820',
customer: 'Tomas Lindqvist',
status: 'pending',
statusLabel: 'Processing',
totalCents: 18500,
placed: '14 Mar',
},
{
id: 'o3',
reference: 'QRY-4819',
customer: 'Amara Osei',
status: 'degraded',
statusLabel: 'Delayed',
totalCents: 9800,
placed: '13 Mar',
},
{
id: 'o4',
reference: 'QRY-4818',
customer: 'Jun Watanabe',
status: 'down',
statusLabel: 'Payment failed',
totalCents: 24500,
placed: '13 Mar',
},
{
id: 'o5',
reference: 'QRY-4817',
customer: 'Elena Rossi',
status: 'operational',
statusLabel: 'Fulfilled',
totalCents: 5200,
placed: '12 Mar',
},
]
export default function DashboardDataTable() {
return (
<section className="bg-canvas p-4 sm:p-6">
<Panel
title="Recent orders"
description="Last 5 of 1,284"
headingLevel="h2"
flush
action={
<>
<Button variant="outline" size="sm" leadingIcon={<Filter className="size-3.5" />}>
Filter
</Button>
<Button variant="outline" size="sm" leadingIcon={<Download className="size-3.5" />}>
Export
</Button>
</>
}
>
<Table
caption="Recent orders with customer, status, total and date"
rows={orders}
rowKey={(row) => row.id}
responsive="stack"
density="compact"
columns={[
{
key: 'reference',
header: 'Order',
cell: (row) => (
<code className="font-mono text-xs text-ink-strong">{row.reference}</code>
),
},
{
key: 'customer',
header: 'Customer',
cell: (row) => (
<span className="flex items-center gap-2.5">
<Avatar name={row.customer} size="xs" decorative />
<span className="truncate">{row.customer}</span>
</span>
),
},
{
key: 'status',
header: 'Status',
cell: (row) => <Status appearance="dot" kind={row.status} label={row.statusLabel} />,
},
{
key: 'total',
header: 'Total',
align: 'end',
cell: (row) => (
<span className="font-mono tabular-nums">{formatCurrency(row.totalCents)}</span>
),
},
{
key: 'placed',
header: 'Placed',
align: 'end',
cell: (row) => <Badge size="sm">{row.placed}</Badge>,
},
]}
/>
</Panel>
</section>
)
}
components/blocks/sections/dashboard/data-table.tsx
import { Download, Filter } from 'lucide-react'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { Status } from '@/components/ui/status'
import { Table } from '@/components/ui/table'
import { formatCurrency } from '@/lib/format'
/**
* Dashboard data table
*
* A panel with a flush table body, so rows reach the panel edge. The table uses
* the stack strategy: below `md` each order becomes a labelled card, which reads
* far better than a five-column grid squeezed into 390px.
*/
interface Order {
id: string
reference: string
customer: string
status: 'operational' | 'pending' | 'degraded' | 'down'
statusLabel: string
totalCents: number
placed: string
}
const orders: Order[] = [
{
id: 'o1',
reference: 'QRY-4821',
customer: 'Priya Raman',
status: 'operational',
statusLabel: 'Fulfilled',
totalCents: 32800,
placed: '14 Mar',
},
{
id: 'o2',
reference: 'QRY-4820',
customer: 'Tomas Lindqvist',
status: 'pending',
statusLabel: 'Processing',
totalCents: 18500,
placed: '14 Mar',
},
{
id: 'o3',
reference: 'QRY-4819',
customer: 'Amara Osei',
status: 'degraded',
statusLabel: 'Delayed',
totalCents: 9800,
placed: '13 Mar',
},
{
id: 'o4',
reference: 'QRY-4818',
customer: 'Jun Watanabe',
status: 'down',
statusLabel: 'Payment failed',
totalCents: 24500,
placed: '13 Mar',
},
{
id: 'o5',
reference: 'QRY-4817',
customer: 'Elena Rossi',
status: 'operational',
statusLabel: 'Fulfilled',
totalCents: 5200,
placed: '12 Mar',
},
]
export default function DashboardDataTable() {
return (
<section className="bg-canvas p-4 sm:p-6">
<Panel
title="Recent orders"
description="Last 5 of 1,284"
headingLevel="h2"
flush
action={
<>
<Button variant="outline" size="sm" leadingIcon={<Filter className="size-3.5" />}>
Filter
</Button>
<Button variant="outline" size="sm" leadingIcon={<Download className="size-3.5" />}>
Export
</Button>
</>
}
>
<Table
caption="Recent orders with customer, status, total and date"
rows={orders}
rowKey={(row) => row.id}
responsive="stack"
density="compact"
columns={[
{
key: 'reference',
header: 'Order',
cell: (row) => (
<code className="font-mono text-xs text-ink-strong">{row.reference}</code>
),
},
{
key: 'customer',
header: 'Customer',
cell: (row) => (
<span className="flex items-center gap-2.5">
<Avatar name={row.customer} size="xs" decorative />
<span className="truncate">{row.customer}</span>
</span>
),
},
{
key: 'status',
header: 'Status',
cell: (row) => <Status appearance="dot" kind={row.status} label={row.statusLabel} />,
},
{
key: 'total',
header: 'Total',
align: 'end',
cell: (row) => (
<span className="font-mono tabular-nums">{formatCurrency(row.totalCents)}</span>
),
},
{
key: 'placed',
header: 'Placed',
align: 'end',
cell: (row) => <Badge size="sm">{row.placed}</Badge>,
},
]}
/>
</Panel>
</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>
)
}
components/ui/card.tsx
import type { HTMLAttributes, ReactNode, ElementType } from 'react'
import { cn } from '@/lib/cn'
/**
* Card & Panel
*
* Two containment primitives with deliberately different jobs:
*
* Card — a discrete, often interactive record in a collection.
* Panel — a titled region of a page, with an optional header action row.
*
* Keeping them separate is what stops the library from degenerating into
* "everything is a rounded box with a shadow".
*/
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
as?: ElementType
/** Forwarded when `as` renders a link. */
href?: string
/** `outline` is the default; `raised` adds elevation; `sunken` insets. */
tone?: 'outline' | 'raised' | 'sunken' | 'ghost' | 'accent'
/** Adds hover affordance. Only use when the whole card is a link/button. */
interactive?: boolean
padding?: 'none' | 'sm' | 'md' | 'lg'
}
const cardTones = {
outline: 'bg-surface border border-line',
raised: 'bg-surface-raised border border-line shadow-sm',
sunken: 'bg-surface-sunken border border-line-subtle',
ghost: 'bg-transparent border border-transparent',
accent: 'bg-accent-soft border border-accent-line',
} as const
const cardPadding = {
none: 'p-0',
sm: 'p-3',
md: 'p-card',
lg: 'p-6 sm:p-8',
} as const
export function Card({
as: Tag = 'div',
tone = 'outline',
interactive = false,
padding = 'md',
className,
children,
...props
}: CardProps) {
return (
<Tag
className={cn(
'rounded-lg',
cardTones[tone],
cardPadding[padding],
interactive &&
'transition-[border-color,box-shadow,background-color] duration-150 ease-standard hover:border-line-strong hover:shadow-sm',
className,
)}
{...props}
>
{children}
</Tag>
)
}
export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('flex items-start justify-between gap-4', className)} {...props}>
{children}
</div>
)
}
export function CardTitle({
as: Tag = 'h3',
className,
children,
...props
}: HTMLAttributes<HTMLHeadingElement> & { as?: ElementType }) {
return (
<Tag className={cn('text-md leading-snug font-semibold text-ink-strong', className)} {...props}>
{children}
</Tag>
)
}
export function CardDescription({
className,
children,
...props
}: HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn('text-sm leading-normal text-ink-muted', className)} {...props}>
{children}
</p>
)
}
export function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'mt-4 flex flex-wrap items-center gap-3 border-t border-line-subtle pt-4',
className,
)}
{...props}
>
{children}
</div>
)
}
export interface PanelProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
title: ReactNode
description?: ReactNode
/** Rendered on the right of the panel header. */
action?: ReactNode
/** Removes body padding — for tables and lists that manage their own. */
flush?: boolean
as?: ElementType
headingLevel?: 'h2' | 'h3' | 'h4'
}
export function Panel({
title,
description,
action,
flush = false,
as: Tag = 'section',
headingLevel: Heading = 'h3',
className,
children,
...props
}: PanelProps) {
return (
<Tag
className={cn('overflow-hidden rounded-lg border border-line bg-surface', className)}
{...props}
>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-line-subtle bg-surface-sunken px-4 py-3">
<div className="min-w-0">
<Heading className="text-sm font-semibold text-ink-strong">{title}</Heading>
{description ? <p className="mt-0.5 text-xs text-ink-muted">{description}</p> : null}
</div>
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
</div>
<div className={cn(flush ? '' : 'p-card')}>{children}</div>
</Tag>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Below md each order becomes a labelled card, which reads far better than a five-column grid squeezed into 390px.
- Use flush panels for tables so rows reach the panel edge.
- Compact density suits admin tables; the type scale does not change with it.
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.
- Flush panel body
- Stack strategy
- Toolbar actions
Accessibility
- Caption
- The table has a caption naming the data, visually hidden by default.
- Stack labels
- Each stacked card repeats the column label beside the value.
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 sectionsTable
A semantic table with two responsive strategies, a focusable scroll region and a built-in empty state.
intermediateFeatured5 variantsDashboard metric row
Four KPI tiles, two of them with inverted trend semantics.
starterFeatured3 variantsAdmin starter
An internal console: overview, a filterable member table, orders, analytics and settings — all at compact density on the same components as the marketing starters.
advancedFeatured4 variants