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.
| Invoice | Customer | Status | Amount |
|---|---|---|---|
| INV-2041 | Northwind | Paid | $1,250.00 |
| INV-2040 | Fabrikam | Due today | $840.00 |
| INV-2039 | Contoso | Failed | $2,100.00 |
| INV-2038 | Litware | Draft | $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.
| Region | Q1 | Q2 |
|---|---|---|
| Europe | 3,204 | 3,610 |
| Americas | 2,987 | 3,105 |
| Asia-Pacific | 1,540 | 1,922 |
| Region | Q1 | Q2 |
|---|---|---|
| Europe | 3,204 | 3,610 |
| Americas | 2,987 | 3,105 |
| Asia-Pacific | 1,540 | 1,922 |
| Region | Q1 | Q2 |
|---|---|---|
| Europe | 3,204 | 3,610 |
| Americas | 2,987 | 3,105 |
| Asia-Pacific | 1,540 | 1,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
| Member | Role | Seats |
|---|---|---|
| Ada Lovelace | Owner | 12 |
| Grace Hopper | Admin | 4 |
| Member | Role | Seats |
|---|---|---|
| Ada Lovelace | Owner | 12 |
| Grace Hopper | Admin | 4 |
| Member | Role | Seats |
|---|---|---|
| Ada Lovelace | Owner | 12 |
| Grace Hopper | Admin | 4 |
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
| File | Size | Modified |
|---|---|---|
| index.html | 4.1 kB | 2 minutes ago |
| app.css | 18.7 kB | 2 minutes ago |
| main.js | 142.0 kB | 1 hour ago |
| vendor.js | 612.3 kB | 3 days ago |
| favicon.svg | 0.9 kB | 3 weeks ago |
| robots.txt | 0.1 kB | 3 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
| Name | Role | ||
|---|---|---|---|
| Ada Lovelace | ada@example.com | Owner | |
| Grace Hopper | grace@example.com | Admin | |
| Katherine Johnson | katherine@example.com | Member | |
| Margaret Hamilton | margaret@example.com | Member |
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
| contrast-harness | Rust | 2,205 | 2026-08-21 |
| forte-ui | TypeScript | 1,840 | 2026-08-30 |
| ramp | JavaScript | 312 | 2026-07-12 |
| motion-lab | TypeScript | 96 | 2026-05-02 |
| docs | MDX | 58 | 2026-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.
Footer and row headers
| Item | Qty | Unit | Total |
|---|---|---|---|
| Scale plan | 1 | $480.00 | $480.00 |
| Additional seats | 6 | $24.00 | $144.00 |
| Priority support | 1 | $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.
Sticky header
| Order | Ship to | Items | Total |
|---|---|---|---|
| ORD-1200 | Lisbon | 1 | $42.50 |
| ORD-1201 | Oslo | 2 | $85.00 |
| ORD-1202 | Nairobi | 3 | $127.50 |
| ORD-1203 | Kyoto | 4 | $170.00 |
| ORD-1204 | Bogotá | 5 | $212.50 |
| ORD-1205 | Toronto | 1 | $42.50 |
| ORD-1206 | Lisbon | 2 | $85.00 |
| ORD-1207 | Oslo | 3 | $127.50 |
| ORD-1208 | Nairobi | 4 | $170.00 |
| ORD-1209 | Kyoto | 5 | $212.50 |
| ORD-1210 | Bogotá | 1 | $42.50 |
| ORD-1211 | Toronto | 2 | $85.00 |
| ORD-1212 | Lisbon | 3 | $127.50 |
| ORD-1213 | Oslo | 4 | $170.00 |
| ORD-1214 | Nairobi | 5 | $212.50 |
| ORD-1215 | Kyoto | 1 | $42.50 |
| ORD-1216 | Bogotá | 2 | $85.00 |
| ORD-1217 | Toronto | 3 | $127.50 |
| ORD-1218 | Lisbon | 4 | $170.00 |
| ORD-1219 | Oslo | 5 | $212.50 |
| ORD-1220 | Nairobi | 1 | $42.50 |
| ORD-1221 | Kyoto | 2 | $85.00 |
| ORD-1222 | Bogotá | 3 | $127.50 |
| ORD-1223 | Toronto | 4 | $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
| Metric | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Signups | 312 | 340 | 398 | 421 | 466 | 512 | 548 | 601 | 655 | 702 | 740 | 812 |
| Active | 1,204 | 1,260 | 1,310 | 1,402 | 1,488 | 1,560 | 1,633 | 1,701 | 1,790 | 1,856 | 1,922 | 2,010 |
| Churned | 24 | 19 | 31 | 27 | 22 | 30 | 26 | 21 | 28 | 25 | 23 | 19 |
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
| Invoice | Customer | Amount |
|---|---|---|
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
One cell spanning every column. The header stays, so the reader still knows what would be here.
In a card
| Commit | Environment | Status | When |
|---|---|---|---|
| a1f9c2e | Production | Live | 4 min ago |
| 7be03d1 | Preview | Building | 12 min ago |
| c0d4e88 | Preview | Failed | 1 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.
| Property | Controls | Default |
|---|---|---|
--forte-table-font-size | Type size for every cell. size re-points it. | var(--forte-font-size-2) |
--forte-table-cell-px | Inline padding of every cell. size re-points it. | var(--forte-space-3) |
--forte-table-cell-py | Block padding of every cell. At md it follows data-forte-density; size re-points it. | var(--forte-list-item-py) |
--forte-table-border-width | Width of every rule and of the frame. | 1px |
--forte-table-border-color | Colour of the rules between rows and columns, and of the frame. | var(--forte-color-border-muted) |
--forte-table-radius | Corner radius of the frame. 0 on line, which has no frame to round; the surface radius on outline and grid. | 0 |
--forte-table-bg | Fill behind the whole table. Transparent, so it takes the surface it sits on. | transparent |
--forte-table-header-bg | Fill behind the header row. Transparent unless the header is sticky, where it must hide the rows scrolling under it. | transparent |
--forte-table-header-color | Text colour of the header cells. | var(--forte-color-foreground) |
--forte-table-header-font-weight | Weight of the header cells. | var(--forte-font-weight-semibold) |
--forte-table-footer-bg | Fill behind the footer rows. | color-mix(in oklab, var(--forte-color-foreground) 4%, transparent) |
--forte-table-row-bg-striped | Fill on every other body row when striped. | color-mix(in oklab, var(--forte-color-foreground) 4%, transparent) |
--forte-table-row-bg-hover | Fill on a body row under the pointer when hoverable. | color-mix(in oklab, var(--forte-color-foreground) 7%, transparent) |
--forte-table-row-bg-selected | Fill on a body row with selected. | var(--forte-color-primary-soft) |
--forte-table-caption-color | Text 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Table.Root
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
hoverable | boolean | false | Tint a body row under the pointer. Off by default because a hover fill says "you can click this", and most rows cannot be clicked. |
size | TableSize | md | Cell padding and type size. `md` follows `data-forte-density` through `--forte-list-item-py`; `sm` and `lg` are fixed steps either side of it. |
stickyHeader | boolean | false | Keep 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. |
striped | boolean | false | Tint every other body row. Helps the eye hold a row across a wide table; noise on a narrow one. |
variant | TableVariant | line | How 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
placement | TableCaptionPlacement | top | Which 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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Table.Body
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Table.Footer
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
Table.Row
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
selected | boolean | false | Tint 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
| Prop | Type | Default | Description |
|---|---|---|---|
align | TableAlign | start | Horizontal alignment of the cell's content. Defaults to the start edge — the UA centres `<th>` text, which lines up with nothing beneath it. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
numeric | boolean | false | Mark 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. | |
sort | TableSortDirection | undefined | The 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
| Prop | Type | Default | Description |
|---|---|---|---|
align | TableAlign | start | Horizontal alignment of the cell's content. |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
numeric | boolean | false | Mark 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. |