Table

Dumb styled primitives for building data tables. Wire any feature — sorting, selection, pinning, expanding — with @tanstack/react-table or any other headless library.

Basic

PropertyCityADR
Grand HotelBerlin180
Hotel RiversidePrague120
The Lighthouse InnAmsterdam215
City SuitesVienna145
Palace CourtParis320
Show code
Hide code
import { Table } from '@mylighthouse/prism-react';
import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from '@tanstack/react-table';

type Property = { adr: number; city: string; name: string };

const columns: ColumnDef<Property>[] = [
  { accessorKey: 'name', header: 'Property' },
  { accessorKey: 'city', header: 'City' },
  { accessorKey: 'adr', header: 'ADR' },
];

const data: Property[] = [
  { name: 'Grand Hotel', city: 'Berlin', adr: 180 },
  { name: 'Hotel Riverside', city: 'Prague', adr: 120 },
  { name: 'The Lighthouse Inn', city: 'Amsterdam', adr: 215 },
  { name: 'City Suites', city: 'Vienna', adr: 145 },
  { name: 'Palace Court', city: 'Paris', adr: 320 },
];

export default function TableBasicExample(): React.JSX.Element {
  // eslint-disable-next-line react-hooks/incompatible-library
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <Table aria-label="Hotels ADR">
      <Table.Header>
        {table.getHeaderGroups().map((group) => (
          <Table.Row key={group.id}>
            {group.headers.map((header) => (
              <Table.Header.Cell colSpan={header.colSpan} key={header.id}>
                {header.isPlaceholder
                  ? null
                  : flexRender(
                      header.column.columnDef.header,
                      header.getContext(),
                    )}
              </Table.Header.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Header>

      <Table.Body>
        {table.getRowModel().rows.map((row) => (
          <Table.Row key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <Table.Cell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </Table.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Fixed column

The Property column is pinned left. Scroll horizontally to see it stay in place. Consumer owns a local getCommonPinningStyles helper and spreads it into style on header cells and data cells. <Table> sets border-collapse: separate; border-spacing: 0 on the inner <table> — required for sticky to work.

PropertyCityADROccupancy
Grand HotelBerlin18078%
Hotel RiversidePrague12065%
The Lighthouse InnAmsterdam21582%
City SuitesVienna14571%
Palace CourtParis32091%
Show code
Hide code
import { Table } from '@mylighthouse/prism-react';
import {
  type Column,
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from '@tanstack/react-table';
import type { CSSProperties } from 'react';

type Property = { adr: number; city: string; name: string; occupancy: string };

const getCommonPinningStyles = (column: Column<Property>): CSSProperties => {
  const isPinned = column.getIsPinned();
  const isLastLeftPinnedColumn =
    isPinned === 'left' && column.getIsLastColumn('left');
  const isFirstRightPinnedColumn =
    isPinned === 'right' && column.getIsFirstColumn('right');

  return {
    borderRight: isLastLeftPinnedColumn
      ? '1px solid var(--prism-color-border-neutral-default)'
      : undefined,
    borderLeft: isFirstRightPinnedColumn
      ? '1px solid var(--prism-color-border-neutral-default)'
      : undefined,
    color: isPinned ? 'var(--prism-color-text-neutral-emphasis)' : undefined,
    fontWeight: isPinned
      ? 'var(--prism-font-weight-text-300-semi-bold)'
      : undefined,
    left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
    right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
    opacity: isPinned ? 0.95 : 1,
    position: isPinned ? 'sticky' : 'relative',
    width: column.getSize(),
    zIndex: isPinned ? 1 : 0,
    backgroundColor: 'var(--prism-color-elevation-surface)',
  };
};

const columns: ColumnDef<Property>[] = [
  { accessorKey: 'name', header: 'Property', size: 200 },
  { accessorKey: 'city', header: 'City', size: 200 },
  { accessorKey: 'adr', header: 'ADR', size: 200 },
  { accessorKey: 'occupancy', header: 'Occupancy', size: 200 },
];

const data: Property[] = [
  { name: 'Grand Hotel', city: 'Berlin', adr: 180, occupancy: '78%' },
  { name: 'Hotel Riverside', city: 'Prague', adr: 120, occupancy: '65%' },
  { name: 'The Lighthouse Inn', city: 'Amsterdam', adr: 215, occupancy: '82%' },
  { name: 'City Suites', city: 'Vienna', adr: 145, occupancy: '71%' },
  { name: 'Palace Court', city: 'Paris', adr: 320, occupancy: '91%' },
];

export default function TableFixedColumnExample(): React.JSX.Element {
  // eslint-disable-next-line react-hooks/incompatible-library
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    initialState: { columnPinning: { left: ['name'] } },
  });

  return (
    <div style={{ width: 450, overflowX: 'auto' }}>
      <Table
        aria-label="Occupancy and ADR by property"
        style={{ tableLayout: 'fixed' }}
      >
        <Table.Header>
          {table.getHeaderGroups().map((group) => (
            <Table.Row key={group.id}>
              {group.headers.map((header) => (
                <Table.Header.Cell
                  colSpan={header.colSpan}
                  key={header.id}
                  style={getCommonPinningStyles(header.column)}
                >
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext(),
                      )}
                </Table.Header.Cell>
              ))}
            </Table.Row>
          ))}
        </Table.Header>

        <Table.Body>
          {table.getRowModel().rows.map((row) => (
            <Table.Row key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <Table.Cell
                  key={cell.id}
                  style={getCommonPinningStyles(cell.column)}
                >
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </Table.Cell>
              ))}
            </Table.Row>
          ))}
        </Table.Body>
      </Table>
    </div>
  );
}

Skeleton

Render <Table.SkeletonBar> cells while data loads. Consumer controls row count, column shape, and widthPct per cell.

Column AColumn BColumn C
Show code
Hide code
import { Table } from '@mylighthouse/prism-react';

export default function TableSkeletonExample(): React.JSX.Element {
  return (
    <Table>
      <Table.Header>
        <Table.Row>
          <Table.Header.Cell>Column A</Table.Header.Cell>
          <Table.Header.Cell>Column B</Table.Header.Cell>
          <Table.Header.Cell>Column C</Table.Header.Cell>
        </Table.Row>
      </Table.Header>

      <Table.Body>
        <Table.Row>
          <Table.Cell colSpan={3}>
            <Table.SkeletonBar width="55%" />
          </Table.Cell>
        </Table.Row>
        <Table.Row>
          <Table.Cell colSpan={3}>
            <Table.SkeletonBar width="35%" />
          </Table.Cell>
        </Table.Row>
        <Table.Row>
          <Table.Cell colSpan={3} />
        </Table.Row>
        <Table.Row>
          <Table.Cell colSpan={3} />
        </Table.Row>
      </Table.Body>
    </Table>
  );
}

Collapsible rows

Single or multi-level. row.depth drives the left-padding indent; TanStack recurses getSubRows automatically.

PropertyADR
Northern Europe
168
Western Europe
244
Show code
Hide code
import { Button, Table } from '@mylighthouse/prism-react';
import {
  type ColumnDef,
  type ExpandedState,
  flexRender,
  getCoreRowModel,
  getExpandedRowModel,
  useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';

type Row = { adr: number; children?: Row[]; name: string };

const columns: ColumnDef<Row>[] = [
  {
    accessorKey: 'name',
    header: 'Property',
    cell: ({ row, getValue }) => (
      <div
        style={{
          display: 'inline-flex',
          alignItems: 'center',
          gap: 'var(--prism-spacing-200)',
          paddingLeft: `calc(${row.depth} * var(--prism-spacing-400))`,
        }}
      >
        {row.getCanExpand() ? (
          <Button
            aria-controls={row.subRows.map((subRow) => subRow.id).join(' ')}
            aria-expanded={row.getIsExpanded()}
            aria-label={
              row.getIsExpanded()
                ? `Collapse ${row.original.name}`
                : `Expand ${row.original.name}`
            }
            buttonType="ghost"
            icon={row.getIsExpanded() ? 'chevron-down' : 'chevron-right'}
            onClick={row.getToggleExpandedHandler()}
            size="small"
          />
        ) : (
          <span style={{ width: 20 }} />
        )}
        {getValue<string>()}
      </div>
    ),
  },
  { accessorKey: 'adr', header: 'ADR' },
];

const data: Row[] = [
  {
    name: 'Northern Europe',
    adr: 168,
    children: [
      { name: 'Grand Hotel Berlin', adr: 180 },
      {
        name: 'Hotel Riverside Prague',
        adr: 120,
        children: [
          { name: 'Superior rooms', adr: 135 },
          { name: 'Standard rooms', adr: 105 },
        ],
      },
    ],
  },
  {
    name: 'Western Europe',
    adr: 244,
    children: [
      { name: 'The Lighthouse Inn Amsterdam', adr: 215 },
      { name: 'Palace Court Paris', adr: 320 },
    ],
  },
];

export default function TableCollapsibleExample(): React.JSX.Element {
  const [expanded, setExpanded] = useState<ExpandedState>({});

  // eslint-disable-next-line react-hooks/incompatible-library
  const table = useReactTable({
    data,
    columns,
    state: { expanded },
    onExpandedChange: setExpanded,
    getSubRows: (row) => row.children,
    getCoreRowModel: getCoreRowModel(),
    getExpandedRowModel: getExpandedRowModel(),
  });

  return (
    <Table aria-label="ADR by region and property">
      <Table.Header>
        {table.getHeaderGroups().map((group) => (
          <Table.Row key={group.id}>
            {group.headers.map((header) => (
              <Table.Header.Cell colSpan={header.colSpan} key={header.id}>
                {header.isPlaceholder
                  ? null
                  : flexRender(
                      header.column.columnDef.header,
                      header.getContext(),
                    )}
              </Table.Header.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Header>

      <Table.Body>
        {table.getRowModel().rows.map((row) => (
          <Table.Row id={row.id} key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <Table.Cell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </Table.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Row selection

<Table.Row isSelected> applies the selection background. The select-all checkbox in the header uses indeterminate when only some rows are selected.

PropertyCityADR
Grand HotelBerlin180
Hotel RiversidePrague120
The Lighthouse InnAmsterdam215
City SuitesVienna145
Palace CourtParis320
Show code
Hide code
import { Checkbox, Table } from '@mylighthouse/prism-react';
import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  type RowSelectionState,
  useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';

type Property = { adr: number; city: string; id: string; name: string };

const data: Property[] = [
  { id: 'p1', name: 'Grand Hotel', city: 'Berlin', adr: 180 },
  { id: 'p2', name: 'Hotel Riverside', city: 'Prague', adr: 120 },
  { id: 'p3', name: 'The Lighthouse Inn', city: 'Amsterdam', adr: 215 },
  { id: 'p4', name: 'City Suites', city: 'Vienna', adr: 145 },
  { id: 'p5', name: 'Palace Court', city: 'Paris', adr: 320 },
];

const columns: ColumnDef<Property>[] = [
  {
    id: 'select',
    size: 40,
    header: ({ table }) => (
      <Checkbox
        aria-label="Select all"
        checked={table.getIsAllRowsSelected()}
        indeterminate={table.getIsSomeRowsSelected()}
        onChange={table.getToggleAllRowsSelectedHandler()}
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        aria-label={`Select ${row.original.name}`}
        checked={row.getIsSelected()}
        disabled={!row.getCanSelect()}
        onChange={row.getToggleSelectedHandler()}
      />
    ),
  },
  { accessorKey: 'name', header: 'Property' },
  { accessorKey: 'city', header: 'City' },
  { accessorKey: 'adr', header: 'ADR' },
];

export default function TableSelectionExample(): React.JSX.Element {
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});

  // eslint-disable-next-line react-hooks/incompatible-library
  const table = useReactTable({
    data,
    columns,
    state: { rowSelection },
    onRowSelectionChange: setRowSelection,
    enableRowSelection: true,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <Table aria-label="ADR by property selection">
      <Table.Header>
        {table.getHeaderGroups().map((group) => (
          <Table.Row key={group.id}>
            {group.headers.map((header) => (
              <Table.Header.Cell
                colSpan={header.colSpan}
                key={header.id}
                {...(header.id === 'select'
                  ? { 'aria-label': 'Select all' }
                  : {})}
              >
                {header.isPlaceholder
                  ? null
                  : flexRender(
                      header.column.columnDef.header,
                      header.getContext(),
                    )}
              </Table.Header.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Header>

      <Table.Body>
        {table.getRowModel().rows.map((row) => (
          <Table.Row isSelected={row.getIsSelected()} key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <Table.Cell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </Table.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Sorting

Consumer renders a sort button inside each header cell. getSortedRowModel from TanStack handles the sort logic.

ADR
Grand HotelBerlin180
Hotel RiversidePrague120
The Lighthouse InnAmsterdam215
City SuitesVienna145
Palace CourtParis320
Show code
Hide code
import type { IconName } from '@mylighthouse/prism-foundation';
import { Button, Table } from '@mylighthouse/prism-react';
import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  type Header,
  type SortingState,
  useReactTable,
} from '@tanstack/react-table';
import { useState } from 'react';

type Property = { adr: number; city: string; name: string };

function sortIconName(state: false | 'asc' | 'desc'): IconName {
  if (state === 'asc') return 'sort-asc';
  if (state === 'desc') return 'sort-desc';
  return 'sort';
}

function SortableHeader({
  header,
}: {
  header: Header<Property, unknown>;
}): React.JSX.Element {
  if (!header.column.getCanSort()) {
    return (
      <>{flexRender(header.column.columnDef.header, header.getContext())}</>
    );
  }
  const sort = header.column.getIsSorted();
  return (
    <Button
      aria-label={
        sort === 'asc'
          ? 'Sort descending'
          : sort === 'desc'
            ? 'Clear sort'
            : 'Sort ascending'
      }
      buttonType="ghost"
      iconAfter={sortIconName(sort)}
      onClick={header.column.getToggleSortingHandler()}
      size="small"
    >
      {flexRender(header.column.columnDef.header, header.getContext())}
    </Button>
  );
}

const columns: ColumnDef<Property>[] = [
  { accessorKey: 'name', header: 'Property' },
  { accessorKey: 'city', header: 'City' },
  { accessorKey: 'adr', header: 'ADR', enableSorting: false },
];

const data: Property[] = [
  { name: 'Grand Hotel', city: 'Berlin', adr: 180 },
  { name: 'Hotel Riverside', city: 'Prague', adr: 120 },
  { name: 'The Lighthouse Inn', city: 'Amsterdam', adr: 215 },
  { name: 'City Suites', city: 'Vienna', adr: 145 },
  { name: 'Palace Court', city: 'Paris', adr: 320 },
];

export default function TableSortingExample(): React.JSX.Element {
  const [sorting, setSorting] = useState<SortingState>([]);

  // eslint-disable-next-line react-hooks/incompatible-library
  const table = useReactTable({
    data,
    columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  return (
    <Table aria-label="ADR by property">
      <Table.Header>
        {table.getHeaderGroups().map((group) => (
          <Table.Row key={group.id}>
            {group.headers.map((header) => (
              <Table.Header.Cell
                aria-sort={
                  !header.column.getCanSort()
                    ? undefined
                    : header.column.getIsSorted() === 'asc'
                      ? 'ascending'
                      : header.column.getIsSorted() === 'desc'
                        ? 'descending'
                        : 'none'
                }
                colSpan={header.colSpan}
                key={header.id}
              >
                <SortableHeader header={header} />
              </Table.Header.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Header>

      <Table.Body>
        {table.getRowModel().rows.map((row) => (
          <Table.Row key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <Table.Cell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </Table.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Header groups

Nest columns in the column def to create grouped headers. The standard header loop renders one <tr> per group, honouring header.colSpan and header.isPlaceholder.

Rate metricsVolume metrics
PropertyADRRevPARRevenueOccupancy
Grand Hotel1801404800078%
Hotel Riverside120783100065%
The Lighthouse Inn2151766400082%
City Suites1451033700071%
Show code
Hide code
import { Table } from '@mylighthouse/prism-react';
import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from '@tanstack/react-table';

type Property = {
  adr: number;
  name: string;
  occupancy: string;
  revenue: number;
  revpar: number;
};

const columns: ColumnDef<Property>[] = [
  { accessorKey: 'name', header: 'Property' },
  {
    header: 'Rate metrics',
    columns: [
      { accessorKey: 'adr', header: 'ADR' },
      { accessorKey: 'revpar', header: 'RevPAR' },
    ],
  },
  {
    header: 'Volume metrics',
    columns: [
      { accessorKey: 'revenue', header: 'Revenue' },
      { accessorKey: 'occupancy', header: 'Occupancy' },
    ],
  },
];

const data: Property[] = [
  {
    name: 'Grand Hotel',
    adr: 180,
    revpar: 140,
    revenue: 48000,
    occupancy: '78%',
  },
  {
    name: 'Hotel Riverside',
    adr: 120,
    revpar: 78,
    revenue: 31000,
    occupancy: '65%',
  },
  {
    name: 'The Lighthouse Inn',
    adr: 215,
    revpar: 176,
    revenue: 64000,
    occupancy: '82%',
  },
  {
    name: 'City Suites',
    adr: 145,
    revpar: 103,
    revenue: 37000,
    occupancy: '71%',
  },
];

export default function TableHeaderGroupsExample(): React.JSX.Element {
  // eslint-disable-next-line react-hooks/incompatible-library
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <Table aria-label="Rate and volume metrics by property">
      <Table.Header>
        {table.getHeaderGroups().map((group) => (
          <Table.Row key={group.id}>
            {group.headers.map((header) => (
              <Table.Header.Cell
                colSpan={header.colSpan}
                key={header.id}
                scope={header.colSpan > 1 ? 'colgroup' : 'col'}
              >
                {header.isPlaceholder
                  ? null
                  : flexRender(
                      header.column.columnDef.header,
                      header.getContext(),
                    )}
              </Table.Header.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Header>

      <Table.Body>
        {table.getRowModel().rows.map((row) => (
          <Table.Row key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <Table.Cell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </Table.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Notes

Tanstack Table library does not work with the react’s compiler memoization. For this reason, when implemented, the "use no memo" directive should be added to the first line of a component or a hook, and the // eslint-disable-next-line react-hooks/incompatible-library comment should be added before calling the useReactTable hook.

API Reference

Table

Name Default Type Description
bordered -- boolean --

Table.Header

No component-specific props

Table.Header.Cell

No component-specific props

Table.Body

No component-specific props

Table.Foot

No component-specific props

Table.Row

Name Default Type Description
isActive -- boolean --
isSelected -- boolean --

Table.Cell

No component-specific props

Table.SkeletonBar

No component-specific props