---
title: Table
description: A data table with rounded rows, header cells, a sticky first column and row, column or cell styling
---

<ComponentPreview align="start" name="table-demo" />

## 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

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/table.json
```

Or manually, copy the following code into `/ui/table/Table.tsx`:

```tsx
'use client';

import { Icon, type IconicNames, type IconicTypes } from '@clubmed/trident-icons';
import clsx from 'clsx';
import {
  Children,
  type ComponentPropsWithoutRef,
  type CSSProperties,
  createContext,
  Fragment,
  type FunctionComponent,
  isValidElement,
  type ReactNode,
  useContext,
  useEffect,
  useId,
  useRef,
  useState,
} from 'react';

import { cn } from '../helpers/cn';
import { Scrollbar, type ScrollbarProps } from '../Scrollbar';

import './Table.css';

/**
 * Cell background of the header and footer rows: spread it in the `style` of any row, cell or
 * column to give it the header look
 */
export const TABLE_HEADER_STYLE = {
  '--table-td-bg-color': 'var(--color-lightSand)',
} as CSSProperties;

/**
 * Cell background of the body rows
 */
export const TABLE_BODY_STYLE = {
  '--table-td-bg-color': 'var(--color-pearl)',
} as CSSProperties;

/**
 * Styling applied to every cell of a column (header, body and footer rows)
 */
export interface TableColumnOverride {
  className?: string;
  style?: CSSProperties;
}

/**
 * Options of the horizontal scrollbar, see `Scrollbar`
 */
export type TableScrollbar = Pick<
  ScrollbarProps,
  'scrollbarClassName' | 'scrollbarWidth' | 'timelineName' | 'variant'
>;

type TableSection = 'head' | 'body' | 'foot';

const ColumnOverridesContext = createContext<
  ReadonlyArray<TableColumnOverride | null | undefined> | undefined
>(undefined);
const SectionContext = createContext<TableSection | undefined>(undefined);
const ColumnIndexContext = createContext<number | undefined>(undefined);

export interface TableProps extends ComponentPropsWithoutRef<'table'> {
  /**
   * Accessible title of the table, rendered as its `<caption>`. Add `sr-only` to `captionClassName`
   * to keep it for assistive technologies only
   */
  caption?: ReactNode;
  captionClassName?: string;
  /**
   * Styling of each column, by index: the cells of the column get the `className` and the `style`
   * (cell props still win). Leave holes (`undefined`) for the columns to keep as is
   */
  columnOverrides?: ReadonlyArray<TableColumnOverride | null | undefined>;
  /**
   * Options of the scrollbar displayed under the table when it overflows: use the `dark` variant on
   * dark backgrounds
   */
  scrollbar?: TableScrollbar;
  /**
   * Pins the first cell of every row while the table scrolls horizontally. Once scrolled, a shadow
   * appears on the side of the pinned column
   */
  sticky?: boolean;
  /**
   * Classes of the `<table>` element (`className` goes to the scroll container)
   */
  tableClassName?: string;
}

export const Table: FunctionComponent<TableProps> = ({
  caption,
  captionClassName,
  children,
  className,
  columnOverrides,
  scrollbar,
  sticky = false,
  tableClassName,
  ...attrs
}) => {
  const captionId = useId();
  const tableRef = useRef<HTMLTableElement>(null);
  const [stickyWidth, setStickyWidth] = useState<number>();

  // Cells snap next to the pinned column instead of under it
  useEffect(() => {
    const table = tableRef.current;
    if (!sticky || !table) {
      setStickyWidth(undefined);
      return;
    }

    const update = () => setStickyWidth(table.rows[0]?.cells[0]?.offsetWidth || undefined);
    update();

    if (typeof ResizeObserver === 'undefined') return;

    const observer = new ResizeObserver(update);
    observer.observe(table);

    return () => observer.disconnect();
  }, [sticky]);

  const labelledBy = attrs['aria-labelledby'] ?? (caption ? captionId : undefined);

  return (
    <Scrollbar
      aria-label={attrs['aria-label']}
      aria-labelledby={labelledBy}
      autoHide
      className={className}
      scrollbarClassName={clsx('mt-16', scrollbar?.scrollbarClassName)}
      scrollbarWidth={scrollbar?.scrollbarWidth}
      scrollerClassName="snap-x snap-mandatory overflow-y-clip overscroll-x-contain outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2 motion-safe:scroll-smooth"
      scrollerStyle={stickyWidth ? { scrollPaddingInlineStart: stickyWidth } : undefined}
      timelineName={scrollbar?.timelineName}
      variant={scrollbar?.variant}
    >
      <table
        ref={tableRef}
        className={clsx(
          'group/table text-b4 isolate -my-4 min-w-full border-separate border-spacing-x-0 border-spacing-y-4 text-center text-balance',
          tableClassName,
        )}
        data-sticky={sticky || undefined}
        {...attrs}
        aria-labelledby={labelledBy}
      >
        {caption && (
          <caption
            className={cn('text-b3 py-4 text-start font-semibold', captionClassName)}
            id={captionId}
          >
            {caption}
          </caption>
        )}
        <ColumnOverridesContext.Provider
          value={columnOverrides?.length ? columnOverrides : undefined}
        >
          {children}
        </ColumnOverridesContext.Provider>
      </table>
    </Scrollbar>
  );
};

export type THeadProps = ComponentPropsWithoutRef<'thead'>;

export const THead: FunctionComponent<THeadProps> = ({ children, style, ...attrs }) => (
  <thead style={{ ...TABLE_HEADER_STYLE, ...style }} {...attrs}>
    <SectionContext.Provider value="head">{children}</SectionContext.Provider>
  </thead>
);

