Skip to content

Table

A semantic table with two responsive strategies, a focusable scroll region and a built-in empty state.

DataintermediateFeaturedtabledatagridresponsiveempty

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.