---
title: "Table"
description: "Rows and columns in a real semantic table, the readable twin a chart ships beside it, with a required caption, right-aligned numeric columns and its own scroll."
url: "https://opsinjs.pensievelabs.org/components/table"
source: "https://opsinjs.pensievelabs.org/components/table.md"
section: "Components"
status: "shipped"
kind: "component"
category: "data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["data table", "grid", "rows and columns"]
usedIn: ["trends-screen"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="table"
  status="shipped"
  questions="[
  &#x22;No contrast pair for the header ink, the caption ink or the hairline borders has been measured.&#x22;,
  &#x22;Nobody has listened to it in a screen reader, so the scopes and the caption are argued rather than heard.&#x22;,
  &#x22;Whether the bare focus stop on the scroll container should be a labelled region, or a stop at all when the table fits, is unresolved.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="table" />

## Installation [#installation]

<ComponentInstall name="table" unbuilt="false" importPath="@/components/ui/table" />

## Usage [#usage]

```tsx
import { Table } from "@/components/ui/table"
```

```tsx
<Table
  caption="Example figures by period, and not a real record"
  columns={[
    { key: "period", header: "Period" },
    { key: "count", header: "Count", numeric: true },
  ]}
  rows={[
    { period: "Period one", count: "12" },
    { period: "Period two", count: "1,000" },
  ]}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;The accessible twin of a chart: the same figures a screen reader user can read.&#x22;,
  &#x22;A small set of rows and columns a reader compares directly.&#x22;,
  &#x22;A comparison read down a column, where lining the digits up is the point.&#x22;,
]"
  avoid="[
  { case: &#x22;One reading with a label, a time and a surface of its own.&#x22;, instead: &#x22;metric-tile&#x22; },
  { case: &#x22;A trend read as a shape rather than as figures.&#x22;, instead: &#x22;trend-sparkline&#x22; },
  { case: &#x22;The data has not arrived and you are holding the layout open.&#x22;, instead: &#x22;skeleton&#x22; },
  { case: &#x22;There are no rows, because the result is genuinely empty.&#x22;, instead: &#x22;empty-state&#x22; },
  { case: &#x22;An interactive grid with sorting, selection and virtualised scrolling.&#x22;, instead: &#x22;card&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="table"
  parts="[
  { name: &#x22;Table&#x22;, describes: &#x22;The focusable scroll container and the table it wraps.&#x22;, prop: &#x22;className&#x22; },
  { name: &#x22;Table.Caption&#x22;, describes: &#x22;The required caption. captionHidden hides it on screen only.&#x22;, prop: &#x22;caption&#x22; },
  { name: &#x22;Table.Head&#x22;, describes: &#x22;One th per column, scope col, muted to read as labels.&#x22;, prop: &#x22;columns&#x22; },
  { name: &#x22;Table.Row&#x22;, describes: &#x22;One row per entry, hairline between. No hover or selected fill.&#x22;, prop: &#x22;rows&#x22; },
  { name: &#x22;Table.Cell&#x22;, describes: &#x22;One cell per column. A numeric column right-aligns.&#x22;, prop: &#x22;columns&#x22; },
]"
/>

<CompositionTree
  name="table"
  tree="[
  {
    part: &#x22;Table&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;Focusable, scrolls sideways&#x22;,
    children: [
      { part: &#x22;Table.Caption&#x22;, cardinality: &#x22;1&#x22;, note: &#x22;sr-only when captionHidden, never absent&#x22; },
      { part: &#x22;Table.Head&#x22;, cardinality: &#x22;1&#x22;, note: &#x22;Header cells, scope col&#x22; },
      {
        part: &#x22;Table.Body&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;A tbody&#x22;,
        children: [
          {
            part: &#x22;Table.Row&#x22;,
            cardinality: &#x22;1..n&#x22;,
            note: &#x22;One per row&#x22;,
            children: [
              { part: &#x22;Table.Cell&#x22;, cardinality: &#x22;1..n&#x22;, note: &#x22;First a th, the rest td&#x22; },
            ],
          },
        ],
      },
    ],
  },
]"
/>

## Examples [#examples]

### The readable twin of a chart [#the-readable-twin-of-a-chart]

The chart draws the shape; the table carries the figures. `captionHidden` hides
the caption the heading above already supplies.

<ComponentPreview name="table-a-chart-twin" kind="example" align="start" />

### Numeric columns line up on their digits [#numeric-columns-line-up-on-their-digits]

`numeric` right-aligns two columns in tabular figures while the first column
labels each row.

<ComponentPreview name="table-numeric-columns" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Write a caption naming the rows and columns, so the table stands on its own.
Where a heading already names the data, keep the caption and set
`captionHidden`.

<DoDont>
  <DoDont.Do>
    **"Weekly figures for the example account, in the example unit"** names rows and columns.
  </DoDont.Do>

  <DoDont.Dont>
    **"Table"**, or an empty caption, leaves numbers counting nothing.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA in a source pass and a rendered pass on
`/view/base/base-lyra/component/table`. The audit is author-run, not
independent, and clinical review is pending.

The audit found nothing to change in the source. The rendered pass is clean: no
axe violations, no target-size failures, no focus-visible gaps, no sideways page
scroll at 320px. A real `<caption>` always renders, headers carry `scope="col"`,
and the first body cell carries `scope="row"` when `rowHeader` is on. The
`overflow-x-auto` container is `tabIndex=0`, reachable and scrollable by
keyboard. Padding-based spacing and no fixed cell heights keep text-spacing
overrides from clipping. No colour prop, no `data-status`, no `data-category`,
no transition.

Two items remain open. An empty `caption` renders an empty `<caption>`, warning
only in development. The scroll container has no `role` and no accessible name;
adding `role="region"` would require one, left to the consuming page.

Contrast for the header ink, caption ink and hairlines has not been measured;
the report below stands in its place.

<KeyboardTable
  name="table"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus onto the scrollable table, then past it&#x22;,
    notes: &#x22;Present even when the table fits.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves back out of the table&#x22;,
    notes: &#x22;Cells are never focus stops.&#x22;,
  },
  {
    keys: &#x22;Left, Right&#x22;,
    action: &#x22;Scroll the table sideways when the container has focus&#x22;,
    notes: &#x22;Native to a focusable scroll region.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Scrolls the page, as anywhere else&#x22;,
    notes: &#x22;No control here captures it.&#x22;,
  },
]"
/>

<ContrastReport component="table" />

## Data attributes [#data-attributes]

<DataAttributesTable
  name="table"
  rows="[
  {
    attribute: &#x22;data-slot&#x22;,
    condition: &#x22;On the container, the table and every inner part, always&#x22;,
    value: &#x22;table-container, table, table-caption, table-header, table-head, table-body, table-row, table-cell&#x22;,
  },
  {
    attribute: &#x22;data-opsinjs-value&#x22;,
    condition: &#x22;On a td in a numeric column&#x22;,
    value: &#x22;The empty string, deliberately: a tabular-figures hook, not a magnitude&#x22;,
  },
]"
/>

## API reference [#api-reference]

<PropsTable name="TableProps" />

`caption` is a required `string`. `rowHeader` defaults to true, so the first
cell of each row is a `th`. There is no `onSort`, no `selectedRows` and no
`virtualized`.

## Related [#related]

* [MetricTile](./metric-tile.mdx) is one labelled reading on its own surface.
* [TrendSparkline](./trend-sparkline.mdx) draws a series as a shape.
* [Card](./card.mdx) is a surface for one record laid out freely.
* [ResultCard](./result-card.mdx) places one result with its explanation.
