Skip to content

Table

A table is the one component the browser already ships: <table>, <thead>, <tr>, <th> and <td> carry structure a screen reader can navigate by row and column, and no stack of <div>s can be made to do the same. Before this component existed, that markup was a class string — border-b border-border-muted px-3 py-2 on every cell — repeated at every call site and free to drift a token from the others. Table is that string as vocabulary, on the real elements.

table/basic.tsx
A list of your recent invoices.
InvoiceCustomerStatusAmount
INV-2041NorthwindPaid$1,250.00
INV-2040FabrikamDue today$840.00
INV-2039ContosoFailed$2,100.00
INV-2038LitwareDraft$415.50

There is no Base UI primitive underneath and no state at all. Every part is one HTML table element, one to one, and takes that element's own props. What earns it a component is a variant axis for how much of the grid to draw, a size axis the density presets drive, striping, hover and selection tints that each know which section they belong to, numeric columns with tabular figures, a sortable header that puts a real button in the cell, and a sticky header that actually sticks.

Sorting, filtering, pagination and selection state stay with you. The table reports a click on a sort control and tints a row it is told is selected, and does nothing else — which is what keeps it usable with any data layer, a row virtualiser included.

Import

import { Table } from "@forte-ui/react";

Table is a namespace of nine parts. Eight of them are the HTML table elements under their usual names; the ninth, Table.Container, is an optional scroll box.

<Table.Root variant="outline">
  <Table.Caption>Invoices this quarter</Table.Caption>
  <Table.Header>
    <Table.Row>
      <Table.Head>Invoice</Table.Head>
      <Table.Head>Status</Table.Head>
      <Table.Head numeric>Amount</Table.Head>
    </Table.Row>
  </Table.Header>
  <Table.Body>
    <Table.Row>
      <Table.Cell>INV-2041</Table.Cell>
      <Table.Cell>Paid</Table.Cell>
      <Table.Cell numeric>$250.00</Table.Cell>
    </Table.Row>
  </Table.Body>
</Table.Root>

Examples

Variants

One axis: how much of the grid is drawn. line rules between rows and nothing else; outline adds a frame with the surface radius; grid adds the column rules too.

table/variants.tsx
variant="line"
RegionQ1Q2
Europe3,2043,610
Americas2,9873,105
Asia-Pacific1,5401,922
variant="outline"
RegionQ1Q2
Europe3,2043,610
Americas2,9873,105
Asia-Pacific1,5401,922
variant="grid"
RegionQ1Q2
Europe3,2043,610
Americas2,9873,105
Asia-Pacific1,5401,922

The frame is drawn on the <table> itself, not on a wrapper, so a framed table sits in a Card or a Dialog with no extra element — and so Table.Container can stay optional. The corners clip with overflow: clip rather than hidden, which is not a detail: hidden would make the table a scroll container, and a sticky header inside a Table.Container would then stick to the table, which never scrolls, instead of to the container, which does.

Sizes

table/sizes.tsx
size="sm"
MemberRoleSeats
Ada LovelaceOwner12
Grace HopperAdmin4
size="md"
MemberRoleSeats
Ada LovelaceOwner12
Grace HopperAdmin4
size="lg"
MemberRoleSeats
Ada LovelaceOwner12
Grace HopperAdmin4

md follows data-forte-density through --forte-list-item-py, so a compact app gets compact tables without a prop. sm and lg are fixed steps either side of it — a table you sized by hand should not move again when the app's density does.

Striped and hoverable

table/striped.tsx
FileSizeModified
index.html4.1 kB2 minutes ago
app.css18.7 kB2 minutes ago
main.js142.0 kB1 hour ago
vendor.js612.3 kB3 days ago
favicon.svg0.9 kB3 weeks ago
robots.txt0.1 kB3 weeks ago

Both are off by default. Stripes help the eye hold a row across a wide table and are noise on a narrow one; a hover fill says "you can click this", and most rows cannot be clicked. The tints are a few percent of the foreground mixed over transparent rather than a grey step, so they read as one shade darker on any surface — the page, a panel, a dialog — in either theme.

Selection

table/selection.tsx
NameEmailRole
Ada Lovelaceada@example.comOwner
Grace Hoppergrace@example.comAdmin
Katherine Johnsonkatherine@example.comMember
Margaret Hamiltonmargaret@example.comMember

1 of 4 selected

selected on a row is the tint and only the tint. The state itself lives on the Checkbox in the row, which is what assistive technology reads and what the keyboard can reach; a row that is "selected" with no control saying so is selected for sighted mouse users alone. The header checkbox is the whole-table control — ticked when every row is, indeterminate when some are — and takes an aria-label because there is no visible label a cell that narrow could hold.

The tint order is deliberate: stripe, then hover, then selection, so a selected row stays selected under the pointer.

Sorting

