Skip to content
Caseroom

Components · Data

Table

A data table with a caption, right-aligned numbers and sortable columns (aria-sort). Scrolls in its own box.

5.4% of the 2,062 sites in The UI Index have a table (112 sites).

Deploys this week, Northwind site
AuthorStatus
mainAda LovelaceLive48
pricing-copyGrace HopperPreview121
fix-navAlan TuringFailed17
docsAda LovelacePreview63

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/table.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/table.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

table.tsx
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { SORT_PATH, sortRows, table, type Column, type Row } from './table.variants';

export type { Column, Row };

export interface TableProps {
  columns: Column[];
  rows: Row[];
  /** What the table shows. Also names the scroll box for screen readers. */
  caption: string;
  /** Unique on the page; ids the caption. */
  id: string;
  className?: string;
}

/** A data table with optional sortable columns (ascending, then descending; aria-sort on the column). */
export function Table({ columns, rows, caption, id, className }: TableProps) {
  const [sort, setSort] = useState<{ key: string; dir: 'ascending' | 'descending' } | null>(null);
  const shown = sort ? sortRows(rows, sort.key, sort.dir) : rows;
  return (
    <div className={cn(table.wrap, className)} tabIndex={0} role="region" aria-labelledby={`${id}-caption`} data-table="">
      <table className={table.table}>
        <caption id={`${id}-caption`} className={table.caption}>{caption}</caption>
        <thead>
          <tr>
            {columns.map((c) => (
              <th key={c.key} scope="col" aria-sort={c.sortable ? (sort?.key === c.key ? sort.dir : 'none') : undefined} className={cn(table.head, c.align === 'end' && table.end)}>
                {c.sortable ? (
                  <button type="button" data-sort="" className={table.sort} onClick={(e) => {
                    // Handled here: the delegated script for Astro and HTML tables leaves this click alone.
                    e.preventDefault();
                    setSort({ key: c.key, dir: sort?.key === c.key && sort.dir === 'ascending' ? 'descending' : 'ascending' });
                  }}>
                    {c.label}
                    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={table.sortIcon}><path d={SORT_PATH} /></svg>
                  </button>
                ) : (
                  c.label
                )}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {shown.map((r, k) => (
            <tr key={k} className={table.row}>
              {columns.map((c) => (
                <td key={c.key} data-value={r[c.key]} className={cn(table.cell, c.align === 'end' && table.end)}>{r[c.key]}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
table.variants.ts
/** A data table: a real <table> with a caption, header cells with scope, numbers right-aligned in tabular figures.
 *  Lines are for data, so rows keep their hairlines. It scrolls sideways in its own box (focusable, named by the
 *  caption, shaded at the edge that hides columns). Sortable columns have a button in the header and aria-sort. */
export const table = {
  wrap: 'relative w-full overflow-x-auto rounded-lg border [background:linear-gradient(to_right,var(--background)_50%,transparent)_left/2rem_100%_no-repeat_local,linear-gradient(to_left,var(--background)_50%,transparent)_right/2rem_100%_no-repeat_local,radial-gradient(farthest-side_at_0_50%,var(--tabs-shade,rgb(0_0_0/0.2)),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,var(--tabs-shade,rgb(0_0_0/0.2)),transparent)_right/0.75rem_100%_no-repeat_scroll,var(--background)]',
  table: 'w-full caption-bottom border-collapse text-sm',
  caption: 'border-t px-4 py-3 text-left text-sm text-muted-foreground',
  head: 'h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground',
  cell: 'border-b px-4 py-3 align-middle whitespace-nowrap',
  row: 'transition-colors last:[&>td]:border-b-0 hover:bg-muted/60',
  end: 'text-right tabular-nums',
  sort: '-mx-2 inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md px-2 font-medium transition-colors select-none hover:bg-accent hover:text-foreground active:bg-pressed [&_svg]:size-3.5 [&_svg]:shrink-0',
  sortIcon: 'opacity-50 [[aria-sort=ascending]_&]:rotate-180 [[aria-sort=ascending]_&]:opacity-100 [[aria-sort=descending]_&]:opacity-100 transition-transform',
};

export type Column = { key: string; label: string; align?: 'start' | 'end'; sortable?: boolean };
export type Row = Record<string, string | number>;
export const SORT_PATH = 'M4 6l4 4 4-4';

/** Rows sorted by a column: numbers as numbers, text with the locale's collation. */
export function sortRows(rows: Row[], key: string, dir: 'ascending' | 'descending'): Row[] {
  const sorted = [...rows].sort((a, b) => {
    const x = a[key];
    const y = b[key];
    return typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y), undefined, { numeric: true });
  });
  return dir === 'descending' ? sorted.reverse() : sorted;
}