# Table (Caseroom component)

A data table with a caption, right-aligned numbers and sortable columns (aria-sort). Scrolls in its own box. Black and white, dark mode, shadcn-compatible; React, Astro and plain HTML.

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

## 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`
- HTML: https://www.themes.dev/caseroom/r/html/table.html (with https://www.themes.dev/caseroom/r/html/caseroom.css)

Page: https://www.themes.dev/caseroom/components/table/ · Rules for agents: https://www.themes.dev/caseroom/AGENT.md

## Code

### table.astro

```astro
---
import { cn } from '@/lib/utils';
import { SORT_PATH, table, type Column, type Row } from './table.variants';

interface Props {
  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;
  class?: string;
}

const { columns, rows, caption, id, class: className } = Astro.props;
---

<div class={cn(table.wrap, className)} tabindex="0" role="region" aria-labelledby={`${id}-caption`} data-table>
  <table class={table.table}>
    <caption id={`${id}-caption`} class={table.caption}>{caption}</caption>
    <thead>
      <tr>
        {
          columns.map((c) => (
            <th scope="col" aria-sort={c.sortable ? 'none' : undefined} class={cn(table.head, c.align === 'end' && table.end)}>
              {c.sortable ? (
                <button type="button" data-sort class={table.sort}>
                  {c.label}
                  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class={table.sortIcon}><path d={SORT_PATH} /></svg>
                </button>
              ) : (
                c.label
              )}
            </th>
          ))
        }
      </tr>
    </thead>
    <tbody>
      {
        rows.map((r) => (
          <tr class={table.row}>
            {columns.map((c) => (
              <td data-value={r[c.key]} class={cn(table.cell, c.align === 'end' && table.end)}>{r[c.key]}</td>
            ))}
          </tr>
        ))
      }
    </tbody>
  </table>
</div>

<script>
  import './table.client';
</script>
```

### table.client.ts

```tsx
// Table sorting for Astro and plain HTML (the React version sorts in state). A header button in a
// [data-table] table sorts its column: ascending first, then descending; aria-sort marks the column, the other
// columns lose theirs. Cells carry data-value (the raw value) so numbers sort as numbers.
declare global {
  interface Window {
    __caseroomTable?: boolean;
  }
}

if (typeof window !== 'undefined' && !window.__caseroomTable) {
  window.__caseroomTable = true;
  document.addEventListener('click', (e) => {
    const button = (e.target as Element).closest<HTMLButtonElement>('[data-table] th button[data-sort]');
    // A click a framework already handled (the React table sorts in state) is left alone.
    if (!button || e.defaultPrevented) return;
    const th = button.closest('th')!;
    const table = th.closest('table')!;
    const index = [...th.parentElement!.children].indexOf(th);
    const dir = th.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
    for (const other of table.querySelectorAll('thead th[aria-sort]')) if (other !== th) other.setAttribute('aria-sort', 'none');
    th.setAttribute('aria-sort', dir);
    const body = table.tBodies[0];
    const value = (tr: HTMLTableRowElement) => {
      const raw = tr.cells[index]?.dataset.value ?? tr.cells[index]?.textContent ?? '';
      return raw;
    };
    const rows = [...body.rows].sort((a, b) => {
      const x = value(a);
      const y = value(b);
      const nx = Number(x);
      const ny = Number(y);
      const cmp = x !== '' && y !== '' && !Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x.localeCompare(y, undefined, { numeric: true });
      return dir === 'ascending' ? cmp : -cmp;
    });
    body.append(...rows);
  });
}

export {};
```

### table.tsx

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

```tsx
/** 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;
}
```
