Skip to main content
astrocraft-ui/ components · 101

Data Table

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add display/data-table

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add display/data-table --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add display/data-table --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/data-table --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/data-table --theme tailwind --bridge lumos

Live 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.

NorthwindPaid£1,250.002 Sept 2026
AcmeOverdue£480.5014 Aug 2026
InitechDraft£9,600.001 Oct 2026
UmbrellaPaid£75.0019 Sept 2026

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.

ComponentSlotsVariantsRuntime stateNative 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.

src/components/ui/display/data-table/DataTable.astro
---
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>
src/components/ui/display/data-table/DataTableHead.astro
---
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>
src/components/ui/display/data-table/TableColumnResizer.astro
---
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>
src/components/ui/display/data-table/TablePagination.astro
---
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>
src/components/ui/display/data-table/TableRowSelect.astro
---
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}
/>
src/components/ui/display/data-table/TableSelectAll.astro
---
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}
/>
src/components/ui/display/data-table/TableSortButton.astro
---
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>
src/components/ui/display/data-table/TableToolbar.astro
---
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>
src/components/ui/display/data-table/index.ts
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;
src/components/ui/display/data-table/page-range.ts
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,
  };
}
src/components/ui/display/data-table/sort.ts
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.