table/sorting.tsx
contrast-harnessRust2,2052026-08-21
forte-uiTypeScript1,8402026-08-30
rampJavaScript3122026-07-12
motion-labTypeScript962026-05-02
docsMDX582026-08-31

Passing sort to a Table.Head — any value, "none" included — makes it a sort control. The label becomes a real <button> with an arrow, and the cell takes aria-sort, so a screen reader in table navigation hears the state and a keyboard user can reach it. Activating it calls onSortChange with the direction the column should take next: descending when it is currently ascending, ascending otherwise. Sort your rows and pass the new direction back; the table holds no sort state of its own.

The unsorted state shows a faint arrow rather than none — an invisible control is a control nobody finds — and the flip to descending is a rotation, which carries information and therefore keeps its duration under reduced motion.

table/footer.tsx
ItemQtyUnitTotal
Scale plan1$480.00$480.00
Additional seats6$24.00$144.00
Priority support1$120.00$120.00
Total due$744.00

Table.Footer is the <tfoot>: set apart by a heavier rule above it and the header's weight, for totals and summary lines. The first column here is a Table.Head with scope="row" — the cell that names its row — which is what lets a screen reader read "Additional seats, Total, $144.00" instead of a bare number.

table/sticky-header.tsx
OrderShip toItemsTotal
ORD-1200Lisbon1$42.50
ORD-1201Oslo2$85.00
ORD-1202Nairobi3$127.50
ORD-1203Kyoto4$170.00
ORD-1204Bogotá5$212.50
ORD-1205Toronto1$42.50
ORD-1206Lisbon2$85.00
ORD-1207Oslo3$127.50
ORD-1208Nairobi4$170.00
ORD-1209Kyoto5$212.50
ORD-1210Bogotá1$42.50
ORD-1211Toronto2$85.00
ORD-1212Lisbon3$127.50
ORD-1213Oslo4$170.00
ORD-1214Nairobi5$212.50
ORD-1215Kyoto1$42.50
ORD-1216Bogotá2$85.00
ORD-1217Toronto3$127.50
ORD-1218Lisbon4$170.00
ORD-1219Oslo5$212.50
ORD-1220Nairobi1$42.50
ORD-1221Kyoto2$85.00
ORD-1222Bogotá3$127.50
ORD-1223Toronto4$170.00

stickyHeader pins the header cells to the top of the nearest scrollport. That has to be a Table.Container with a height cap: a sticky element sticks to the nearest scrolling ancestor, and the page is a poor one. Because the container is what scrolls, the frame and the radius go on it here — a frame on the table would scroll away with the rows.

The header fill flips to an opaque surface colour when sticky, since that is the point at which a transparent header turns into rows visible through it.

Horizontal scrolling

table/scroll.tsx
MetricJanFebMarAprMayJunJulAugSepOctNovDec
Signups312340398421466512548601655702740812
Active1,2041,2601,3101,4021,4881,5601,6331,7011,7901,8561,9222,010
Churned241931272230262128252319

A <table> refuses to be narrower than its widest row, so on a phone a twelve-column table pushes the whole page sideways. Inside Table.Container the table scrolls and the page does not. The container is a separate part rather than something the root renders unconditionally, so that a table which fits its column needs no wrapper — and so that every ref, id and aria-* you pass has exactly one element to land on.

Loading

table/loading.tsx
Loading invoices
InvoiceCustomerAmount

The same rows render in both states, with each cell's content wrapped in a Skeleton.Root that sizes itself to the text it stands in for — so nothing shifts when the data arrives. Skeleton.Group goes around the whole table, not the body: a <div> inside a <tbody> is invalid markup, and the group is what marks the region busy and announces the label.

Empty

table/empty.tsx
NameOwnerUpdated
No projects yet.

One cell spanning every column. The header stays, so the reader still knows what would be here.

In a card

table/card.tsx
Recent deploys
The last three builds across every environment.
CommitEnvironmentStatusWhen
a1f9c2eProductionLive4 min ago
7be03d1PreviewBuilding12 min ago
c0d4e88PreviewFailed1 h ago

Use line inside a card: the card is already the frame, and a second one an inch inside it reads as a box in a box.

Accessibility

The parts are the HTML table elements, which is most of the accessibility work done. Screen readers navigate a real <table> cell by cell and announce the column header for each; no role="grid" reconstruction of that on <div>s comes close. Three things remain yours:

Name the table. Prefer Table.Caption to aria-label on the root: a caption is visible, so it names the table for everyone. placement="bottom" makes it read as a footnote if a title above the table is too much.

Mark row headers. When one column names the row — an item, a person, a file — make it a Table.Head with scope="row", so the row's other cells are announced against it.

Keep state on controls. A sortable column is a <button> inside the header; a selected row is a Checkbox inside the row. The table's sort and selected props drive aria-sort and a tint respectively, and neither is a substitute for the control.

