Table

Records in rows and columns, which people can sort and select.

StableSince 1.0.0
On this page

Import

import { Table } from '@envsemble/envui';

Usage

Table's parts are the native table elements, inside a focusable region. Name every Table.Root with a caption, an aria-label or aria-labelledby; the name reaches its scroll region too. Header cells are scope="col"; set scope="row" on one that heads its row, such as Total. Put numeric on a number column's header cell and cells, and nowrap on short values such as names and dates.

ProjectStatus.tsx
import { Badge, Link, Table, Text } from '@envsemble/envui';

const projects = [
  {
    id: 'website',
    name: 'Website refresh',
    team: 'Marketing',
    owner: 'Alex Morgan',
    openTasks: 12,
    status: 'In progress',
  },
  {
    id: 'onboarding',
    name: 'Onboarding emails',
    team: 'Growth',
    owner: 'Sam Lee',
    openTasks: 4,
    status: 'Blocked',
  },
  {
    id: 'billing',
    name: 'Billing migration',
    team: 'Finance',
    owner: 'Priya Shah',
    openTasks: 0,
    status: 'Done',
  },
  {
    id: 'checkout',
    name: 'Mobile checkout',
    team: 'Product',
    owner: 'Alex Morgan',
    openTasks: 27,
    status: 'In progress',
  },
] as const;

const tones = { 'In progress': 'primary', Blocked: 'warning', Done: 'success' } as const;

