Persona

Table

Data display · 2 props · Updated Sep 22, 2026

A table puts records in rows and their fields in columns so they can be scanned and compared. Sorting and row selection are what this component gives you. Search, filtering and bulk operations are not built in, and nothing in PDS supplies them.

  • Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Header row the top row of column labels; can include sorting controls and a select-all checkbox, and can be made sticky so the labels stay visible while the body scrolls.
  • Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Body row a horizontal group of cells representing a single data record.
  • Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Row checkbox (optional) selects one row; the matching checkbox in the header row selects or clears all of them.
  • Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Cell one data value, at the intersection of a row and a column.
  • Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Name
    Status
    Ada LovelaceApproved
    Grace HopperPending
    Row actions (optional) the overflow menu at the end of a row, holding actions that apply to that record.
1 of 5 parts falls below the 4.5:1 WCAG AA threshold for normal text in the dark theme only (△).

Table is one of a family. It takes size and isLoading and is the <table> itself; the head, rows, header cells, cells and the layout wrapper are separate exports with props of their own, and the generated table below covers only the first. Where a prop named on this page is not in that table, it belongs to one of the others. size is the one that reaches all of them: sm cuts the padding in every header cell and every cell, and is what a dense table is made of.

Use a table when the reader’s question is comparative: which of these is largest, newest, failing. Columns are what make that question answerable, so a set of records with nothing consistent to line up does not gain anything from being put in a grid.

A table that needs many columns, or nested data, has outgrown the grid. Use a list or a Card per record when the content is not structured in consistent columns.

The header cell keeps the sort state itself

Section titled “The header cell keeps the sort state itself”

Pass sortable and a sortDirection of asc or desc; the header cell derives aria-sort from the pair and sets it, along with role="columnheader". Setting aria-sort by hand duplicates a value the component already maintains, and the two can then disagree.

The sort caret is hidden until the header is hovered. On a sortable column that has not been sorted yet, nothing visible says it can be — which is worth knowing before deciding a column needs sorting at all, because for a touch or keyboard reader that affordance never appears.

Leave sorting off a column whose order says nothing

Section titled “Leave sorting off a column whose order says nothing”

A sortable header is an invitation, and sorting by a free-text note or an opaque identifier answers no question anyone has. The control ends up asking the reader to try it and find out.

Borrow a row checkbox’s name from the row

Section titled “Borrow a row checkbox’s name from the row”

A row checkbox has no label of its own, so point it at the cell that identifies the record with aria-labelledby. Without it, a screen reader reads a column of identical unnamed checkboxes, and there is nothing in the row to distinguish them by. This is one of two places in PDS where a control is allowed to borrow its name — see Accessibility.

The header checkbox selects and clears every row, and where only some rows are selected it is indeterminate rather than unchecked. Unchecked would be a lie about the state of the table.

It is where the reader’s eye starts, and it keeps the identifying cell — the one the checkbox is named from — adjacent to it.

Tell a loading table apart from an empty one

Section titled “Tell a loading table apart from an empty one”

isLoading animates a line under the header row. It does not disable the table, replace the rows, or say anything to a screen reader: the rows underneath are the previous result, still readable and now stale.

A table with no rows has to say so in words, and the words are the caller’s: TableLayout, the wrapper export, renders whatever you pass as nullState. Whether the column headers stay above that message is showHeader — with it, the reader can still see what the table would have contained; without it, the null state replaces the table outright. A table waiting for data and a table with none have different things to say, and only the second is “No records found”.

Each export sets its own role — table, rowgroup, row, columnheader, cell — so the semantics are already there, and they are correct only while the nesting is. A header cell outside a row, or a row outside the head or body, announces a role its position contradicts, and a screen reader’s table mode stops being able to describe where the reader is.

Sticky headers keep working through all of it: sticky on the head, or isSticky on a single header cell, is position: sticky and nothing more, so the cell stays in the reading order it started in.

Put a row’s action in a cell, not on the row

Section titled “Put a row’s action in a cell, not on the row”

A row accepts onClick and responds by showing a pointer cursor. It does not become focusable, gain a role, or activate on Enter, so a whole-row click is a mouse-only affordance. Where the row is meant to be openable, put a link or button in the identifying cell and let the row’s click be the shortcut.

A cell holds one value, and a long one widens the column

Section titled “A cell holds one value, and a long one widens the column”

A cell holds one value; anything larger belongs outside the table. TableCell does not wrap, so long text there does not make a taller row — it makes a wider column and pushes the columns after it out of view. breakWord turns wrapping on for one cell, and TableTD, a second exported cell built on the first, has it on already. Two cell exports that disagree about wrapping is worth checking before assuming a long value will behave.

Give a cell one job for the same reason: an action button and inline editing in the same cell compete for the same click. Long-form text and embedded interactive components belong on the record’s own page rather than inside its row.

A cell is left-aligned and has no prop to change it, so the convention of right-aligning numbers is not something this component can do. align="right" exists on the header cell, where it moves the label and the sort caret to the other side of the cell — which lines a header up with nothing, unless the numbers under it have been aligned in CSS of your own.

Align related actions consistently in a dedicated column at the end, and where there are more than two, an overflow Dropdown Menu keeps that column one cell wide. Column headers are where units and formats go — “Amount (USD)” — since the cells below repeat the number and not its meaning.

NameType
size

sm, md

isLoading

boolean

Was this page helpful?