The sort button reaches the 24px minimum target (SC 2.5.8) at every size through a negative block margin rather than by growing the row. Under forced colours the fills are stripped and the rules are the whole grid, painted in the system ink; a selected row takes the system highlight, and the faint unsorted arrow — which is opacity, untouched by forced colours — is redrawn in GrayText so it does not claim every sortable column is sorted.

Theming

Everything below is declared on Table.Root itself, which is also why an ancestor is the wrong place to set one — the element's own declaration beats an inherited value. Override them on Table.Root through its className or a style object, or re-point the global tokens in the Default column to move every table at once.

Theming tokens for Table
PropertyControlsDefault
--forte-table-font-sizeType size for every cell. size re-points it.var(--forte-font-size-2)
--forte-table-cell-pxInline padding of every cell. size re-points it.var(--forte-space-3)
--forte-table-cell-pyBlock padding of every cell. At md it follows data-forte-density; size re-points it.var(--forte-list-item-py)
--forte-table-border-widthWidth of every rule and of the frame.1px
--forte-table-border-colorColour of the rules between rows and columns, and of the frame.var(--forte-color-border-muted)
--forte-table-radiusCorner radius of the frame. 0 on line, which has no frame to round; the surface radius on outline and grid.0
--forte-table-bgFill behind the whole table. Transparent, so it takes the surface it sits on.transparent
--forte-table-header-bgFill behind the header row. Transparent unless the header is sticky, where it must hide the rows scrolling under it.transparent
--forte-table-header-colorText colour of the header cells.var(--forte-color-foreground)
--forte-table-header-font-weightWeight of the header cells.var(--forte-font-weight-semibold)
--forte-table-footer-bgFill behind the footer rows.color-mix(in oklab, var(--forte-color-foreground) 4%, transparent)
--forte-table-row-bg-stripedFill on every other body row when striped.color-mix(in oklab, var(--forte-color-foreground) 4%, transparent)
--forte-table-row-bg-hoverFill on a body row under the pointer when hoverable.color-mix(in oklab, var(--forte-color-foreground) 7%, transparent)
--forte-table-row-bg-selectedFill on a body row with selected.var(--forte-color-primary-soft)
--forte-table-caption-colorText colour of the caption.var(--forte-color-foreground-muted)

State is on data-variant, data-size, data-striped, data-hoverable, data-sticky-header, data-selected, data-align and data-numeric, reachable from plain CSS or a Tailwind arbitrary variant — data-[selected]:... — without a wrapper element.

API reference

Every part is the plain HTML element of the same name and takes that element's whole prop surface, ref included. There is no render prop, because there is no Base UI primitive to swap the element on.

Table.Container

Props for TableContainer
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Table.Root

Props for TableRoot
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
hoverablebooleanfalseTint a body row under the pointer. Off by default because a hover fill says "you can click this", and most rows cannot be clicked.
sizeTableSizemdCell padding and type size. `md` follows `data-forte-density` through `--forte-list-item-py`; `sm` and `lg` are fixed steps either side of it.
stickyHeaderbooleanfalseKeep the header row visible while the body scrolls. Needs a scrolling ancestor — `Table.Container` with a height cap — because a sticky element sticks to the nearest scrollport, and the page is a poor one.
stripedbooleanfalseTint every other body row. Helps the eye hold a row across a wide table; noise on a narrow one.
variantTableVariantlineHow much of the grid is drawn. `line` rules between rows only; `outline` adds a frame with the surface radius around the whole table; `grid` adds the column rules as well.

Table.Caption

Props for TableCaption
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
placementTableCaptionPlacementtopWhich edge of the table the caption sits on. It is the table's accessible name either way; `bottom` reads as a footnote, `top` as a title.

Table.Header

Props for TableHeader
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Table.Body

Props for TableBody
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Table.Footer

Props for TableFooter
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Table.Row

Props for TableRow
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
selectedbooleanfalseTint the row as chosen. Visual only — put the state itself on a `Checkbox` in the row, which is what assistive technology reads and what the keyboard can reach.

Table.Head

Props for TableHead
PropTypeDefaultDescription
alignTableAlignstartHorizontal alignment of the cell's content. Defaults to the start edge — the UA centres `<th>` text, which lines up with nothing beneath it.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
numericbooleanfalseMark the column as numeric: end-aligned, with tabular figures so digits line up down the column. Set it on the header and the cells alike.
onSortChange((direction: "ascending" | "descending") => void)Called when the sort control is activated, with the direction the column should take next: `descending` when it is currently ascending, `ascending` otherwise. The table holds no sort state of its own — sort the rows and pass the new `sort` back.
sortTableSortDirectionundefinedThe column's current sort. Passing any value — `none` included — makes the header a sort control: the label becomes a button with an arrow, and `aria-sort` reports the state to assistive technology.

Table.Cell

Props for TableCell
PropTypeDefaultDescription
alignTableAlignstartHorizontal alignment of the cell's content.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
numericbooleanfalseMark the cell as numeric: end-aligned, with tabular figures so digits line up down the column. Set it on the header and the cells alike.