Blocks · Data
Data table
A simple table: caption, header held while the rows scroll, sideways scroll in its own box on a narrow screen; person and link columns.
5.4% of the 2,073 sites in The UI Index have a table (112 sites).
Releases of Northwind, newest first
| Version | Released | Author | Changes | Highlights |
|---|---|---|---|---|
| 2.4.0 | 4 Nov 2026 | Ada Lovelace | 18 | Dark mode for the settings pages |
| 2.3.2 | 21 Oct 2026 | Grace Hopper | 4 | Fixes the export for large accounts |
| 2.3.1 | 14 Oct 2026 | Alan Turing | 2 | Keyboard focus in the invoice list |
| 2.3.0 | 30 Sep 2026 | Ada Lovelace | 23 | Receipts by photo, bank feed filters |
| 2.2.0 | 2 Sep 2026 | Katherine Johnson | 15 | Recurring invoices |
| 2.1.4 | 19 Aug 2026 | Grace Hopper | 3 | Faster search |
Releases of Northwind, newest first
| Version | Released | Author | Changes | Highlights |
|---|---|---|---|---|
| 2.4.0 | 4 Nov 2026 | Ada Lovelace | 18 | Dark mode for the settings pages |
| 2.3.2 | 21 Oct 2026 | Grace Hopper | 4 | Fixes the export for large accounts |
| 2.3.1 | 14 Oct 2026 | Alan Turing | 2 | Keyboard focus in the invoice list |
| 2.3.0 | 30 Sep 2026 | Ada Lovelace | 23 | Receipts by photo, bank feed filters |
| 2.2.0 | 2 Sep 2026 | Katherine Johnson | 15 | Recurring invoices |
| 2.1.4 | 19 Aug 2026 | Grace Hopper | 3 | Faster search |
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/ ui/ r/ data-table.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ ui/ r/ astro/ data-table.json
Or copy the files below into your project. They need clsx, tailwind-merge and the components they import, and the themes.dev UI theme. The HTML needs only the compiled CSS.
/** A simple data table: a caption above, a real <table> with scoped header cells in a scroll box of its own
* (focusable, named by the caption) that scrolls sideways on a narrow screen and keeps the header in view when the
* rows are many. Rows keep their hairlines: lines are for data. A person column draws a monogram and the name. */
export type DataTableKind = 'text' | 'person' | 'link';
export type DataTableColumn = {
/** The key of the cell in each row. */
key: string;
/** The header text. At most 24 characters. */
label: string;
kind?: DataTableKind;
/** Right-aligns the column in tabular figures. */
align?: 'start' | 'end';
};
export type DataTableRow = {
cells: Record<string, string | number>;
/** Where a link column goes. */
href?: string;
};
export type DataTableContent = {
/** Names the table and the scroll box. At most 80 characters. */
caption: string;
columns: DataTableColumn[];
rows: DataTableRow[];
/** Unique on the page; ids the caption. */
id: string;
};
/** More rows than this and the box stops growing and scrolls, header held. */
export const DATA_TABLE_MAX_ROWS = 8;
export const dataTable = {
root: '@container flex w-full min-w-0 flex-col gap-4 bg-background px-6 py-14 text-foreground',
caption: 'text-base font-semibold tracking-[-0.01em]',
wrap: (rowCount: number) =>
`relative w-full overflow-auto rounded-lg border bg-background [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%,color-mix(in_oklab,var(--foreground)_18%,transparent),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,color-mix(in_oklab,var(--foreground)_18%,transparent),transparent)_right/0.75rem_100%_no-repeat_scroll,var(--background)] ${rowCount > DATA_TABLE_MAX_ROWS ? 'max-h-[28rem]' : ''}`,
table: 'w-full min-w-max border-separate border-spacing-0 text-sm',
head: (rowCount: number) =>
`h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] max-sm:px-3 ${rowCount > DATA_TABLE_MAX_ROWS ? 'sticky top-0 z-10 bg-background' : ''}`,
cell: 'border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3',
row: 'transition-colors hover:bg-muted/60 motion-reduce:transition-none [&:last-child>td]:border-b-0',
end: 'text-right tabular-nums',
first: 'font-medium',
person: 'inline-flex items-center gap-2',
link: 'font-medium',
};import { Avatar } from '@/components/ui/avatar';
import { Link } from '@/components/ui/link';
import { cn } from '@/lib/utils';
import { dataTable as c, type DataTableContent } from './data-table.classes';
export type DataTableProps = DataTableContent & { className?: string };
/** A simple data table: caption, held header, sideways scroll in its own box. */
export function DataTable({ caption, columns, rows, id, className }: DataTableProps) {
return (
<div className={cn(c.root, className)}>
<p id={`${id}-caption`} className={c.caption}>{caption}</p>
<div className={c.wrap(rows.length)} tabIndex={0} role="region" aria-labelledby={`${id}-caption`}>
<table className={c.table}>
<caption className="sr-only">{caption}</caption>
<thead>
<tr>
{columns.map((col) => <th key={col.key} scope="col" className={cn(c.head(rows.length), col.align === 'end' && 'text-right')}>{col.label}</th>)}
</tr>
</thead>
<tbody>
{rows.map((r, ri) => (
<tr key={ri} className={c.row}>
{columns.map((col, i) => {
const val = r.cells[col.key];
const kind = col.kind ?? 'text';
return (
<td key={col.key} className={cn(c.cell, col.align === 'end' && c.end)}>
{kind === 'person' ? (
<span className={c.person}>
<span aria-hidden="true"><Avatar name={String(val)} size="sm" /></span>
{val}
</span>
) : kind === 'link' && r.href ? (
<Link href={r.href} className={c.link}>{val}</Link>
) : i === 0 && !col.align ? (
<span className={c.first}>{val}</span>
) : (
val
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}---
import Avatar from '@/components/ui/avatar.astro';
import Link from '@/components/ui/link.astro';
import { cn } from '@/lib/utils';
import { dataTable as c, type DataTableContent } from './data-table.classes';
type Props = DataTableContent & { class?: string };
const { caption, columns, rows, id, class: className } = Astro.props;
---
<div class={cn(c.root, className)}>
<p id={`${id}-caption`} class={c.caption}>{caption}</p>
<div class={c.wrap(rows.length)} tabindex="0" role="region" aria-labelledby={`${id}-caption`}>
<table class={c.table}>
<caption class="sr-only">{caption}</caption>
<thead>
<tr>
{columns.map((col) => <th scope="col" class={cn(c.head(rows.length), col.align === 'end' && 'text-right')}>{col.label}</th>)}
</tr>
</thead>
<tbody>
{
rows.map((r) => (
<tr class={c.row}>
{columns.map((col, i) => {
const val = r.cells[col.key];
const kind = col.kind ?? 'text';
return (
<td class={cn(c.cell, col.align === 'end' && c.end)}>
{kind === 'person' ? (
<span class={c.person}>
<span aria-hidden="true"><Avatar name={String(val)} size="sm" /></span>
{val}
</span>
) : kind === 'link' && r.href ? (
<Link href={r.href} class={c.link}>{val}</Link>
) : i === 0 && !col.align ? (
<span class={c.first}>{val}</span>
) : (
val
)}
</td>
);
})}
</tr>
))
}
</tbody>
</table>
</div>
</div>/** A simple data table: a caption above, a real <table> with scoped header cells in a scroll box of its own
* (focusable, named by the caption) that scrolls sideways on a narrow screen and keeps the header in view when the
* rows are many. Rows keep their hairlines: lines are for data. A person column draws a monogram and the name. */
export type DataTableKind = 'text' | 'person' | 'link';
export type DataTableColumn = {
/** The key of the cell in each row. */
key: string;
/** The header text. At most 24 characters. */
label: string;
kind?: DataTableKind;
/** Right-aligns the column in tabular figures. */
align?: 'start' | 'end';
};
export type DataTableRow = {
cells: Record<string, string | number>;
/** Where a link column goes. */
href?: string;
};
export type DataTableContent = {
/** Names the table and the scroll box. At most 80 characters. */
caption: string;
columns: DataTableColumn[];
rows: DataTableRow[];
/** Unique on the page; ids the caption. */
id: string;
};
/** More rows than this and the box stops growing and scrolls, header held. */
export const DATA_TABLE_MAX_ROWS = 8;
export const dataTable = {
root: '@container flex w-full min-w-0 flex-col gap-4 bg-background px-6 py-14 text-foreground',
caption: 'text-base font-semibold tracking-[-0.01em]',
wrap: (rowCount: number) =>
`relative w-full overflow-auto rounded-lg border bg-background [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%,color-mix(in_oklab,var(--foreground)_18%,transparent),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,color-mix(in_oklab,var(--foreground)_18%,transparent),transparent)_right/0.75rem_100%_no-repeat_scroll,var(--background)] ${rowCount > DATA_TABLE_MAX_ROWS ? 'max-h-[28rem]' : ''}`,
table: 'w-full min-w-max border-separate border-spacing-0 text-sm',
head: (rowCount: number) =>
`h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] max-sm:px-3 ${rowCount > DATA_TABLE_MAX_ROWS ? 'sticky top-0 z-10 bg-background' : ''}`,
cell: 'border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3',
row: 'transition-colors hover:bg-muted/60 motion-reduce:transition-none [&:last-child>td]:border-b-0',
end: 'text-right tabular-nums',
first: 'font-medium',
person: 'inline-flex items-center gap-2',
link: 'font-medium',
};<div class="@container flex w-full min-w-0 flex-col gap-4 bg-background px-6 py-14 text-foreground">
<p id="ex-releases-caption" class="text-base font-semibold tracking-[-0.01em]">Releases of Northwind, newest first</p>
<div class="relative w-full overflow-auto rounded-lg border bg-background [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%,color-mix(in_oklab,var(--foreground)_18%,transparent),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,color-mix(in_oklab,var(--foreground)_18%,transparent),transparent)_right/0.75rem_100%_no-repeat_scroll,var(--background)] " tabindex="0" role="region" aria-labelledby="ex-releases-caption">
<table class="w-full min-w-max border-separate border-spacing-0 text-sm">
<caption class="sr-only">Releases of Northwind, newest first</caption>
<thead>
<tr>
<th scope="col" class="h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] max-sm:px-3">
Version
</th>
<th scope="col" class="h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] max-sm:px-3">
Released
</th>
<th scope="col" class="h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] max-sm:px-3">
Author
</th>
<th scope="col" class="h-10 border-b px-4 align-middle text-xs font-medium whitespace-nowrap text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] max-sm:px-3 text-right">
Changes
</th>
<th scope="col" class="h-10 border-b px-4 text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground shadow-[inset_0_-1px_0_var(--border)] max-sm:px-3">
Highlights
</th>
</tr>
</thead>
<tbody>
<tr class="transition-colors hover:bg-muted/60 motion-reduce:transition-none [&:last-child>td]:border-b-0">
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<a href="#" class="rounded-sm text-foreground underline-offset-4 transition-[color,text-decoration-color,background-color] active:bg-accent [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:transition-transform underline decoration-foreground/30 hover:decoration-foreground active:decoration-2 font-medium">
2.4.0
</a>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">4 Nov 2026</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">
<span class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground ring-1 ring-border size-6 text-[10px]">
<span role="img" aria-label="Ada Lovelace">AL</span>
</span>
</span>
Ada Lovelace
</span>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3 text-right tabular-nums">18</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">Dark mode for the settings pages</td>
</tr>
<tr class="transition-colors hover:bg-muted/60 motion-reduce:transition-none [&:last-child>td]:border-b-0">
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<a href="#" class="rounded-sm text-foreground underline-offset-4 transition-[color,text-decoration-color,background-color] active:bg-accent [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:transition-transform underline decoration-foreground/30 hover:decoration-foreground active:decoration-2 font-medium">
2.3.2
</a>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">21 Oct 2026</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">
<span class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground ring-1 ring-border size-6 text-[10px]">
<span role="img" aria-label="Grace Hopper">GH</span>
</span>
</span>
Grace Hopper
</span>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3 text-right tabular-nums">4</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">Fixes the export for large accounts</td>
</tr>
<tr class="transition-colors hover:bg-muted/60 motion-reduce:transition-none [&:last-child>td]:border-b-0">
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<a href="#" class="rounded-sm text-foreground underline-offset-4 transition-[color,text-decoration-color,background-color] active:bg-accent [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:transition-transform underline decoration-foreground/30 hover:decoration-foreground active:decoration-2 font-medium">
2.3.1
</a>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">14 Oct 2026</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">
<span class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground ring-1 ring-border size-6 text-[10px]">
<span role="img" aria-label="Alan Turing">AT</span>
</span>
</span>
Alan Turing
</span>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3 text-right tabular-nums">2</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">Keyboard focus in the invoice list</td>
</tr>
<tr class="transition-colors hover:bg-muted/60 motion-reduce:transition-none [&:last-child>td]:border-b-0">
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<a href="#" class="rounded-sm text-foreground underline-offset-4 transition-[color,text-decoration-color,background-color] active:bg-accent [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:transition-transform underline decoration-foreground/30 hover:decoration-foreground active:decoration-2 font-medium">
2.3.0
</a>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">30 Sep 2026</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">
<span class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground ring-1 ring-border size-6 text-[10px]">
<span role="img" aria-label="Ada Lovelace">AL</span>
</span>
</span>
Ada Lovelace
</span>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3 text-right tabular-nums">23</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">Receipts by photo, bank feed filters</td>
</tr>
<tr class="transition-colors hover:bg-muted/60 motion-reduce:transition-none [&:last-child>td]:border-b-0">
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<a href="#" class="rounded-sm text-foreground underline-offset-4 transition-[color,text-decoration-color,background-color] active:bg-accent [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:transition-transform underline decoration-foreground/30 hover:decoration-foreground active:decoration-2 font-medium">
2.2.0
</a>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">2 Sep 2026</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">
<span class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground ring-1 ring-border size-6 text-[10px]">
<span role="img" aria-label="Katherine Johnson">KJ</span>
</span>
</span>
Katherine Johnson
</span>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3 text-right tabular-nums">15</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">Recurring invoices</td>
</tr>
<tr class="transition-colors hover:bg-muted/60 motion-reduce:transition-none [&:last-child>td]:border-b-0">
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<a href="#" class="rounded-sm text-foreground underline-offset-4 transition-[color,text-decoration-color,background-color] active:bg-accent [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:transition-transform underline decoration-foreground/30 hover:decoration-foreground active:decoration-2 font-medium">
2.1.4
</a>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">19 Aug 2026</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">
<span class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-muted-foreground ring-1 ring-border size-6 text-[10px]">
<span role="img" aria-label="Grace Hopper">GH</span>
</span>
</span>
Grace Hopper
</span>
</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3 text-right tabular-nums">3</td>
<td class="border-b px-4 py-3 align-middle whitespace-nowrap max-sm:px-3">Faster search</td>
</tr>
</tbody>
</table>
</div>
</div>