export type TBodyProps = ComponentPropsWithoutRef<'tbody'>;

export const TBody: FunctionComponent<TBodyProps> = ({ children, style, ...attrs }) => (
  <tbody style={{ ...TABLE_BODY_STYLE, ...style }} {...attrs}>
    <SectionContext.Provider value="body">{children}</SectionContext.Provider>
  </tbody>
);

export type TFootProps = ComponentPropsWithoutRef<'tfoot'>;

export const TFoot: FunctionComponent<TFootProps> = ({ children, style, ...attrs }) => (
  <tfoot style={{ ...TABLE_HEADER_STYLE, ...style }} {...attrs}>
    <SectionContext.Provider value="foot">{children}</SectionContext.Provider>
  </tfoot>
);

function indexCells(children: ReactNode) {
  let index = 0;

  const walk = (nodes: ReactNode, prefix = ''): ReactNode[] =>
    Children.toArray(nodes).flatMap((child) => {
      if (!isValidElement<{ children?: ReactNode; colSpan?: number }>(child)) return child;
      if (child.type === Fragment) return walk(child.props.children, `${prefix}${child.key}`);

      const column = index;
      index += Math.max(1, Number(child.props.colSpan) || 1);

      return (
        <ColumnIndexContext.Provider key={`${prefix}${child.key}`} value={column}>
          {child}
        </ColumnIndexContext.Provider>
      );
    });

  return walk(children);
}

export type TRowProps = ComponentPropsWithoutRef<'tr'>;

export const TRow: FunctionComponent<TRowProps> = ({ children, ...attrs }) => {
  const overrides = useContext(ColumnOverridesContext);

  return <tr {...attrs}>{overrides ? indexCells(children) : children}</tr>;
};

const CELL_CLASSNAME = clsx(
  'snap-start px-20 py-12 font-normal',
  'min-w-140 md:min-w-fit',
  'border-lightGrey border-e last:border-e-0',
  'first:rounded-s-16 last:rounded-e-16',
  'bg-(--table-td-bg-color,var(--color-pearl))',
  'first:group-data-sticky/table:sticky',
  'first:group-data-sticky/table:start-0',
  'first:group-data-sticky/table:z-1',
  'first:group-data-sticky/table:before:absolute',
  'first:group-data-sticky/table:before:inset-0',
  'first:group-data-sticky/table:before:-z-1',
  'first:group-data-sticky/table:before:bg-(--table-bg-container,var(--color-white))',
  'first:group-data-sticky/table:after:absolute',
  'first:group-data-sticky/table:after:inset-0',
  'first:group-data-sticky/table:after:-z-1',
  'first:group-data-sticky/table:after:rounded-s-16',
  'first:group-data-sticky/table:after:bg-inherit',
);

export interface TCellProps
  extends Omit<ComponentPropsWithoutRef<'th'>, 'title'>,
    Omit<ComponentPropsWithoutRef<'td'>, 'title'> {
  /**
   * `th` for a header cell (bold, with a `scope` inferred from its section), `td` otherwise
   */
  as?: 'th' | 'td';
  /**
   * Column index used to pick the cell styling in the table `columnOverrides`. Computed from the cell
   * position in its row by default: set it when a `rowSpan` from a previous row shifts the cells
   */
  column?: number;
  /**
   * Icon displayed before the content of the cell
   */
  icon?: IconicNames;
  iconType?: IconicTypes;
  /**
   * Semibold line displayed above the content of the cell (`children`), which is then in regular
   * weight, header cells included. Replaces the native `title` attribute (tooltip) of the cell
   */
  title?: ReactNode;
}

export const TCell: FunctionComponent<TCellProps> = ({
  as: Tagname = 'td',
  children,
  className,
  column,
  icon,
  iconType,
  scope,
  style,
  title,
  ...attrs
}) => {
  const overrides = useContext(ColumnOverridesContext);
  const section = useContext(SectionContext);
  const index = useContext(ColumnIndexContext);
  const columnStyling = overrides?.[column ?? index ?? -1];
  const isHeader = Tagname === 'th';
  const hasTitle = title != null && title !== false;
  const content = hasTitle ? (
    <span className="flex min-w-0 flex-1 flex-col">
      <span className="font-semibold">{title}</span>
      {children != null && children !== false && <span className="font-normal">{children}</span>}
    </span>
  ) : icon ? (
    <span>{children}</span>
  ) : (
    children
  );

  return (
    <Tagname
      className={cn(
        CELL_CLASSNAME,
        isHeader && 'font-semibold',
        columnStyling?.className,
        className,
      )}
      scope={scope ?? (isHeader && section ? (section === 'body' ? 'row' : 'col') : undefined)}
      style={columnStyling?.style ? { ...columnStyling.style, ...style } : style}
      {...attrs}
    >
      {icon ? (
        <span className="flex items-center justify-center gap-8">
          <Icon className="shrink-0" iconType={iconType} name={icon} width="30px" />
          {content}
        </span>
      ) : (
        content
      )}
    </Tagname>
  );
};
```

## Usage

```tsx showLineNumbers
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.

<ComponentPreview align="start" name="table-sticky-demo" />

```tsx showLineNumbers
<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:

```tsx
<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.

<ComponentPreview align="start" name="table-icons-demo" />

```tsx showLineNumbers
<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.

<ComponentPreview align="start" name="table-titles-demo" />

```tsx showLineNumbers
<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.

<ComponentPreview align="start" name="table-columns-demo" />

```tsx showLineNumbers
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.

<ComponentPreview align="start" name="table-row-styles-demo" />

```tsx showLineNumbers
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`](/docs/components/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:

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`](/docs/components/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 <kbd>Tab</kbd> 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.