export default function ProjectStatus() {
  return (
    // On a phone, each project is a block of its own rather than a row that scrolls sideways.
    <Table.Root caption="Projects" narrowLayout="stack">
      <Table.Head>
        <Table.Row>
          <Table.HeaderCell>Project</Table.HeaderCell>
          <Table.HeaderCell>Status</Table.HeaderCell>
          <Table.HeaderCell>Owner</Table.HeaderCell>
          <Table.HeaderCell numeric>Open tasks</Table.HeaderCell>
        </Table.Row>
      </Table.Head>
      <Table.Body>
        {projects.map((project) => (
          <Table.Row key={project.id} interactive>
            <Table.Cell nowrap>
              <Link href={`/projects/${project.id}`} underline={false}>
                {project.name}
              </Link>
              <Text variant="caption">{project.team}</Text>
            </Table.Cell>
            <Table.Cell label="Status">
              <Badge tone={tones[project.status]}>{project.status}</Badge>
            </Table.Cell>
            <Table.Cell nowrap label="Owner">
              {project.owner}
            </Table.Cell>
            <Table.Cell numeric label="Open tasks">
              {project.openTasks}
            </Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
    </Table.Root>
  );
}

density on Table.Root changes only the cells' padding; controls in cells take the small height.

A Link in a cell, such as the name that opens a row, takes the cell's text color, so a column of names doesn't read as a column of blue; its underline shows under the pointer and with keyboard focus. A secondary line under a cell's text, <Text variant="caption">, keeps to one line and ends in an ellipsis where its column is narrower; give it a title with the full text when that can happen.

Sorting

The application sorts the rows. onSortChange on a header cell makes it a sort button, called with the order pressing asks for, and sortDirection shows the current order on every sortable header:

<Table.HeaderCell
  sortDirection={sort.column === 'name' ? sort.direction : 'none'}
  onSortChange={(direction) => setSort({ column: 'name', direction })}
>
  Name
</Table.HeaderCell>

Selecting and opening rows

selected on a Table.Row reports aria-selected; pass selected={false} to the other rows, and select with a Checkbox labelled with the row's name. interactive only highlights a row that opens through a link or a button in a cell; the row takes no click handler.

Resizable columns

resizableColumns needs widths in percent that add up to 100, in column order: defaultColumnWidths, or columnWidths with onColumnWidthsChange, which receives { committed }, a ResizeDetails, as Panes' and Sheet's width callbacks do: false while people drag and true once a change is complete, the widths to save.

<Table.Root aria-label="Files" resizableColumns defaultColumnWidths={[40, 25, 20, 15]}>
  …
</Table.Root>

Sticky header and loading

stickyHeader makes the region scroll vertically, so give it a height through regionClassName or its layout. skeleton shows the table as its own skeleton over placeholder rows until they arrive, without the selection's fill or bar.

Stacked rows

narrowLayout="stack" stacks the body rows below stackBelow, the region's own width. It works by a container query, so server markup already stacks.

  • Give every cell after the first a label, which titles its value in a stacked row.
  • The first labelled value sits beside the title without its label, so put one there that reads on its own, such as a status.
  • A cell without a label, such as the row's menu of actions, joins that line too.
  • The other values follow two to a line, each under its label.
  • The header row, and its sort buttons, are hidden while rows stack. onStackedChange tells the application, such as to show a Select for the order instead.
<Table.Root aria-label="Invoices" narrowLayout="stack" onStackedChange={setStacked}>
  <Table.Body>
    <Table.Row>
      <Table.Cell>{invoice.number}</Table.Cell>
      <Table.Cell label="Due">{invoice.due}</Table.Cell>
    </Table.Row>
  </Table.Body>
</Table.Root>

Composition

Parts the anatomy doesn't show: a column's resize handle, env-table-column-resize-handle, and a stacked cell's label, env-table-cell-label.

Accessibility

You provide

  • A caption or an aria-label for every table.
  • The sorting, the selection control and the row actions, named for the row they act on.
  • A label on every cell after the first in a stacking table.

envUI provides

  • Native table elements, with scope="col" on header cells by default, and a focusable region named like the table.
  • aria-sort on sortable headers, and sort buttons named by the header and what pressing does.
  • Resize handles as focusable separators with their width in percent.
  • aria-selected on rows with selected.
  • Table roles kept while rows stack, with the header row still read and the stacked labels hidden.

Props

Table.Root

A semantic data table on a rounded surface, in a focusable region that scrolls sideways when the columns do not fit.

PropTypeDescription
aria-label
string
Accessible name of the table and its scroll region when there is no caption. A table needs a caption, an aria-label or an aria-labelledby, which names its region too.
caption
ReactNode
A visible caption above the rows that names the table and its scroll region, in place of aria-label or aria-labelledby. Under a sticky header it scrolls away with the rows.
densityDefault 'md'
'xs''sm''md''lg'
How tightly the rows pack: xs, sm, md or lg changes only the cells' block padding, never the text. Buttons, icon buttons and fields in the cells take the small control height in every density, unless they set a size.
stickyHeaderDefault false
boolean
The header row stays at the top of the region while the rows scroll beneath it. The region then scrolls vertically too, so give it a height through regionClassName or the surrounding layout.
regionClassName
string
Class of the scroll region around the table, for example to limit its height.
narrowLayoutDefault 'scroll'
'scroll''stack'
What the table does when its region is narrower than stackBelow: scroll keeps the columns and scrolls sideways; stack shows each body row as a block that starts with its first cell, followed by the other cells with their labels. The header row is then visually hidden. A container query on the region decides it, so the server's markup and the first paint already stack; a stacking region fills the width its context gives it.
stackBelowDefault '36rem'
stringnumber
The region width below which a table with narrowLayout="stack" stacks its rows: a CSS length such as 60rem or 480px, or a number of rem, such as 40 or "40", as PanelDock's. Wide tables, such as seven sortable columns, need more.
onStackedChange
(stacked: boolean) => void
Called with true when the rows stack and with false when the columns return, as the table reads its layout once it is in the document, for example to show another sort control while the header row is hidden; a table that leaves while its rows stack reports false. The region has data-stacked from then on while the rows stack; style stacked rows with a container query on env-table, which applies before hydration, rather than through it.
columnWidths
readonly number[]
Column widths in percent of the table width, in column order and adding up to 100, for widths the application controls with onColumnWidthsChange. Set widths fix the columns: long words wrap and nowrap cells end with an ellipsis.
defaultColumnWidths
readonly number[]
Starting column widths, in percent, when the table owns them; other defaults start them over. Double-clicking a resize handle restores them, or else the widths a controlled table started with.
onColumnWidthsChange
(widths: number[], details: ResizeDetails) => void
Called with every column's width when people resize a column: while dragging a handle with committed false, and with committed true as a drag ends, a key moves it or a double click resets the widths.
minColumnWidthsDefault 5
numberreadonly number[]
Smallest width of each column in percent, in column order, or one value for every column.
resizableColumnsDefault false
boolean
People can resize the columns with a handle at the end of each header cell but the last, which moves the boundary to the next column and keeps the table's width. Needs columnWidths or defaultColumnWidths.
skeletonDefault false
boolean
Shows it and its content as a busy skeleton while that loads; turning it on or off renders the content anew.

Also accepts the native props of <table>, such as ref, className and aria-* attributes.

Table.Head

The header rows of a table, a native thead, which hold the column headers.

No props of its own.

Also accepts the native props of <thead>, such as ref, className and aria-* attributes.

Table.Body

The data rows of a table, a native tbody, separated by hairlines.

No props of its own.

Also accepts the native props of <tbody>, such as ref, className and aria-* attributes.

Table.Row

A row of a table, a native tr, which can highlight on hover or be selected.

PropTypeDescription
interactiveDefault false
boolean
The row highlights on hover and while focus is inside it, for rows that open something through a link or button in a cell. It adds no behavior of its own.
selected
boolean
The row is selected: it reports aria-selected and takes the selection colors, with a bar at its start. Pass false to the other rows of a table whose rows can be selected; unset, a row reports no selection state.

Also accepts the native props of <tr>, such as ref, className and aria-* attributes.

Table.HeaderCell

A header cell, a native th that heads its column, or its row with scope="row". With onSortChange it sorts its column; in a table with resizable columns it holds the column's resize handle.

PropTypeDescription
sortDirectionDefault 'none' with onSortChange
'ascending''descending''none'
The column's sort order: aria-sort on the header and the arrow in its sort button. Use it with onSortChange; without it only sets aria-sort.
onSortChange
(next: 'ascending' | 'descending') => void
The header becomes a button across the cell that asks the application to sort the rows by this column, with the order pressing it asks for: descending while the column is sorted ascending, ascending otherwise. The application sorts the rows and sets sortDirection.
sortLabelDefault '{header} sort {next order}', from EnvUIProvider's messages.table.sortAscending and sortDescending
string
Accessible name of the sort button, which says what pressing it does: the header's name followed by "sort ascending", or by "sort descending" while the column is sorted ascending.
resizeLabelDefault 'Resize {header}', from EnvUIProvider's messages.table.resize
string
Accessible name of the column's resize handle, "Resize" followed by the header's name.
alignDefault 'start', or 'end' with numeric
'start''end'
Aligns the content with the start or the end of the cell.
numericDefault false
boolean
Tabular figures aligned to the end, for numbers that compare down a column. In a sortable header the arrow comes before the label, so the label lines up with the numbers.
nowrapDefault false
boolean
Keeps the content on one line. In a table with set column widths, content that doesn't fit ends with an ellipsis.

Also accepts the native props of <th>, such as ref, className and aria-* attributes.

Table.Cell

A data cell, a native td, with a label that names its value while rows stack.

PropTypeDescription
label
ReactNode
The column's name, shown before the value while a table with narrowLayout="stack" stacks its rows. Assistive technology reads the column header instead.
alignDefault 'start', or 'end' with numeric
'start''end'
Aligns the content with the start or the end of the cell.
numericDefault false
boolean
Tabular figures aligned to the end, for numbers that compare down a column. In a sortable header the arrow comes before the label, so the label lines up with the numbers.
nowrapDefault false
boolean
Keeps the content on one line. In a table with set column widths, content that doesn't fit ends with an ellipsis.

Also accepts the native props of <td>, such as ref, className and aria-* attributes.

Part names

Each part's class and state attributes are public and versioned, for an application's CSS to style.

env-table
The table.
env-table-body
The body.
env-table-caption
The caption.
env-table-cell
A cell.
env-table-cell-content
A cell's content.
env-table-cell-label
A cell's column name, shown when rows stack.
env-table-column-resize-handle
The handle that resizes a column.
env-table-head
The head.
env-table-header-cell
A column header.
env-table-header-label
A column header's text.
env-table-region
The region that scrolls the table.
env-table-row
A row.
env-table-sort-button
The button that sorts by a column.
data-align
end · start on env-table-cell, env-table-header-cell
data-density
lg · md · sm · xs
data-fixed-layout
Present
data-interactive
Present on env-table-row
data-labelled
Present on env-table-cell
data-narrow-layout
stack on env-table-region
data-nowrap
Present on env-table-cell, env-table-header-cell
data-numeric
Present on env-table-cell, env-table-header-cell
data-resizable
Present on env-table-header-cell
data-resizing
Present on env-table, env-table-column-resize-handle
data-sortable
Present on env-table-header-cell
data-stacked
Present on env-table-region
data-sticky-header
Present on env-table-region

Strings

EnvUIProvider's messages overrides each, and a component's own label prop wins over it.

messages.table.sortAscending
"sort ascending" Read after a sortable column's name while pressing sorts it ascending.
messages.table.sortDescending
"sort descending" Read after a sortable column's name while pressing sorts it descending.
messages.table.resize
"Resize" A column's resize handle.

Changelog

1.0.0

The parts are members of the Table namespace: Table is Table.Root, and TableHead, TableBody, TableRow, TableHeaderCell and TableCell are Table.Head, Table.Body, Table.Row, Table.HeaderCell and Table.Cell; TableSortDirection is Table.SortDirection. The page is table, after the component. Table.HeaderCell's onSort is onSortChange, called with the order pressing asks for. A table named by aria-labelledby names its scroll region too, which was left unnamed. The sort and resize words come from messages.table. A column's resize handle, env-table-column-resize, is env-table-column-resize-handle, as every resize handle is named.

1.0.0-prerelease.6

Colors under the pointer and while pressed change with --env-motion-ease-enter, rather than easing in slowly, so hover and press show from the first frame.

A selected row takes a lighter tint of the selection fill, still a step above a row under the pointer; at high contrast it keeps the full fill.

A sortable header's focus ring has a row's rounded corners. In dark, the header band and row dividers are a light step closer to the surface. A stacked row's labels sit directly over their values.

A sortable header under the pointer takes no fill: its label darkens to the text color, with its arrow showing.

A sortable header's focus ring sits a hair inside the header, so the frame's rounded corners never cut it in the first and last headers.

An unsorted sortable header shows its arrow only while the pointer is over the header row or the header has keyboard focus, where the pointer hovers; the sorted column's arrow always shows.

A stacked row is a compact block: the first labelled value, such as a status, sits beside the title without its label, an unlabelled cell such as the row's menu joins them, and the other values follow two to a line, each under its label, in place of a line for each value. The row densities differ by at least 4 px of row height in the compact scale: xs and sm rows are shorter, and md keeps its height.

A skeleton shows no selection. A row under the pointer takes --env-color-bg-row-hover, clear in light and dark and a step below the selection.

A selected row whose first cell holds a checkbox takes no bar at its start, as the checkbox and the fill say it already; a row selected without one keeps the bar.

Links and times repeat down the rows, so a Link in a table takes the cell's text color and shows its underline only under the pointer and with keyboard focus, and a RelativeTime its dotted line; in a selected row too. A secondary line keeps to one line, ending in an ellipsis. A skeleton's sortable headers are bars, as the others are.

1.0.0-prerelease.5

A number for stackBelow is a number of rem, as on PanelDock, where it was pixels.

onColumnWidthsChange receives { committed } beside the widths, as the other resize callbacks do.