Data Table
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add display/data-tablePlain-CSS theme — no build step
npx astrocraft-ui add display/data-table --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add display/data-table --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add display/data-table --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add display/data-table --theme tailwind --bridge lumosLive demo
DataTable
A real <table>, so row and column association come from the browser. Click a column head to sort — aria-sort moves to it and the rows are moved, not re-rendered, so the checkboxes keep their state. The header checkbox is the library's third indeterminate case and reuses CheckboxGroup's groupState(). Drag the handle after Client to resize it, or focus it and use the arrow keys.
| Northwind | Paid | £1,250.00 | 2 Sept 2026 | |
| Acme | Overdue | £480.50 | 14 Aug 2026 | |
| Initech | Draft | £9,600.00 | 1 Oct 2026 | |
| Umbrella | Paid | £75.00 | 19 Sept 2026 |
1–4 of 42
- Previous page
- Next page
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `DataTable.astro` | `data-table` `data-table-container` `data-table-status` | — | — | — |
| `DataTableHead.astro` | `data-table-head` | `data-sort`: `none` · `ascending` · `descending` `data-align`: `start` · `center` · `end` | — | — |
| `TableColumnResizer.astro` | `table-column-resizer` | — | — | — |
| `TablePagination.astro` | `table-pagination` `table-pagination-status` | — | — | — |
| `TableRowSelect.astro` | `table-row-select` | — | — | — |
| `TableSelectAll.astro` | `table-select-all` | — | — | — |
| `TableSortButton.astro` | `table-sort-button` | — | — | — |
| `TableToolbar.astro` | `table-toolbar` | `data-align`: `start` · `between` · `end` | — | — |
Source
What the command copies — 11 files, alias-free so the copy lands standing alone. This is the whole component, shown without its comments; the files you install keep them, with the reasoning behind every rule.
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"table"> & {
label?: string;
ascendingLabel?: string;
descendingLabel?: string;
};
const {
label,
ascendingLabel = "ascending",
descendingLabel = "descending",
class: className,
...rest
} = Astro.props;
---
<div data-slot="data-table-container">
<table
aria-label={label}
class={className}
data-slot="data-table"
data-ascending-label={ascendingLabel}
data-descending-label={descendingLabel}
{...rest}
>
<slot />
</table>
<span role="status" aria-live="polite" data-slot="data-table-status"></span>
</div>
<script>
import { onReadyOnce } from "../../_once";
import { groupState } from "../../forms/checkbox-group/group-state";
import { nextDirection, orderBy } from "./sort";
const SORT_BUTTON = '[data-slot="table-sort-button"]';
const RESIZER = '[data-slot="table-column-resizer"]';
const SELECT_ALL = '[data-slot="table-select-all"]';
const ROW_SELECT = '[data-slot="table-row-select"]';
const MIN_WIDTH = 48;
const KEY_STEP = 16;
function sortValue(row: HTMLTableRowElement, column: number): string {
const cell = row.cells[column];
return (cell?.dataset.sortValue ?? cell?.textContent ?? "").trim();
}
function wireSort(table: HTMLTableElement, announce: (text: string) => void) {
table.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest<HTMLElement>(SORT_BUTTON);
const head = button?.closest("th");
const body = table.tBodies[0];
if (!button || !head || !body) return;
const direction = nextDirection(head.getAttribute("aria-sort"));
for (const other of table.querySelectorAll("th[aria-sort]")) {
other.setAttribute("aria-sort", "none");
}
head.setAttribute("aria-sort", direction);
const rows = [...body.rows];
const column = head.cellIndex;
for (const i of orderBy(
rows.map((row) => sortValue(row, column)),
direction,
)) {
body.append(rows[i]);
}
const word =
direction === "ascending" ? table.dataset.ascendingLabel : table.dataset.descendingLabel;
announce(`${(button.textContent ?? "").trim()}, ${word ?? direction}`);
});
}
function wireSelection(table: HTMLTableElement, announce: (text: string) => void) {
const all = table.querySelector<HTMLInputElement>(SELECT_ALL);
const boxes = () => [...table.querySelectorAll<HTMLInputElement>(ROW_SELECT)];
if (!all && boxes().length === 0) return;
let bulk = false;
const sync = (quiet = false) => {
const checked = boxes().filter((box) => box.checked);
if (all) {
const state = groupState(boxes().map((box) => box.checked));
all.checked = state === "all";
all.indeterminate = state === "some";
}
if (!quiet) announce(String(checked.length));
};
all?.addEventListener("change", () => {
const next = all.checked;
bulk = true;
for (const box of boxes()) {
if (box.disabled || box.checked === next) continue;
box.checked = next;
box.dispatchEvent(new Event("change", { bubbles: true }));
}
bulk = false;
sync();
});
table.addEventListener("change", (event) => {
if (bulk) return;
if (event.target instanceof HTMLInputElement && event.target.matches(ROW_SELECT)) sync();
});
for (const box of boxes()) {
if (box.dataset.autoLabel !== "true") continue;
const row = box.closest("tr");
const text = [...(row?.cells ?? [])]
.map((cell) => (cell.textContent ?? "").trim())
.find((value) => value !== "");
if (text) box.setAttribute("aria-label", `${box.getAttribute("aria-label")} ${text}`);
}
sync(true);
}
function wireResizers(table: HTMLTableElement) {
const handles = [...table.querySelectorAll<HTMLElement>(RESIZER)];
if (handles.length === 0) return;
for (const head of table.querySelectorAll("th")) {
head.style.width = `${head.getBoundingClientRect().width}px`;
}
table.style.tableLayout = "fixed";
for (const handle of handles) {
const head = handle.closest("th");
if (!head) continue;
const setWidth = (px: number) => {
head.style.width = `${Math.max(MIN_WIDTH, Math.round(px))}px`;
handle.setAttribute(
"aria-valuenow",
String(Math.round(head.getBoundingClientRect().width)),
);
};
handle.tabIndex = 0;
handle.setAttribute("aria-valuemin", String(MIN_WIDTH));
setWidth(head.getBoundingClientRect().width);
handle.addEventListener("pointerdown", (event) => {
event.preventDefault();
handle.setPointerCapture(event.pointerId);
handle.focus();
const startX = event.clientX;
const startWidth = head.getBoundingClientRect().width;
const move = (e: PointerEvent) => setWidth(startWidth + (e.clientX - startX));
const stop = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", stop);
handle.removeEventListener("pointercancel", stop);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", stop);
handle.addEventListener("pointercancel", stop);
});
handle.addEventListener("keydown", (event) => {
const step =
event.key === "ArrowLeft" ? -KEY_STEP : event.key === "ArrowRight" ? KEY_STEP : 0;
if (step === 0) return;
event.preventDefault();
setWidth(head.getBoundingClientRect().width + step);
});
handle.addEventListener("click", (event) => event.stopPropagation());
}
}
function wire(table: HTMLElement) {
if (!(table instanceof HTMLTableElement)) return;
const status = table.parentElement?.querySelector<HTMLElement>(
'[data-slot="data-table-status"]',
);
const announce = (text: string) => {
if (!status) return;
status.textContent = "";
status.textContent = text;
};
wireSort(table, announce);
wireSelection(table, announce);
wireResizers(table);
}
onReadyOnce('[data-slot="data-table"]', wire);
</script>
---
import type { HTMLAttributes } from "astro/types";
type Props = Omit<HTMLAttributes<"th">, "align"> & {
sortable?: boolean;
sort?: "none" | "ascending" | "descending";
align?: "start" | "center" | "end";
};
const {
sortable = false,
sort = "none",
align = "start",
scope = "col",
class: className,
...rest
} = Astro.props;
---
<th
scope={scope}
aria-sort={sortable || sort !== "none" ? sort : undefined}
class={className}
data-slot="data-table-head"
data-align={align}
{...rest}
>
<slot />
</th>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"span"> & { label?: string };
const { label = "Resize column", class: className, ...rest } = Astro.props;
---
<span
role="separator"
aria-orientation="vertical"
aria-label={label}
class={className}
data-slot="table-column-resizer"
{...rest}></span>
---
import type { HTMLAttributes } from "astro/types";
import { pageRange } from "./page-range";
type Props = HTMLAttributes<"nav"> & {
page?: number;
pageSize?: number;
total: number;
label?: string;
ofLabel?: string;
};
const {
page = 1,
pageSize = 10,
total,
label = "Table pagination",
ofLabel = "of",
class: className,
...rest
} = Astro.props;
const range = pageRange(page, pageSize, total);
const rangeText = `${range.from}–${range.to} ${ofLabel} ${total}`;
---
<nav
aria-label={label}
class={className}
data-slot="table-pagination"
data-page={range.page}
data-pages={range.pages}
{...rest}
>
<p role="status" data-slot="table-pagination-status">{rangeText}</p>
<slot />
</nav>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & { label?: string };
const { label, class: className, ...rest } = Astro.props;
---
<input
type="checkbox"
aria-label={label ?? "Select row"}
class={className}
data-slot="table-row-select"
data-auto-label={label ? undefined : "true"}
{...rest}
/>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"input"> & { label?: string };
const { label = "Select all rows", class: className, ...rest } = Astro.props;
---
<input
type="checkbox"
aria-label={label}
class={className}
data-slot="table-select-all"
{...rest}
/>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"button">;
const { class: className, ...rest } = Astro.props;
---
<button type="button" class={className} data-slot="table-sort-button" {...rest}><slot /></button>
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"div"> & {
label?: string;
align?: "start" | "between" | "end";
};
const { label, align = "between", class: className, ...rest } = Astro.props;
---
<div
role={label ? "group" : undefined}
aria-label={label}
class={className}
data-slot="table-toolbar"
data-align={align}
{...rest}
>
<slot />
</div>
import DataTable from "./DataTable.astro";
import DataTableHead from "./DataTableHead.astro";
import TableColumnResizer from "./TableColumnResizer.astro";
import TablePagination from "./TablePagination.astro";
import TableRowSelect from "./TableRowSelect.astro";
import TableSelectAll from "./TableSelectAll.astro";
import TableSortButton from "./TableSortButton.astro";
import TableToolbar from "./TableToolbar.astro";
export { type PageRange, pageRange } from "./page-range";
export { compareValues, nextDirection, orderBy, type SortDirection } from "./sort";
export {
DataTable,
DataTableHead,
TableColumnResizer,
TablePagination,
TableRowSelect,
TableSelectAll,
TableSortButton,
TableToolbar,
};
export default DataTable;
export interface PageRange {
from: number;
to: number;
page: number;
pages: number;
}
export function pageRange(page: number, pageSize: number, total: number): PageRange {
const size = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : 0;
const rows = Number.isFinite(total) && total > 0 ? Math.floor(total) : 0;
const pages = size > 0 ? Math.ceil(rows / size) : 0;
const current = Math.min(Math.max(Number.isFinite(page) ? Math.floor(page) : 1, 1), pages || 1);
if (pages === 0) return { from: 0, to: 0, page: 1, pages: 0 };
return {
from: (current - 1) * size + 1,
to: Math.min(current * size, rows),
page: current,
pages,
};
}
export type SortDirection = "ascending" | "descending";
export function nextDirection(current: string | null | undefined): SortDirection {
return current === "ascending" ? "descending" : "ascending";
}
export function compareValues(a: string, b: string): number {
const [x, y] = [Number(a), Number(b)];
if (a.trim() !== "" && b.trim() !== "" && Number.isFinite(x) && Number.isFinite(y)) return x - y;
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
}
export function orderBy(values: readonly string[], direction: SortDirection): number[] {
const sign = direction === "descending" ? -1 : 1;
return values.map((_, i) => i).sort((i, j) => sign * compareValues(values[i], values[j]));
}
What you get
The component source, copied into your project by npx astrocraft-ui add display/data-table — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.