Table
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.
Preview
table · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/table
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Table } from "@/components/ui/table"<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
Use it when
- The accessible twin of a chart: the same figures a screen reader user can read.
- A small set of rows and columns a reader compares directly.
- A comparison read down a column, where lining the digits up is the point.
Do not use it when
- One reading with a label, a time and a surface of its own.Use
metric-tileinstead. - A trend read as a shape rather than as figures.Use
trend-sparklineinstead. - The data has not arrived and you are holding the layout open.Use
skeletoninstead. - There are no rows, because the result is genuinely empty.Use
empty-stateinstead. - An interactive grid with sorting, selection and virtualised scrolling.Use
cardinstead.
Anatomy
TableThe focusable scroll container and the table it wraps.Controlled byclassNameTable.CaptionThe required caption. captionHidden hides it on screen only.Controlled bycaptionTable.HeadOne th per column, scope col, muted to read as labels.Controlled bycolumnsTable.RowOne row per entry, hairline between. No hover or selected fill.Controlled byrowsTable.CellOne cell per column. A numeric column right-aligns.Controlled bycolumns
- Table 1Focusable, scrolls sideways
- Table.Caption 1sr-only when captionHidden, never absent
- Table.Head 1Header cells, scope col
- Table.Body 1A tbody
- Table.Row 1..nOne per row
- Table.Cell 1..nFirst a th, the rest td
- Table.Row 1..nOne per row
Examples
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.
table-a-chart-twin · base base · style base-lyraOpen under the product themeNumeric columns line up on their digits
numeric right-aligns two columns in tabular figures while the first column
labels each row.
table-numeric-columns · base base · style base-lyraOpen under the product themeContent 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.
Do
"Weekly figures for the example account, in the example unit" names rows and columns.
Don’t
"Table", or an empty caption, leaves numbers counting nothing.
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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus onto the scrollable table, then past it | Present even when the table fits. |
| Shift+Tab | Moves back out of the table | Cells are never focus stops. |
| Left, Right | Scroll the table sideways when the container has focus | Native to a focusable scroll region. |
| Space | Scrolls the page, as anywhere else | No control here captures it. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
Data attributes
| Attribute | Condition | Value |
|---|---|---|
| data-slot | On the container, the table and every inner part, always | table-container, table, table-caption, table-header, table-head, table-body, table-row, table-cell |
| data-opsinjs-value | On a td in a numeric column | The empty string, deliberately: a tabular-figures hook, not a magnitude |
API reference
Prop
Type
Generated from TableProps in registry/bases/base/table.tsx.
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
- MetricTile is one labelled reading on its own surface.
- TrendSparkline draws a series as a shape.
- Card is a surface for one record laid out freely.
- ResultCard places one result with its explanation.
RelativeTime
When something happened, written the way people say it. The exact date and time stay in reach, and explicit words appear once the staleness boundary is passed.
Badge
A small neutral label attached to something else, such as a count on a tab or a word on a list row. It takes neither colour axis and never stands in for status.