On this page
Data Table
Display structured data in rows and columns for comparison and review.
- Overview
- Rules and guidance
- Properties
- Styles
- Accessibility
When to use
- To display structured data where rows and columns have a meaningful relationship
- When users need to compare values across multiple items of the same type
- When the data has a consistent structure, with the same attributes for every row
When not to use
- For layouts - use Stack or Cluster instead. Tables impose structure that assistive technologies use for navigation, and misusing them creates an inaccessible experience.
- For key-value pairs describing a single entity - use Summary List instead, for example a customer's name, address, and account number.
- For a single list of values - use a List instead.
- For data without a clear row-column relationship - if cells do not gain meaning from their position in both a row and a column, the data is not tabular.
Anatomy
- Caption
- Column header
- Row header
- Cell
Basic usage
The simplest way to use DataTable is with the items and columnHeadings props.
Edit live code
<DataTable columnHeadings={['Day', 'Average', 'Max', 'Min']} items={[ ['Today', '222', '260', '147'], ['Tomorrow', '254', '279', '211'], ['Wednesday', '174', '223', '135'] ]} caption={<Strong>National Grid Carbon Intensity Forecast (gCO2/kWh)</Strong>} />
Variants and features
Row headers
Use rowHeadings when the first column identifies the row rather than containing a data value, for example a meter serial number or account ID. This renders the first cell of each row as a <th scope="row"> so that screen readers can announce the row identity before each cell value.
Edit live code
<DataTable columnHeadings={['Day', 'Average', 'Max', 'Min']} items={[ ['Today', '222', '260', '147'], ['Tomorrow', '254', '279', '211'], ['Wednesday', '174', '223', '135'], ]} caption={<Strong>National Grid Carbon Intensity Forecast (gCO2/kWh)</Strong>} rowHeadings={true} />
Striped rows
Row striping alternates the background colour of odd rows and is on by default. It helps users track across wide tables. Only disable striping for compact tables where alternating backgrounds add visual noise without helping readability.
Striping on, the default:
Edit live code
<DataTable columnHeadings={['Day', 'Average', 'Max', 'Min']} items={[ ['Today', '222', '260', '147'], ['Tomorrow', '254', '279', '211'], ['Wednesday', '174', '223', '135'], ]} caption={<Strong>Striped (default)</Strong>} rowHeadings={true} />
Striping off, with striped={false}:
Edit live code
<DataTable columnHeadings={['Day', 'Average', 'Max', 'Min']} items={[ ['Today', '222', '260', '147'], ['Tomorrow', '254', '279', '211'], ['Wednesday', '174', '223', '135'], ]} caption={<Strong>Not striped</Strong>} rowHeadings={true} striped={false} />
Fixed column widths
Use columnWidths to prevent columns from resizing as content changes. Pass a CSS width value for each column position. Columns without a fixed width will share the remaining space.
Edit live code
<DataTable columnHeadings={['Day', 'Average', 'Max', 'Min']} items={[ ['Today', '222', '260', '147'], ['Tomorrow', '254', '279', '211'], ['Wednesday', '174', '223', '135'], ]} caption={<Strong>National Grid Carbon Intensity Forecast (gCO2/kWh)</Strong>} rowHeadings={true} columnWidths={['12rem', '6rem', '6rem', '6rem']} />
Composable usage
For tables that need custom rendering, for example mixed cell types or specific row grouping that the items prop cannot express, use the exported sub-components directly as children.
The sub-components are: TableHead, TableBody, TableRow, TableHeader, TableRowHeader, TableCell, and TableCaption. Each accepts a cellWidth prop in addition to its native HTML element attributes.
When composing, the striped prop does not apply. Set stripe on each alternating TableRow yourself.
Edit live code
<DataTable> <TableCaption> <Strong>Meter reads awaiting validation</Strong> </TableCaption> <TableHead> <TableRow> <TableHeader scope="col">Meter serial</TableHeader> <TableHeader scope="col">Reading</TableHeader> <TableHeader scope="col">Status</TableHeader> </TableRow> </TableHead> <TableBody> <TableRow> <TableRowHeader scope="row">A1234567</TableRowHeader> <TableCell>12,044</TableCell> <TableCell><Badge variant="success">Accepted</Badge></TableCell> </TableRow> <TableRow stripe> <TableRowHeader scope="row">B7654321</TableRowHeader> <TableCell>8,910</TableCell> <TableCell><Badge variant="warning">Pending</Badge></TableCell> </TableRow> </TableBody> </DataTable>
usage
- DataTable must only be used when cell content gains meaning from its position in both a row and a columnIf removing the column header or row header from a cell would not change its meaning, the data is not truly tabular and a different component should be used.Copy rule ID for datatable.meaningful-row-column-relationship
- DataTable must not be used for layout purposesTables expose document structure that assistive technologies use to navigate. Using a table for layout breaks this contract and creates an inaccessible experience for screen reader users.Copy rule ID for datatable.tabular-data-only
- DataTable should include a caption that describes the table's purposeThe caption is the table's accessible name. Without it, screen reader users hear only "table" with no context when they navigate to it. When a caption is present, the scroll container is also labelled, which is important for tables that overflow horizontally.Copy rule ID for datatable.caption
- DataTable should include column headings that describe the category of data in each columnColumn headings help sighted users understand the structure and support screen reader navigation via table mode. Tables without headings force users to infer meaning from cell values alone.Copy rule ID for datatable.column-headings
- DataTable should use the rowHeadings prop when the first column contains an identifier or label for the row rather than a data valueRow headers are announced by screen readers before each cell in that row, giving users the row's identity as context. Rendering an identifier as a td instead of th loses this association.Copy rule ID for datatable.row-headings
- DataTable should keep row striping enabled for tables with more than five rows or more than three columnsStriping helps users track across a row, reducing errors when reading wide or long tables. The benefit is negligible for small tables but meaningful at scale.Copy rule ID for datatable.striped-for-wide-tables
- DataTable should display no more data than fits comfortably on screen without pagination or filteringVery long tables are difficult to scan and slow to load. If a table consistently has more than 20 rows, introduce pagination, filtering, or splitting across multiple tables rather than scrolling the user through a very long list.Copy rule ID for datatable.large-dataset-split
content
- DataTable column heading should use a noun or short noun phraseColumn headings describe a category of data, not an action. Verb headings imply interactivity that does not exist.Copy rule ID for datatable.column-heading-content
- DataTable column heading should include units of measurement rather than repeating them in every cellRepeating units in every cell adds visual noise and increases reading time. Including them once in the heading is cleaner and equally unambiguous. For example, prefer 'Usage (kWh)' as the heading over repeating 'kWh' in each cell.Copy rule ID for datatable.units-in-heading
- DataTable column should contain the same type of data in every rowMixed data types in a column prevent meaningful comparison. Users scanning vertically rely on consistent types to make sense of the values they are reading.Copy rule ID for datatable.consistent-column-type
- DataTable column containing numbers should right-align numeric valuesRight-aligning numbers positions the decimal point and units consistently, making columns of figures easier to compare. Left-aligning numbers of varying digit length makes differences harder to spot at a glance.Copy rule ID for datatable.numeric-column-alignment
- DataTable cell should not be left visually blank when a value is absentAn empty cell gives no indication of whether data is missing, not applicable, or not yet available. Prefer descriptive text such as "None" or "Not applicable". If using an em-dash as a visual null marker, pair it with visually hidden text so screen readers announce something meaningful rather than silence.Copy rule ID for datatable.empty-cell
interaction
- DataTable scroll container will become keyboard-focusable (tabIndex=0) automatically when the table overflows horizontallyThe component sets tabIndex=0 on the scroll container when content overflows, giving keyboard users access to scrolled content.Copy rule ID for datatable.scroll-container-focusable
- DataTable scroll container must not have its tabIndex or scroll behaviour suppressed or overriddenWhen the table overflows horizontally, the scroll container automatically becomes keyboard-focusable with tabIndex=0. Removing this removes keyboard access to content that is scrolled out of view.Copy rule ID for datatable.no-keyboard-override
composition
- DataTable must not be nested inside another DataTable cellNested tables are difficult for screen reader users to navigate and make it impossible for assistive technologies to determine the scope of row and column headers.Copy rule ID for datatable.no-nested-tables
- DataTable cell should not contain complex interactive controls such as form fields, date pickers, or modalsInteractive content inside table cells is difficult to navigate by keyboard and breaks the expected mental model of a table as a read-only display component. Links and simple action buttons are acceptable.Copy rule ID for datatable.no-complex-interactive-cells
DataTable
Also accepts all props from HTMLAttributes, TableHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
items | ReactNode[][] | — | No | Table body content as an array of rows, each an array of cell contents. |
columnHeadings | ReactNode[] | — | No | Content of the column header cells, rendered in a header row above the body. |
caption | ReactNode | — | No | Visible caption for the table, which also labels the scrollable container for assistive technology. |
rowHeadings | boolean | — | No | Renders the first cell of each row as a row header (`th scope="row"`) rather than a data cell. |
columnWidths | string[] | — | No | CSS widths for each column, matched to cells by index, for example `['20%', '10rem']`. |
striped | boolean | true | No | Gives alternate body rows a muted background. |
TableCaption
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
as | ElementType | — | No | Renders the component as a different element or component, e.g. `as="section"`. |
forwardedAs | ElementType | — | No | Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. |
TableHead
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
as | ElementType | — | No | Renders the component as a different element or component, e.g. `as="section"`. |
forwardedAs | ElementType | — | No | Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. |
TableHeader
Also accepts all props from HTMLAttributes, ThHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
cellWidth | string | — | No | CSS width of the cell's column, for example `20%` or `10rem`. |
as | ElementType | — | No | Renders the component as a different element or component, e.g. `as="section"`. |
forwardedAs | ElementType | — | No | Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. |
TableBody
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
as | ElementType | — | No | Renders the component as a different element or component, e.g. `as="section"`. |
forwardedAs | ElementType | — | No | Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. |
TableRow
Also accepts all props from HTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
stripe | boolean | — | No | Gives the row a muted background, as used for alternate rows. |
as | ElementType | — | No | Renders the component as a different element or component, e.g. `as="section"`. |
forwardedAs | ElementType | — | No | Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. |
TableCell
Also accepts all props from HTMLAttributes, TdHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
cellWidth | string | — | No | CSS width of the cell's column, for example `20%` or `10rem`. |
as | ElementType | — | No | Renders the component as a different element or component, e.g. `as="section"`. |
forwardedAs | ElementType | — | No | Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. |
TableRowHeader
Also accepts all props from HTMLAttributes, ThHTMLAttributes.
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
cellWidth | string | — | No | CSS width of the cell's column, for example `20%` or `10rem`. |
as | ElementType | — | No | Renders the component as a different element or component, e.g. `as="section"`. |
forwardedAs | ElementType | — | No | Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. |
Styles
table-container
overflow-x: auto;
white-space: nowrap;
:focus-visible {
${focusRing}
}
table
border-radius: ${radii.standard};
border-collapse: separate;
width: 100%;
table-layout: auto;
white-space: nowrap;
table-header
padding: ${space[2]};
font-weight: ${fontWeights.bold};
text-align: left;
color: ${colors.heading};
background-color: ${colors.canvas};
border-bottom: ${borderWidths.standard} solid ${colors.border};
&[cellWidth] {
width: ${cellWidth};
}
font-family: ${fonts.body};
font-size: ${responsiveFontSizes.body[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
table-row-header
padding: ${space[2]};
border-bottom: ${borderWidths.standard} solid ${colors.borderMuted};
font-weight: ${fontWeights.bold};
text-align: left;
color: ${colors.heading};
&[cellWidth] {
width: ${cellWidth};
}
font-family: ${fonts.body};
font-size: ${responsiveFontSizes.body[0]};
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
table-caption
font-weight: ${fontWeights.bold};
text-align: left;
color: ${colors.heading};
padding: ${space[2]};
table-row
background-color: ${stripe ? colors.canvasMuted : colors.canvas};
table-cell
font-weight: ${fontWeights.normal};
font-family: ${fonts.body};
font-size: ${responsiveFontSizes.body[0]};
padding: ${space[2]};
border-bottom: ${borderWidths.standard} solid ${colors.borderMuted};
color: ${colors.body};
&[cellWidth] {
width: ${cellWidth};
}
${mediaQueries.smallAndUp} {
font-size: ${responsiveFontSizes.body[1]};
}
In brief
Data Table is a native table. A screen reader announces the caption, the column headings and, with rowHeadings, the row headings as it moves through the cells. A wide table sits in a scroll region.
What Nebula provides
- It renders a native table, so the browser associates each cell with its headers. The columnHeadings and rowHeadings props set scope on the header cells.
- The caption prop labels the table and its scroll region.
- A table wider than its container becomes a tab stop, with a visible focus ring, so a keyboard user can scroll it.
Keyboard behaviour
The table is not a tab stop unless it is wider than its container. Then the scroll region is, reached with Tab.
Interactions
- Tab - Moves focus to the scroll region when the table is wider than its container
Accessible design
Use a table only for data in rows and columns
A screen reader announces every cell with its headings, so a table used for layout reads as nonsense.
WCAG: 1.3.1 Info and Relationships
Give every table a caption that says what it shows
The caption is the table's name. Without it a screen reader user reaches the table with no idea what it holds.
WCAG: 2.4.6 Headings and Labels
Write column headings that make sense on their own
A screen reader announces the heading with every cell, so "Max" is clearer than "Value" next to "Tomorrow".
WCAG: 2.4.6 Headings and Labels 1.3.1 Info and Relationships
Accessible development
Pass the caption with the caption prop
The prop names the table and the scroll region around it. A TableCaption passed as a child names the table but not the scroll region, which is then announced as a group with no name.
WCAG: 1.3.1 Info and Relationships
If you build the table from children, set scope on every header cell
Only the columnHeadings and rowHeadings props set scope. A TableHeader or TableRowHeader passed as a child has none, so set scope="col" or scope="row" yourself.
WCAG: 1.3.1 Info and Relationships
Accessibility testing status
Reviewed on 9 October 2026 against v17.0.1.
| Test | Status | Tested with |
|---|---|---|
| Default state | Tested | axe-core via the Storybook test runner (CI) |
| Advanced states | Partially tested | axe-core via the Storybook test runner (CI) |
| Keyboard navigation | Partially tested | keyboard (reported by the owner) |
| Screen reader | Partially tested | VoiceOver with Chrome (reported by the owner) |
What these tests and statuses mean
The table records which checks were run on this component on its own. It is not a statement of WCAG conformance, and it does not cover how the component is used.
The tests
- Default state: The component as it first appears, before anything is clicked or changed.
- Advanced states: Every other state and variant: error, open, selected, each size and style, and hovering.
- Keyboard navigation: Whether every function can be reached and used with the keyboard alone, with a visible focus ring.
- Screen reader: Whether a screen reader announces what is on screen correctly: the name, the role and any state.
The statuses
- Tested: Every check in this row passed in an automated run that repeats on every pull request.
- Manually tested: Every check in this row was run once, by a person or with a tool they started, and all of them passed. It is not re-run on every change.
- Partially tested: Some checks in this row were run once and passed; the rest are still to do.
- Issue found: A check in this row failed. The ticket is shown beside the status.
- Not tested: No check in this row has been run yet.
- Not applicable: This row does not apply to the component, for the reason given beside it.