Skip to content

Table

A static table of data with real header cells, tabular figures and optional sortable column headers.

  • Free core, MIT
  • Version 2.0.0-rc.1
  • APG pattern: Table (native; sortable headers are buttons with aria-sort)

Examples

These are the real React components. Use them with a mouse, touch or keyboard, and switch the theme and density for every example at once.

Default

Invoices issued in October 2026
InvoiceCustomerDateAmount
INV-1042Northwind Ltd3 Oct 2026£1,240.50
INV-1043Fabrikam7 Oct 2026£380.00
INV-1044Contoso12 Oct 2026£9,120.75
INV-1045Tailspin Toys19 Oct 2026£64.00
Total£10,805.25
Show the code

Default, code

const invoices = [
  { id: 'INV-1042', customer: 'Northwind Ltd', date: '3 Oct 2026', amount: 1240.5 },
  { id: 'INV-1043', customer: 'Fabrikam', date: '7 Oct 2026', amount: 380 },
  { id: 'INV-1044', customer: 'Contoso', date: '12 Oct 2026', amount: 9120.75 },
  { id: 'INV-1045', customer: 'Tailspin Toys', date: '19 Oct 2026', amount: 64 },
];

const gbp = (n: number) => new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);

<Table aria-label="Invoices">
  <TableCaption>Invoices issued in October 2026</TableCaption>
  <TableHeader>
    <TableRow><TableHead>Invoice</TableHead><TableHead>Customer</TableHead><TableHead>Date</TableHead><TableHead align="end">Amount</TableHead></TableRow>
  </TableHeader>
  <TableBody>
    {invoices.map((i) => (
      <TableRow key={i.id}><TableRowHeader>{i.id}</TableRowHeader><TableCell>{i.customer}</TableCell><TableCell>{i.date}</TableCell><TableCell align="end">{gbp(i.amount)}</TableCell></TableRow>
    ))}
  </TableBody>
  <TableFooter>
    <TableRow><TableRowHeader colSpan={3}>Total</TableRowHeader><TableCell align="end">{gbp(invoices.reduce((a, i) => a + i.amount, 0))}</TableCell></TableRow>
  </TableFooter>
</Table>

Sortable

Invoice
INV-1044Contoso£9,120.75
INV-1042Northwind Ltd£1,240.50
INV-1043Fabrikam£380.00
INV-1045Tailspin Toys£64.00
Show the code

Sortable, code

const gbp = (n: number) => new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);

function SortableExample() {
  const [sort, setSort] = useState<{ column: 'customer' | 'amount'; direction: SortDirection }>({ column: 'amount', direction: 'descending' });
  const rows = useMemo(() => [...invoices].sort((a, b) => {
    const d = sort.column === 'amount' ? a.amount - b.amount : a.customer.localeCompare(b.customer);
    return sort.direction === 'ascending' ? d : -d;
  }), [sort]);
  const toggle = (column: 'customer' | 'amount') => setSort((s) => ({ column, direction: s.column === column && s.direction === 'ascending' ? 'descending' : 'ascending' }));
  return (
    <Table aria-label="Invoices, sortable">
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead sortDirection={sort.column === 'customer' ? sort.direction : 'none'} onSort={() => toggle('customer')}>Customer</TableHead>
          <TableHead align="end" sortDirection={sort.column === 'amount' ? sort.direction : 'none'} onSort={() => toggle('amount')}>Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {rows.map((i) => <TableRow key={i.id}><TableRowHeader>{i.id}</TableRowHeader><TableCell>{i.customer}</TableCell><TableCell align="end">{gbp(i.amount)}</TableCell></TableRow>)}
      </TableBody>
    </Table>
  );
}

<SortableExample />

Typed cells and a frozen column

InvoiceCustomerStatusAmountActions
INV-1042Northwind Ltdaccounts@northwind.examplePaid£1,240.50
INV-1043Fabrikambilling@fabrikam.exampleOverdue£380.00
INV-1044Contosoap@contoso.exampleDraft£9,120.75
INV-1045Tailspin Toysfinance@tailspin.examplePaid£64.00
Show the code

Typed cells and a frozen column, code

const invoices = [
  { id: 'INV-1042', customer: 'Northwind Ltd', date: '3 Oct 2026', amount: 1240.5 },
  { id: 'INV-1043', customer: 'Fabrikam', date: '7 Oct 2026', amount: 380 },
  { id: 'INV-1044', customer: 'Contoso', date: '12 Oct 2026', amount: 9120.75 },
  { id: 'INV-1045', customer: 'Tailspin Toys', date: '19 Oct 2026', amount: 64 },
];

const gbp = (n: number) => new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);

const status = { 'INV-1042': ['Paid', 'success'], 'INV-1043': ['Overdue', 'danger'], 'INV-1044': ['Draft', 'neutral'], 'INV-1045': ['Paid', 'success'] } as const;

