Table

PreviousNext

A data table with rounded rows, header cells, a sticky first column and row, column or cell styling

Weather in Punta Cana
MonthTemperature (min)Temperature (max)Precipitation
January22°C29°C61 mm
February22°C29°C48 mm
March23°C30°C56 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:

PartElementRole
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.

Shuttle timetable
StopBus 1Bus 2Bus 3Bus 4Bus 5Bus 6Bus 7Bus 8
Airport07:3009:0010:3012:0013:3015:0016:3018:00
Bus station07:4509:1510:4512:1513:4515:1516:4518:15
Harbour08:0009:3011:0012:3014:0015:3017:0018:30
City centre08:1509:4511:1512:4514:1515:4517:1518:45
Resort08:3010:0011:3013:0014:3016:0017:3019: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.

Weather in Punta Cana
MonthSunshineSea
January8 h26°C
February9 h27°C
March10 h28°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.

Prices per week
WeekFlightFrom Paris CDGPriceper person
Week 1June 1 – 8Included€1,290All inclusive
Week 2June 8 – 15Included€1,390All inclusive
Week 3June 15 – 22Included€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.

Compare the packages
PackageEssentialPremiumExclusive
Ski passIncludedIncludedIncluded
Ski lessons—IncludedIncluded
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.

Availabilities
WeekPriceRemaining places
Week 1€1,29012
Week 2€1,1903
Week 3€1,3900
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.

PropTypeDefaultDescription
captionReactNodeundefinedTitle of the table, rendered as its <caption>. Names the table and the scroll region.
captionClassNamestringundefinedClasses merged on the caption. sr-only keeps it for assistive technologies only.
columnOverridesReadonlyArray<TableColumnOverride | null | undefined>undefinedStyling of each column, by index. See TableColumnOverride.
scrollbarTableScrollbarundefinedOptions of the scrollbar shown under the table when it overflows. See TableScrollbar.
stickybooleanfalsePins the first cell of every row while the table scrolls.
classNamestringundefinedClasses of the scroll container, e.g. its margins.
tableClassNamestringundefinedClasses of the <table> element.
styleCSSPropertiesundefinedStyle of the <table> element, e.g. --table-bg-container.
aria-labelstringundefinedAccessible name of the table (and of the scroll region) without caption.
aria-labelledbystringundefinedId of an element naming the table, like the heading above it. Wins over the caption.

TableColumnOverride

PropTypeDefaultDescription
classNamestringundefinedClasses merged on every cell of the column, before the cell className.
styleCSSPropertiesundefinedStyle of every cell of the column, before the cell style.

TableScrollbar

The table scrolls inside a Scrollbar. These of its props are available:

PropTypeDefaultDescription
variant'light' | 'dark''light'light: saffron thumb on an ultramarine track. dark: ultramarine on white.
scrollbarWidthstring'60%'Width of the thumb.
scrollbarClassNamestringundefinedClasses merged on the track, e.g. to change its top margin (mt-16).
timelineNamestringuniqueCSS 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…).

PropTypeDefaultDescription
as'td' | 'th''td'th renders a bold header cell.
scopestringinferredScope of a header cell: col in THead and TFoot, row in TBody. Set it to override.
iconIconicNamesundefinedIcon displayed before the content, 30px wide and 8px away from it.
iconTypeIconicTypesundefinedRendering of the icon, svg or svg-use, see Icon.
titleReactNodeundefinedSemibold line above the content, which is then regular (even in a th). Replaces the native title attribute.
columnnumberinferredColumn index used to pick the styling in columnOverrides. Set it when a rowSpan above shifts the cells of a row.
classNamestringundefinedClasses merged on the cell, after the column ones.
styleCSSPropertiesundefinedStyle of the cell, after the column one.

Constants

NameValueUse
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

VariableSet onDefaultDescription
--table-td-bg-colora section, row, cell or columnpearl / light sandBackground of the cells.
--table-bg-containerthe table or an ancestorvar(--color-white)Fills the rounded corners of the sticky first cells.

Behaviour

Styling precedence

From the strongest to the weakest:

  1. the cell className and style,
  2. the column className and style,
  3. the row --table-td-bg-color (and inherited text styles),
  4. 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, or sr-only with captionClassName), an aria-labelledby pointing to the heading above the table, or an aria-label.
  • Header cells: use <TCell as="th"> for headers. Their scope is inferred from the section, so screen readers announce the right header with each data cell. Set scope (or headers on 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: Table measures its overflow in an effect, so it is a client component. The other parts are plain elements reading React context.
  • className goes to the container: on Table, className styles the scroll container, as in the source design system; use tableClassName for the <table> itself. The other props go to the <table>.
  • rowSpan and 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: set column on the cells that follow it.
  • columnOverrides keeps the cells in the DOM order: it does not reorder, hide or size columns. To size a column, pass a min-w-* or w-* class in its className.
  • 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.