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).
| Author | Status | ||
|---|---|---|---|
| main | Ada Lovelace | Live | 48 |
| pricing-copy | Grace Hopper | Preview | 121 |
| fix-nav | Alan Turing | Failed | 17 |
| docs | Ada Lovelace | Preview | 63 |
| Author | Status | ||
|---|---|---|---|
| main | Ada Lovelace | Live | 48 |
| pricing-copy | Grace Hopper | Preview | 121 |
| fix-nav | Alan Turing | Failed | 17 |
| docs | Ada Lovelace | Preview | 63 |
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.
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>
);
}/** 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;
}---
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 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 {};/** 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;
}<div class="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)] max-w-2xl" tabindex="0" role="region" aria-labelledby="ex-deploys-caption" data-table>
<table class="w-full caption-bottom border-collapse text-sm">
<caption id="ex-deploys-caption" class="border-t px-4 py-3 text-left text-sm text-muted-foreground">Deploys this week, Northwind site</caption>
<thead>
<tr>
<th scope="col" aria-sort="none" class="h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground">
<button type="button" data-sort class="-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">
Branch
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="opacity-50 [[aria-sort=ascending]_&]:rotate-180 [[aria-sort=ascending]_&]:opacity-100 [[aria-sort=descending]_&]:opacity-100 transition-transform"><path d="M4 6l4 4 4-4"></path></svg>
</button>
</th>
<th scope="col" class="h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground">Author</th>
<th scope="col" class="h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground">Status</th>
<th scope="col" aria-sort="none" class="h-10 border-b px-4 align-middle text-xs font-medium whitespace-nowrap text-muted-foreground text-right tabular-nums">
<button type="button" data-sort class="-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">
Build (s)
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="opacity-50 [[aria-sort=ascending]_&]:rotate-180 [[aria-sort=ascending]_&]:opacity-100 [[aria-sort=descending]_&]:opacity-100 transition-transform"><path d="M4 6l4 4 4-4"></path></svg>
</button>
</th>
</tr>
</thead>
<tbody>
<tr class="transition-colors last:[&>td]:border-b-0 hover:bg-muted/60">
<td data-value="main" class="border-b px-4 py-3 align-middle whitespace-nowrap">main</td>
<td data-value="Ada Lovelace" class="border-b px-4 py-3 align-middle whitespace-nowrap">Ada Lovelace</td>
<td data-value="Live" class="border-b px-4 py-3 align-middle whitespace-nowrap">Live</td>
<td data-value="48" class="border-b px-4 py-3 align-middle whitespace-nowrap text-right tabular-nums">48</td>
</tr>
<tr class="transition-colors last:[&>td]:border-b-0 hover:bg-muted/60">
<td data-value="pricing-copy" class="border-b px-4 py-3 align-middle whitespace-nowrap">pricing-copy</td>
<td data-value="Grace Hopper" class="border-b px-4 py-3 align-middle whitespace-nowrap">Grace Hopper</td>
<td data-value="Preview" class="border-b px-4 py-3 align-middle whitespace-nowrap">Preview</td>
<td data-value="121" class="border-b px-4 py-3 align-middle whitespace-nowrap text-right tabular-nums">121</td>
</tr>
<tr class="transition-colors last:[&>td]:border-b-0 hover:bg-muted/60">
<td data-value="fix-nav" class="border-b px-4 py-3 align-middle whitespace-nowrap">fix-nav</td>
<td data-value="Alan Turing" class="border-b px-4 py-3 align-middle whitespace-nowrap">Alan Turing</td>
<td data-value="Failed" class="border-b px-4 py-3 align-middle whitespace-nowrap">Failed</td>
<td data-value="17" class="border-b px-4 py-3 align-middle whitespace-nowrap text-right tabular-nums">17</td>
</tr>
<tr class="transition-colors last:[&>td]:border-b-0 hover:bg-muted/60">
<td data-value="docs" class="border-b px-4 py-3 align-middle whitespace-nowrap">docs</td>
<td data-value="Ada Lovelace" class="border-b px-4 py-3 align-middle whitespace-nowrap">Ada Lovelace</td>
<td data-value="Preview" class="border-b px-4 py-3 align-middle whitespace-nowrap">Preview</td>
<td data-value="63" class="border-b px-4 py-3 align-middle whitespace-nowrap text-right tabular-nums">63</td>
</tr>
</tbody>
</table>
</div><script type="module" src="/caseroom/r/html/table.js"></script>