- Accordion
- Advanced Toast
- AlertMessage
- ArrowButton
- Arrows
- Avatar
- Backdrop
- Basic Toast
- Breadcrumb
- Button
- CardBackground
- Card
- Carousel
- Chapters
- ChatBody
- ChatButton
- ChatHeader
- ChatInput
- ChatMessageActions
- ChatMessageEditor
- ChatMessage
- ChatTypingIndicator
- ChatWindowCsatPrompt
- ChatWindowQuote
- ChatWindow
- CheckboxSelect
- Checkbox
- Checkboxes
- Chip
- ChoiceExpander
- Clickable
- ContentReview
- Countdown
- CriteriaDates
- CriteriaParticipants
- CriteriaTransports
- Date Field
- Datepicker
- Dropdown
- ElasticHeight
- ExpandableCard
- ExpandableText
- Filter
- FloatingLabelTextField
- Form Control
- Form Label
- Frame
- HamburgerIcon
- Heading
- Image
- JourneyCard
- LayerHeader
- Layer
- Link
- Loader
- Number Field
- PageNavigation
- Pagination
- Paragraph
- Password
- Phone Field
- Popin
- Portal
- ProductThumbnail
- Promises
- Prose
- Radio Group
- RadioSelect
- Radio
- Range
- Scrollbar
- Select
- SidebarLayout
- SliderFrame
- SparkleWrapper
- Spinner
- Switch
- Table
- Tabs
- Tag
- TextField
- Toggle
- Tooltip
- ValidationMessage
| Month | Temperature (min) | Temperature (max) | Precipitation |
|---|---|---|---|
| January | 22°C | 29°C | 61 mm |
| February | 22°C | 29°C | 48 mm |
| March | 23°C | 30°C | 56 mm |
import { TBody, TCell, THead, TRow, Table } from '@/ui/table/Table';
const MONTHS = [
{ month: 'January', min: '22°C', max: '29°C', rain: '61 mm' },
{ month: 'February', min: '22°C', max: '29°C', rain: '48 mm' },
{ month: 'March', min: '23°C', max: '30°C', rain: '56 mm' },
];
export function TableDemo() {
return (
<Table caption="Weather in Punta Cana" captionClassName="sr-only">
<THead>
<TRow>
<TCell as="th">Month</TCell>
<TCell as="th">Temperature (min)</TCell>
<TCell as="th">Temperature (max)</TCell>
<TCell as="th">Precipitation</TCell>
</TRow>
</THead>
<TBody>
{MONTHS.map(({ month, min, max, rain }) => (
<TRow key={month}>
<TCell as="th">{month}</TCell>
<TCell>{min}</TCell>
<TCell>{max}</TCell>
<TCell>{rain}</TCell>
</TRow>
))}
</TBody>
</Table>
);
}
Overview
Table displays tabular data as a stack of rounded rows. It is built from six parts that map one to one to the HTML table elements:
| Part | Element | Role |
|---|---|---|
Table | <div> + <table> | Scroll container and table. Holds the caption and the columnOverrides. |
THead | <thead> | Header rows, on a light sand background. |
TBody | <tbody> | Body rows, on a pearl background. |
TFoot | <tfoot> | Footer rows, styled like the header rows. |
TRow | <tr> | A row. |
TCell | <td> or <th> (as) | A data cell, or a header cell with as="th". |
When the table is wider than its container, it scrolls horizontally, cell by cell, and the container becomes a focusable region.
Installation
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/table.json
Usage
import { TBody, TCell, THead, TRow, Table } from '@/ui/table/Table';
export function Weather() {
return (
<Table caption="Weather in Punta Cana" captionClassName="sr-only">
<THead>
<TRow>
<TCell as="th">Month</TCell>
<TCell as="th">Temperature (min)</TCell>
<TCell as="th">Temperature (max)</TCell>
</TRow>
</THead>
<TBody>
{months.map(({ month, min, max }) => (
<TRow key={month}>
<TCell as="th">{month}</TCell>
<TCell>{min}</TCell>
<TCell>{max}</TCell>
</TRow>
))}
</TBody>
</Table>
);
}Header cells in THead and TFoot become column headers (scope="col"); header cells in TBody, usually the first cell of each row, become row headers (scope="row").
Sticky first column
sticky pins the first cell of every row while the table scrolls. Cells then snap next to the pinned column instead of sliding under it, and a shadow fades in on the side of the pinned column during the first 40px of scroll.
| Stop | Bus 1 | Bus 2 | Bus 3 | Bus 4 | Bus 5 | Bus 6 | Bus 7 | Bus 8 |
|---|---|---|---|---|---|---|---|---|
| Airport | 07:30 | 09:00 | 10:30 | 12:00 | 13:30 | 15:00 | 16:30 | 18:00 |
| Bus station | 07:45 | 09:15 | 10:45 | 12:15 | 13:45 | 15:15 | 16:45 | 18:15 |
| Harbour | 08:00 | 09:30 | 11:00 | 12:30 | 14:00 | 15:30 | 17:00 | 18:30 |
| City centre | 08:15 | 09:45 | 11:15 | 12:45 | 14:15 | 15:45 | 17:15 | 18:45 |
| Resort | 08:30 | 10:00 | 11:30 | 13:00 | 14:30 | 16:00 | 17:30 | 19:00 |
'use client';
import { TBody, TCell, THead, TRow, Table } from '@/ui/table/Table';
const STOPS = ['Airport', 'Bus station', 'Harbour', 'City centre', 'Resort'];
const DEPARTURES = ['07:30', '09:00', '10:30', '12:00', '13:30', '15:00', '16:30', '18:00'];
export function TableStickyDemo() {
return (
<div className="max-w-375">
<Table caption="Shuttle timetable" captionClassName="sr-only" sticky>
<THead>
<TRow>
<TCell as="th">Stop</TCell>
{DEPARTURES.map((_, index) => (
<TCell as="th" key={index}>{`Bus ${index + 1}`}</TCell>
))}
</TRow>
</THead>
<TBody>
{STOPS.map((stop, stopIndex) => (
<TRow key={stop}>
<TCell as="th">{stop}</TCell>
{DEPARTURES.map((time) => {
const [hours, minutes] = time.split(':').map(Number);
const at = hours * 60 + minutes + stopIndex * 15;
return (
<TCell key={time}>
{`${String(Math.floor(at / 60)).padStart(2, '0')}:${String(at % 60).padStart(2, '0')}`}
</TCell>
);
})}
</TRow>
))}
</TBody>
</Table>
</div>
);
}
<div className="max-w-375">
<Table caption="Shuttle timetable" captionClassName="sr-only" sticky>
<THead>
<TRow>
<TCell as="th">Stop</TCell>
{buses.map((bus) => (
<TCell as="th" key={bus}>
{bus}
</TCell>
))}
</TRow>
</THead>
<TBody>{/* rows starting with a <TCell as="th"> */}</TBody>
</Table>
</div>The rounded corners of the pinned cells are filled with the container background, white by default. On another background, set the --table-bg-container variable on the table, and on a dark background switch the scrollbar to its dark variant:
<Table
scrollbar={{ variant: 'dark' }}
sticky
style={{ '--table-bg-container': 'var(--color-ultramarine)' } as CSSProperties}
>Icons
icon puts an icon before the content of a cell: the icon (30px wide) and the content sit in a row, 8px apart. It works on data cells and header cells alike.
| Month | Sunshine | Sea |
|---|---|---|
| January | 8 h | 26°C |
| February | 9 h | 27°C |
| March | 10 h | 28°C |
import { TBody, TCell, THead, TRow, Table } from '@/ui/table/Table';
const MONTHS = [
{ month: 'January', sunshine: '8 h', sea: '26°C' },
{ month: 'February', sunshine: '9 h', sea: '27°C' },
{ month: 'March', sunshine: '10 h', sea: '28°C' },
];
export function TableIconsDemo() {
return (
<Table caption="Weather in Punta Cana" captionClassName="sr-only">
<THead>
<TRow>
<TCell as="th">Month</TCell>
<TCell as="th" icon="SunView">
Sunshine
</TCell>
<TCell as="th" icon="SwimmingArea">
Sea
</TCell>
</TRow>
</THead>
<TBody>
{MONTHS.map(({ month, sunshine, sea }) => (
<TRow key={month}>
<TCell as="th">{month}</TCell>
<TCell>{sunshine}</TCell>
<TCell>{sea}</TCell>
</TRow>
))}
</TBody>
</Table>
);
}
<THead>
<TRow>
<TCell as="th">Month</TCell>
<TCell as="th" icon="SunView">
Sunshine
</TCell>
<TCell as="th" icon="SwimmingArea">
Sea
</TCell>
</TRow>
</THead>Title and content
title adds a semibold line above the content of a cell (children), which is then displayed in regular weight — in header cells too. Both lines are centered, and they sit after the icon when the cell has one.
| Week | FlightFrom Paris CDG | Priceper person |
|---|---|---|
| Week 1June 1 – 8 | Included | €1,290All inclusive |
| Week 2June 8 – 15 | Included | €1,390All inclusive |
| Week 3June 15 – 22 | Included | €1,490All inclusive |
import { TBody, TCell, THead, TRow, Table } from '@/ui/table/Table';
const WEEKS = [
{ week: 'Week 1', dates: 'June 1 – 8', price: '€1,290' },
{ week: 'Week 2', dates: 'June 8 – 15', price: '€1,390' },
{ week: 'Week 3', dates: 'June 15 – 22', price: '€1,490' },
];
export function TableTitlesDemo() {
return (
<Table caption="Prices per week" captionClassName="sr-only">
<THead>
<TRow>
<TCell as="th">Week</TCell>
<TCell as="th" icon="PlaneDefault" title="Flight">
From Paris CDG
</TCell>
<TCell as="th" title="Price">
per person
</TCell>
</TRow>
</THead>
<TBody>
{WEEKS.map(({ week, dates, price }) => (
<TRow key={week}>
<TCell as="th" title={week}>
{dates}
</TCell>
<TCell>Included</TCell>
<TCell title={price}>All inclusive</TCell>
</TRow>
))}
</TBody>
</Table>
);
}
<TRow>
<TCell as="th" title="Week 1">
June 1 – 8
</TCell>
<TCell>Included</TCell>
<TCell title="€1,290">All inclusive</TCell>
</TRow>title is not the native title attribute: the cell has no tooltip. Use it as a visible label only.
Column styles
columnOverrides styles every cell of a column, in the header, body and footer rows. Each entry takes a className and a style, by column index; leave holes for the columns to keep as is.
| Package | Essential | Premium | Exclusive |
|---|---|---|---|
| Ski pass | Included | Included | Included |
| Ski lessons | — | Included | Included |
| Spa access | — | — | Included |
| From | €1,290 | €1,590 | €2,190 |
import { TABLE_HEADER_STYLE, TBody, TCell, TFoot, THead, TRow, Table } from '@/ui/table/Table';
const FEATURES = [
{ name: 'Ski pass', essential: 'Included', premium: 'Included', exclusive: 'Included' },
{ name: 'Ski lessons', essential: '—', premium: 'Included', exclusive: 'Included' },
{ name: 'Spa access', essential: '—', premium: '—', exclusive: 'Included' },
];
const COLUMN_OVERRIDES = [
{ style: TABLE_HEADER_STYLE },
undefined,
{ className: 'bg-saffron font-semibold' },
];
export function TableColumnsDemo() {
return (
<Table
caption="Compare the packages"
captionClassName="sr-only"
columnOverrides={COLUMN_OVERRIDES}
>
<THead>
<TRow>
<TCell as="th">Package</TCell>
<TCell as="th">Essential</TCell>
<TCell as="th">Premium</TCell>
<TCell as="th">Exclusive</TCell>
</TRow>
</THead>
<TBody>
{FEATURES.map(({ name, essential, premium, exclusive }) => (
<TRow key={name}>
<TCell as="th">{name}</TCell>
<TCell>{essential}</TCell>
<TCell>{premium}</TCell>
<TCell>{exclusive}</TCell>
</TRow>
))}
</TBody>
<TFoot>
<TRow>
<TCell as="th">From</TCell>
<TCell>€1,290</TCell>
<TCell>€1,590</TCell>
<TCell>€2,190</TCell>
</TRow>
</TFoot>
</Table>
);
}
import { TABLE_HEADER_STYLE, TBody, TCell, TFoot, THead, TRow, Table } from '@/ui/table/Table';
const COLUMN_OVERRIDES = [
{ style: TABLE_HEADER_STYLE }, // first column with the header background
undefined, // second column untouched
{ className: 'bg-saffron font-semibold' }, // third column highlighted
];
export function Packages() {
return (
<Table caption="Compare the packages" captionClassName="sr-only" columnOverrides={COLUMN_OVERRIDES}>
<THead>…</THead>
<TBody>…</TBody>
<TFoot>…</TFoot>
</Table>
);
}The column index accounts for colSpan: after a cell spanning two columns, the next cell is in the third column. Cells wrapped in fragments or in your own components are counted too.
Row and cell styles
A row and a cell take any className and style. Every cell paints its background from the --table-td-bg-color CSS variable, so set it on a row to change the background of all of its cells; text styles (font-semibold, text-white…) are inherited by the cells.
| Week | Price | Remaining places |
|---|---|---|
| Week 1 | €1,290 | 12 |
| Week 2 | €1,190 | 3 |
| Week 3 | €1,390 | 0 |
import type { CSSProperties } from 'react';
import { TBody, TCell, THead, TRow, Table } from '@/ui/table/Table';
const HIGHLIGHTED_ROW = { '--table-td-bg-color': 'var(--color-wave)' } as CSSProperties;
const WEEKS = [
{ week: 'Week 1', price: '€1,290', places: '12' },
{ week: 'Week 2', price: '€1,190', places: '3' },
{ week: 'Week 3', price: '€1,390', places: '0' },
];
export function TableRowStylesDemo() {
return (
<Table caption="Availabilities" captionClassName="sr-only">
<THead>
<TRow>
<TCell as="th">Week</TCell>
<TCell as="th">Price</TCell>
<TCell as="th">Remaining places</TCell>
</TRow>
</THead>
<TBody>
{WEEKS.map(({ week, price, places }) => (
<TRow
className={week === 'Week 2' ? 'font-semibold text-white' : undefined}
key={week}
style={week === 'Week 2' ? HIGHLIGHTED_ROW : undefined}
>
<TCell as="th">{week}</TCell>
<TCell>{price}</TCell>
<TCell className={places === '0' ? 'bg-red text-white' : undefined}>{places}</TCell>
</TRow>
))}
</TBody>
</Table>
);
}
const HIGHLIGHTED_ROW = { '--table-td-bg-color': 'var(--color-wave)' } as CSSProperties;
<TBody>
{weeks.map(({ week, price, places }) => (
<TRow
className={week === 'Week 2' ? 'font-semibold text-white' : undefined}
key={week}
style={week === 'Week 2' ? HIGHLIGHTED_ROW : undefined}
>
<TCell as="th">{week}</TCell>
<TCell>{price}</TCell>
<TCell className={places === '0' ? 'bg-red text-white' : undefined}>{places}</TCell>
</TRow>
))}
</TBody>;A background class on a TRow (bg-wave) has no visible effect: the cells paint over it. Use the variable.
API Reference
Table
Extra props are spread on the <table> element.
| Prop | Type | Default | Description |
|---|---|---|---|
caption | ReactNode | undefined | Title of the table, rendered as its <caption>. Names the table and the scroll region. |
captionClassName | string | undefined | Classes merged on the caption. sr-only keeps it for assistive technologies only. |
columnOverrides | ReadonlyArray<TableColumnOverride | null | undefined> | undefined | Styling of each column, by index. See TableColumnOverride. |
scrollbar | TableScrollbar | undefined | Options of the scrollbar shown under the table when it overflows. See TableScrollbar. |
sticky | boolean | false | Pins the first cell of every row while the table scrolls. |
className | string | undefined | Classes of the scroll container, e.g. its margins. |
tableClassName | string | undefined | Classes of the <table> element. |
style | CSSProperties | undefined | Style of the <table> element, e.g. --table-bg-container. |
aria-label | string | undefined | Accessible name of the table (and of the scroll region) without caption. |
aria-labelledby | string | undefined | Id of an element naming the table, like the heading above it. Wins over the caption. |
TableColumnOverride
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | undefined | Classes merged on every cell of the column, before the cell className. |
style | CSSProperties | undefined | Style of every cell of the column, before the cell style. |
TableScrollbar
The table scrolls inside a Scrollbar. These of its props are available:
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'light' | 'dark' | 'light' | light: saffron thumb on an ultramarine track. dark: ultramarine on white. |
scrollbarWidth | string | '60%' | Width of the thumb. |
scrollbarClassName | string | undefined | Classes merged on the track, e.g. to change its top margin (mt-16). |
timelineName | string | unique | CSS scroll timeline name. Unique per table by default. |
THead, TBody, TFoot, TRow
They accept the attributes of their element (<thead>, <tbody>, <tfoot>, <tr>). The style of a section is merged with its default --table-td-bg-color.
TCell
Extra props are spread on the <td> or <th> element (colSpan, rowSpan, headers…).
| Prop | Type | Default | Description |
|---|---|---|---|
as | 'td' | 'th' | 'td' | th renders a bold header cell. |
scope | string | inferred | Scope of a header cell: col in THead and TFoot, row in TBody. Set it to override. |
icon | IconicNames | undefined | Icon displayed before the content, 30px wide and 8px away from it. |
iconType | IconicTypes | undefined | Rendering of the icon, svg or svg-use, see Icon. |
title | ReactNode | undefined | Semibold line above the content, which is then regular (even in a th). Replaces the native title attribute. |
column | number | inferred | Column index used to pick the styling in columnOverrides. Set it when a rowSpan above shifts the cells of a row. |
className | string | undefined | Classes merged on the cell, after the column ones. |
style | CSSProperties | undefined | Style of the cell, after the column one. |
Constants
| Name | Value | Use |
|---|---|---|
TABLE_HEADER_STYLE | { '--table-td-bg-color': 'var(--color-lightSand)' } | Gives a row, a cell or a column the header background. |
TABLE_BODY_STYLE | { '--table-td-bg-color': 'var(--color-pearl)' } | Gives a row, a cell or a column the body background. |
CSS variables
| Variable | Set on | Default | Description |
|---|---|---|---|
--table-td-bg-color | a section, row, cell or column | pearl / light sand | Background of the cells. |
--table-bg-container | the table or an ancestor | var(--color-white) | Fills the rounded corners of the sticky first cells. |
Behaviour
Styling precedence
From the strongest to the weakest:
- the cell
classNameandstyle, - the column
classNameandstyle, - the row
--table-td-bg-color(and inherited text styles), - the section default (
THead,TBody,TFoot).
Classes are merged with tailwind-merge, so a column or cell bg-saffron replaces the default background class instead of fighting with it.
Horizontal scroll
The table scrolls horizontally inside the library Scrollbar: the native scrollbar is hidden, replaced by its saffron thumb on an ultramarine track, which can be dragged or clicked. Scrolling snaps on the start of each cell, and is not smoothed for people who prefer reduced motion. Below md, cells are at least 140px wide, so wide tables scroll on mobile instead of squeezing their content.
The scroller watches its size and the size of the table (ResizeObserver): the track is only displayed, and the scroller only becomes a focusable region, while the table actually overflows.
The thumb follows the scroll position with a CSS scroll-driven animation (animation-timeline). In browsers without support for it, the thumb does not move with the scroll, but dragging it still scrolls the table.
Sticky column shadow
The shadow of the pinned column is a scroll-driven animation (animation-timeline: scroll(nearest inline), defined in Table.css): it follows the scroll position of the table without any JavaScript, fades in over the first 40px of scroll and fades out when scrolling back. While the table fits, the scroll timeline is inactive and there is no shadow. In right-to-left layouts, the shadow is cast on the left.
In browsers without scroll-driven animations, the column is still pinned, without the shadow. The shadow ignores the reduced motion preference on purpose: it does not move anything, and it would otherwise show before any scroll.
Rounded rows
The first and last cells of each row are rounded on their inline start and end sides, and cells are separated by a light grey border: in right-to-left layouts, the rounding and the borders follow the reading direction, and so does the sticky column.
Accessibility
- Name the table: pass a
caption(visible, orsr-onlywithcaptionClassName), anaria-labelledbypointing to the heading above the table, or anaria-label. - Header cells: use
<TCell as="th">for headers. Theirscopeis inferred from the section, so screen readers announce the right header with each data cell. Setscope(orheaderson the data cells) for irregular tables. - Keyboard scroll: when the table overflows, the scroller gets
tabIndex={0},role="region"and the name of the table, so keyboard users can reach it with Tab and scroll it with the arrow keys. It shows the focus ring of the library (ring-4 ring-black ring-offset-2). A table that fits stays a plain container, out of the tab order. - Colors: when a row, column or cell gets a dark background (
wave,red,ultramarine…), set a light text color too, and never convey information by color alone (add a label or an icon).
Notes
- Client component:
Tablemeasures its overflow in an effect, so it is a client component. The other parts are plain elements reading React context. classNamegoes to the container: onTable,classNamestyles the scroll container, as in the source design system; usetableClassNamefor the<table>itself. The other props go to the<table>.rowSpanand column indexes: the column index of a cell is computed from its position in its own row. A cell spanning rows from a previous row is not accounted for: setcolumnon the cells that follow it.columnOverrideskeeps the cells in the DOM order: it does not reorder, hide or size columns. To size a column, pass amin-w-*orw-*class in itsclassName.- No
<colgroup>: column styles are applied to the cells themselves, because<col>only supports a few properties and would be hidden by the cell backgrounds.
On This Page
OverviewInstallationUsageSticky first columnIconsTitle and contentColumn stylesRow and cell stylesAPI ReferenceTableTableColumnOverrideTableScrollbarTHead, TBody, TFoot, TRowTCellConstantsCSS variablesBehaviourStyling precedenceHorizontal scrollSticky column shadowRounded rowsAccessibilityNotes