const emails: Record<string, string> = { 'Northwind Ltd': 'accounts@northwind.example', Fabrikam: 'billing@fabrikam.example', Contoso: 'ap@contoso.example', 'Tailspin Toys': 'finance@tailspin.example' };

<Table aria-label="Invoices with status">
  <TableHeader>
    <TableRow><TableHead frozen>Invoice</TableHead><TableHead>Customer</TableHead><TableHead>Status</TableHead><TableHead align="end">Amount</TableHead><TableHead><span className="sr-only">Actions</span></TableHead></TableRow>
  </TableHeader>
  <TableBody>
    {invoices.map((i) => (
      <TableRow key={i.id}>
        <TableRowHeader frozen>{i.id}</TableRowHeader>
        <TableCell className="min-w-[14rem]"><span className="block">{i.customer}</span><span className="block text-body-sm text-fg-secondary">{emails[i.customer]}</span></TableCell>
        <TableCell><Badge tone={status[i.id as keyof typeof status][1]}>{status[i.id as keyof typeof status][0]}</Badge></TableCell>
        <TableCell align="end">{gbp(i.amount)}</TableCell>
        <TableCell align="end" className="w-px">
          <MenuTrigger>
            <IconButton icon={Ellipsis} label={`More actions for ${i.id}`} variant="ghost" />
            <Menu><MenuItem id="view">View</MenuItem><MenuItem id="send">Send reminder</MenuItem></Menu>
          </MenuTrigger>
        </TableCell>
      </TableRow>
    ))}
  </TableBody>
</Table>

Tokens

The 16 design tokens Table reads, with the value each has in the theme and density chosen above. Change a token and the component follows, in Figma and in code.

Show all 16 tokens
Tokens Table reads
TokenValue nowCSS variable
color.bg.canvas…--sd-color-bg-canvas
color.bg.muted…--sd-color-bg-muted
color.bg.subtle…--sd-color-bg-subtle
color.border.default…--sd-color-border-default
color.border.subtle…--sd-color-border-subtle
color.icon.default…--sd-color-icon-default
color.icon.strong…--sd-color-icon-strong
color.text.default…--sd-color-text-default
color.text.secondary…--sd-color-text-secondary
radius.sm…--sd-radius-sm
shape.radius-surface…--sd-shape-radius-surface
spacing.gap.xs…--sd-spacing-gap-xs
spacing.inset.md…--sd-spacing-inset-md
spacing.inset.sm…--sd-spacing-inset-sm
spacing.inset.xs…--sd-spacing-inset-xs
target.min…--sd-target-min

Install

The npm package isn’t published yet. When it is, this is the install line; until then, this page runs the same build of the package (from commit fa3abf8).

Install

npm i @superduperui/react @superduperui/tokens

Import

import { Table, TableCaption, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableRowHeader } from "@superduperui/react"

Props

Table also takes the props of the React Aria component it’s built on.

Table props
PropTypeWhat it does
scrollbooleanDefault trueLets a wide table scroll sideways inside its own region instead of the page (1.4.10). Default true.
aria-labelstringNames the table and its scroll region (or use a TableCaption).

When to use it

Use it for

  • Comparing values across rows (invoices, usage, prices)
  • Read-only lists with several attributes per row

Use something else for

  • Selecting rows, bulk actions or filtering: use DataTable
  • Layout: never use a table to place things
  • A single list of items: use a list or ListRow

Accessibility

Screen readers announce "Invoices, table, 5 rows, 4 columns"; on a sortable header "Amount, button, sorted descending".

WCAG 2.2 AAA criteria the component meets in Comfortable density: 1.4.6, 1.4.9, 2.3.2, 2.1.3, 2.4.13, 2.5.5.

Compact density meets AA (24px rows, 2.5.8), not AAA.

What your product still has to do

  • Give every table a caption or aria-label
  • Put the identifying value in a TableRowHeader

This release’s test results

Test results for Table in this release
axe-core (WCAG A, AA, AAA and best practice)80 runs across every story, theme, density and width: 0 violations
Rendered text contrast485 text runs: 0 below 7:1, lowest 7.81:1
Target size12 targets: 0 under 44px in Comfortable
Focus appearance (2.4.13)54 focus stops in 3 browser engines: all pass
Keyboard scriptChromium 5 of 5, Firefox 5 of 5, WebKit 5 of 5

Manual passes with VoiceOver and NVDA haven’t been done yet.

Keyboard

Each step is scripted and run in Chromium, Firefox and WebKit.

Table keyboard steps
KeyWhat happens
TabTab reaches the scroll region (so a wide table can be scrolled by keyboard)
TabTab reaches the first sort button
EnterEnter sorts by that column, ascending
SpaceSpace reverses the order
TabTab moves to the next sort button

In Figma

The Figma component is Table. The Figma library isn’t published yet.

Figma to code: Caption → TableCaption children when Show caption; Show footer → TableFooter; Rows → TableBody children.