# Data Table Display structured data in rows and columns for comparison and review. Section: components Docs: https://nebula.kaluza.com/components/data-table --- ## 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](/components/layout-components#stack) or [Cluster](/components/layout-components#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](/components/summary-list) instead, for example a customer's name, address, and account number. - **For a single list of values** - use a [List](/components/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 1. **Caption** 2. **Column header** 3. **Row header** 4. **Cell** ## Basic usage The simplest way to use DataTable is with the `items` and `columnHeadings` props. | Day | Average | Max | Min | | --- | --- | --- | --- | | Today | 222 | 260 | 147 | | Tomorrow | 254 | 279 | 211 | | Wednesday | 174 | 223 | 135 | _National Grid Carbon Intensity Forecast (gCO2/kWh)_ ## 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 `` so that screen readers can announce the row identity before each cell value. | Day | Average | Max | Min | | --- | --- | --- | --- | | Today | 222 | 260 | 147 | | Tomorrow | 254 | 279 | 211 | | Wednesday | 174 | 223 | 135 | _National Grid Carbon Intensity Forecast (gCO2/kWh)_ ### 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: | Day | Average | Max | Min | | --- | --- | --- | --- | | Today | 222 | 260 | 147 | | Tomorrow | 254 | 279 | 211 | | Wednesday | 174 | 223 | 135 | _Striped (default)_ Striping off, with `striped={false}`: | Day | Average | Max | Min | | --- | --- | --- | --- | | Today | 222 | 260 | 147 | | Tomorrow | 254 | 279 | 211 | | Wednesday | 174 | 223 | 135 | _Not striped_ ### 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. | Day | Average | Max | Min | | --- | --- | --- | --- | | Today | 222 | 260 | 147 | | Tomorrow | 254 | 279 | 211 | | Wednesday | 174 | 223 | 135 | _National Grid Carbon Intensity Forecast (gCO2/kWh)_ ### 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. | Meter serial | Reading | Status | | --- | --- | --- | | 12,044 | Accepted | | 8,910 | Pending | ## Rules and guidance ### Usage - **DataTable** MUST only be used when cell content gains meaning from its position in both a row and a column. Rationale: If 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. [id: datatable.meaningful-row-column-relationship; testability: manual] - **DataTable** MUST NOT be used for layout purposes. Rationale: Tables 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. [id: datatable.tabular-data-only; testability: manual] - **DataTable** SHOULD include a caption that describes the table's purpose. Rationale: The 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. [id: datatable.caption; testability: auto] - **DataTable** SHOULD include column headings that describe the category of data in each column. Rationale: Column 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. [id: datatable.column-headings; testability: auto] - **DataTable** SHOULD use the rowHeadings prop when the first column contains an identifier or label for the row rather than a data value. Rationale: Row 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. [id: datatable.row-headings; testability: auto] - **DataTable** SHOULD keep row striping enabled for tables with more than five rows or more than three columns. Rationale: Striping 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. [id: datatable.striped-for-wide-tables; testability: context] - **DataTable** SHOULD display no more data than fits comfortably on screen without pagination or filtering. Rationale: Very 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. [id: datatable.large-dataset-split; testability: context] ### Content - **DataTable column heading** SHOULD use a noun or short noun phrase. Rationale: Column headings describe a category of data, not an action. Verb headings imply interactivity that does not exist. [id: datatable.column-heading-content; testability: manual] - **DataTable column heading** SHOULD include units of measurement rather than repeating them in every cell. Rationale: Repeating 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. [id: datatable.units-in-heading; testability: manual] - **DataTable column** SHOULD contain the same type of data in every row. Rationale: Mixed data types in a column prevent meaningful comparison. Users scanning vertically rely on consistent types to make sense of the values they are reading. [id: datatable.consistent-column-type; testability: manual] - **DataTable column containing numbers** SHOULD right-align numeric values. Rationale: Right-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. [id: datatable.numeric-column-alignment; testability: manual] - **DataTable cell** SHOULD NOT be left visually blank when a value is absent. Rationale: An 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. [id: datatable.empty-cell; testability: manual] ### Interaction - **DataTable scroll container** WILL become keyboard-focusable (tabIndex=0) automatically when the table overflows horizontally. Rationale: The component sets tabIndex=0 on the scroll container when content overflows, giving keyboard users access to scrolled content. [id: datatable.scroll-container-focusable; testability: auto] - **DataTable scroll container** MUST NOT have its tabIndex or scroll behaviour suppressed or overridden. Rationale: When 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. [id: datatable.no-keyboard-override; testability: auto] ### Composition - **DataTable** MUST NOT be nested inside another DataTable cell. Rationale: Nested 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. [id: datatable.no-nested-tables; testability: auto] - **DataTable cell** SHOULD NOT contain complex interactive controls such as form fields, date pickers, or modals. Rationale: Interactive 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. [id: datatable.no-complex-interactive-cells; testability: manual] ## Accessibility ### 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](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) #### 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](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) #### 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](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) ### 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](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) #### 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](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) ### 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 each test covers: - 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. What these statuses mean: - 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. 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. ## Properties ### DataTable Also accepts all props from `HTMLAttributes`, `TableHTMLAttributes`. - `items` (type `ReactNode[][]`, optional). Table body content as an array of rows, each an array of cell contents. - `columnHeadings` (type `ReactNode[]`, optional). Content of the column header cells, rendered in a header row above the body. - `caption` (type `ReactNode`, optional). Visible caption for the table, which also labels the scrollable container for assistive technology. - `rowHeadings` (type `boolean`, optional). Renders the first cell of each row as a row header (`th scope="row"`) rather than a data cell. - `columnWidths` (type `string[]`, optional). CSS widths for each column, matched to cells by index, for example `['20%', '10rem']`. - `striped` (type `boolean`, optional, default `true`). Gives alternate body rows a muted background. ### TableCaption Also accepts all props from `HTMLAttributes`. - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). 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`. - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). 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`. - `cellWidth` (type `string`, optional). CSS width of the cell's column, for example `20%` or `10rem`. - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). 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`. - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). 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`. - `stripe` (type `boolean`, optional). Gives the row a muted background, as used for alternate rows. - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). 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`. - `cellWidth` (type `string`, optional). CSS width of the cell's column, for example `20%` or `10rem`. - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). 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`. - `cellWidth` (type `string`, optional). CSS width of the cell's column, for example `20%` or `10rem`. - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). 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 Reference only; you do not need this to use the component. This is the CSS its styled elements use, with theme tokens shown by name. `${...}` is an interpolation (a theme token, a mixin or another value from the code). A `&[prop]` or `@when ` block holds CSS that applies only under that condition; these are notation, not valid CSS. ### table-container ```css overflow-x: auto; white-space: nowrap; :focus-visible { ${focusRing} } ``` ### table ```css border-radius: ${radii.standard}; border-collapse: separate; width: 100%; table-layout: auto; white-space: nowrap; ``` ### table-header ```css 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 ```css 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 ```css font-weight: ${fontWeights.bold}; text-align: left; color: ${colors.heading}; padding: ${space[2]}; ``` ### table-row ```css background-color: ${stripe ? colors.canvasMuted : colors.canvas}; ``` ### table-cell ```css 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]}; } ```