Skip to content

Table

  • Records compared across the same columns: invoices, users
  • Data people sort or scan by column
  • Totals in a footer

Not for layout, or items that read better as tiles — use a Card grid.

Choosing between organising content:

ComponentUse it forFor example
AccordionSections stacked on one page, of which people read one or a fewFAQ, settings groups, order details
TabsViews of the same thing at the same level, one at a timeAccount / Billing / Security
CardA self-contained group of content, shown as one unit, often in a grid or listA product tile, a project summary
TableRows of records compared across the same columnsInvoices, users, order lines
SeparatorA line that divides sections, with no content of its ownBetween menu groups or toolbar groups
Invoices this month
InvoiceCustomerStatus
INV-1042Ada LovelacePaid€1,200
INV-1043Alan TuringDue€450
INV-1044Grace HopperPaid€3,100
INV-1045Katherine JohnsonOverdue€780
Total€5,530
import { useState } from 'react';
import { Button } from '@arun-dev/headless/button';
import { Table } from '@arun-dev/ui';
const invoices = [
{ id: 'INV-1042', customer: 'Ada Lovelace', amount: 1200, status: 'Paid' },
{ id: 'INV-1043', customer: 'Alan Turing', amount: 450, status: 'Due' },
{ id: 'INV-1044', customer: 'Grace Hopper', amount: 3100, status: 'Paid' },
{ id: 'INV-1045', customer: 'Katherine Johnson', amount: 780, status: 'Overdue' },
];
type Sort = 'none' | 'ascending' | 'descending';
// Each press moves on: as received, then ascending, then descending, then back.
const next: Record<Sort, Sort> = { none: 'ascending', ascending: 'descending', descending: 'none' };
// Sorting is yours: a button in the header, and aria-sort on the header cell.
export default function TableBasics() {
const [sort, setSort] = useState<Sort>('none');
const rows =
sort === 'none'
? invoices
: [...invoices].sort((a, b) =>
sort === 'ascending' ? a.amount - b.amount : b.amount - a.amount,
);
const total = invoices.reduce((sum, i) => sum + i.amount, 0);
return (
<Table.Root style={{ minInlineSize: '32rem' }}>
<Table.Caption>Invoices this month</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Invoice</Table.Head>
<Table.Head>Customer</Table.Head>
<Table.Head>Status</Table.Head>
{/* No aria-sort while unsorted, so no arrow. */}
<Table.Head aria-sort={sort === 'none' ? undefined : sort}>
{/* Unstyled: the Head styles the button that fills it. */}
<Button onClick={() => setSort(next[sort])}>Amount</Button>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((invoice) => (
<Table.Row key={invoice.id}>
<Table.Head scope="row">{invoice.id}</Table.Head>
<Table.Cell>{invoice.customer}</Table.Cell>
<Table.Cell>{invoice.status}</Table.Cell>
<Table.Cell>€{invoice.amount.toLocaleString('en')}</Table.Cell>
</Table.Row>
))}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.Cell colSpan={3}>Total</Table.Cell>
<Table.Cell>€{total.toLocaleString('en')}</Table.Cell>
</Table.Row>
</Table.Footer>
</Table.Root>
);
}

Table is a native <table>, styled. Its parts map onto the table’s elements: Root (<table>), Caption, Header (<thead>), Body, Footer, Row (<tr>), Head (<th>) and Cell (<td>). It sits in a container that scrolls sideways when the table is wider than the page.

  • Head is scope="col" by default. Use scope="row" for the first cell of a body row, so each cell is read with its row’s name.
  • Give every table a Caption. It names the table for screen readers.

Sorting is yours. Put a plain <button> in the column’s Head — @arun-dev/headless’s Button renders one, unstyled, as the example does — sort your rows when it’s pressed, and set aria-sort on the Head. The arrow follows aria-sort:

Statearia-sortArrow
As received, e.g. from your serverleave it offnone
Ascending"ascending"↑
Descending"descending"↓

The example cycles through all three: each press of Amount moves from the order the rows arrived in, to ascending, to descending, and back. Keep the original array and sort a copy, so you can return to it. Only the sorted column carries aria-sort; leave it off the others.

Table.Root props
PropTypeDefaultDescription
containerClassNamestringClasses for the scrolling container around the table.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Table.Head props
PropTypeDefaultDescription
scope'col' | 'row''col'What the header labels. row for the first cell of a body row.

The other parts take the props of the element they render.

  • This is a table for reading, not a data grid. There is no cell-by-cell keyboard navigation.