component
A structured way to present rows and columns of data. Pindoba’s Table wraps TanStack Table v9 and ships with sensible defaults — sorting, filtering, and pagination are on out of the box — while still exposing the full TanStack feature set (row selection, column visibility, expandable rows, grouping, server-side mode) for when you need more.
Both the Astro and Svelte components share a single state core (createTableStore in @pindoba/core-table, backed by nanostores). The Svelte component subscribes reactively; the Astro component SSR-renders the first page and ships a tiny mountTable script that hydrates the markup into a live TanStack instance on the client — no framework island required. Every advanced feature (row selection, column visibility, expandable rows, grouping, server-side mode) works in both frameworks; the only difference is how you author per-cell content (Svelte snippets vs. Astro <template> slots).
The table inherits its surface (background, border, radius, shadow) from Panel, so the same design tokens apply. Interior surfaces — the header, footer, filter rows, group cells, and striped rows — automatically pick a contrasting step on the surface ramp for whichever background you choose, so the header never blends into the body.
Two levels of configuration, pick whichever fits:
data and columns. You get sortable headers, built-in pagination controls, and a ready-to-filter row model automatically.enableRowSelection, enableColumnVisibility, enableExpanding, enableGrouping, manualPagination, etc. to layer in advanced behaviors.Sorting and pagination are active by default. Click any header to cycle through ascending, descending, and unsorted.
| Ada Lovelace | Engineer | London | 1843 |
| Alan Turing | Mathematician | Manchester | 1936 |
| Grace Hopper | Admiral | New York | 1944 |
| Katherine Johnson | Mathematician | Hampton | 1953 |
| Hedy Lamarr | Inventor | Vienna | 1942 |
| Claude Shannon | Engineer | Michigan | 1948 |
| John von Neumann | Mathematician | Budapest | 1945 |
| Margaret Hamilton | Engineer | Boston | 1961 |
| Dennis Ritchie | Engineer | New Jersey | 1972 |
| Barbara Liskov | Researcher | California | 1968 |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Person {
name: string;
role: string;
location: string;
joined: string;
}
const data: Person[] = [
{
name: "Ada Lovelace",
role: "Engineer",
location: "London",
joined: "1843",
},
{
name: "Alan Turing",
role: "Mathematician",
location: "Manchester",
joined: "1936",
},
{
name: "Grace Hopper",
role: "Admiral",
location: "New York",
joined: "1944",
},
{
name: "Katherine Johnson",
role: "Mathematician",
location: "Hampton",
joined: "1953",
},
{ name: "Hedy Lamarr", role: "Inventor", location: "Vienna", joined: "1942" },
{
name: "Claude Shannon",
role: "Engineer",
location: "Michigan",
joined: "1948",
},
{
name: "John von Neumann",
role: "Mathematician",
location: "Budapest",
joined: "1945",
},
{
name: "Margaret Hamilton",
role: "Engineer",
location: "Boston",
joined: "1961",
},
{
name: "Dennis Ritchie",
role: "Engineer",
location: "New Jersey",
joined: "1972",
},
{
name: "Barbara Liskov",
role: "Researcher",
location: "California",
joined: "1968",
},
{
name: "Donald Knuth",
role: "Author",
location: "Stanford",
joined: "1968",
},
{
name: "Linus Torvalds",
role: "Engineer",
location: "Helsinki",
joined: "1991",
},
{
name: "Radia Perlman",
role: "Engineer",
location: "Virginia",
joined: "1985",
},
{
name: "Tim Berners-Lee",
role: "Engineer",
location: "Geneva",
joined: "1989",
},
{
name: "Anita Borg",
role: "Researcher",
location: "California",
joined: "1986",
},
];
const columns: TableColumnDef<Person>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "location", header: "Location" },
{ accessorKey: "joined", header: "Joined" },
];
---
<Table
data={data}
columns={columns}
caption="Pioneers — sorting and pagination on by default"
/>Omit the caption prop when the table sits inside a section that already provides its own heading.
| Ada Lovelace | Engineer | London | 1843 |
| Alan Turing | Mathematician | Manchester | 1936 |
| Grace Hopper | Admiral | New York | 1944 |
| Katherine Johnson | Mathematician | Hampton | 1953 |
| Hedy Lamarr | Inventor | Vienna | 1942 |
| Claude Shannon | Engineer | Michigan | 1948 |
| John von Neumann | Mathematician | Budapest | 1945 |
| Margaret Hamilton | Engineer | Boston | 1961 |
| Dennis Ritchie | Engineer | New Jersey | 1972 |
| Barbara Liskov | Researcher | California | 1968 |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Person {
name: string;
role: string;
location: string;
joined: string;
}
const data: Person[] = [
{
name: "Ada Lovelace",
role: "Engineer",
location: "London",
joined: "1843",
},
{
name: "Alan Turing",
role: "Mathematician",
location: "Manchester",
joined: "1936",
},
{
name: "Grace Hopper",
role: "Admiral",
location: "New York",
joined: "1944",
},
{
name: "Katherine Johnson",
role: "Mathematician",
location: "Hampton",
joined: "1953",
},
{ name: "Hedy Lamarr", role: "Inventor", location: "Vienna", joined: "1942" },
{
name: "Claude Shannon",
role: "Engineer",
location: "Michigan",
joined: "1948",
},
{
name: "John von Neumann",
role: "Mathematician",
location: "Budapest",
joined: "1945",
},
{
name: "Margaret Hamilton",
role: "Engineer",
location: "Boston",
joined: "1961",
},
{
name: "Dennis Ritchie",
role: "Engineer",
location: "New Jersey",
joined: "1972",
},
{
name: "Barbara Liskov",
role: "Researcher",
location: "California",
joined: "1968",
},
{
name: "Donald Knuth",
role: "Author",
location: "Stanford",
joined: "1968",
},
{
name: "Linus Torvalds",
role: "Engineer",
location: "Helsinki",
joined: "1991",
},
{
name: "Radia Perlman",
role: "Engineer",
location: "Virginia",
joined: "1985",
},
{
name: "Tim Berners-Lee",
role: "Engineer",
location: "Geneva",
joined: "1989",
},
{
name: "Anita Borg",
role: "Researcher",
location: "California",
joined: "1986",
},
];
const columns: TableColumnDef<Person>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "location", header: "Location" },
{ accessorKey: "joined", header: "Joined" },
];
---
<Table data={data} columns={columns} />Sorting is on by default. To seed the initial sort, use initialState. To disable sorting entirely, set enableSorting={false}.
| Framework B | 48000 | JavaScript |
| Framework E | 31200 | Python |
| Framework D | 23500 | Go |
| Framework A | 12000 | TypeScript |
| Framework C | 7400 | Rust |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Row {
name: string;
stars: number;
language: string;
}
const data: Row[] = [
{ name: "Framework A", stars: 12000, language: "TypeScript" },
{ name: "Framework B", stars: 48000, language: "JavaScript" },
{ name: "Framework C", stars: 7400, language: "Rust" },
{ name: "Framework D", stars: 23500, language: "Go" },
{ name: "Framework E", stars: 31200, language: "Python" },
];
const columns: TableColumnDef<Row>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "stars", header: "★ Stars" },
{ accessorKey: "language", header: "Language" },
];
---
<Table
data={data}
columns={columns}
striped
caption="Click any header to sort (ascending, descending, unsorted)"
initialState={{ sorting: [{ id: "stars", desc: true }] }}
enablePagination={false}
/>The footer renders pindoba’s Pagination component — First/Prev/Next/Last buttons, a jump-to-page select, an items-per-page select, and an “X – Y / Z” summary. Set pageSize to tune the page size (default 10) and pageSizeOptions to control the items-per-page choices. To hide the controls but keep pagination, use showPagination={false}; to disable pagination entirely, use enablePagination={false}. To replace the footer wholesale, pass the pagination snippet (Svelte).
| ORD-1000 | Customer 1 | $1.74 | pending |
| ORD-1001 | Customer 2 | $3.48 | shipped |
| ORD-1002 | Customer 3 | $5.22 | delivered |
| ORD-1003 | Customer 4 | $6.96 | cancelled |
| ORD-1004 | Customer 5 | $8.70 | pending |
| ORD-1005 | Customer 6 | $10.43 | shipped |
| ORD-1006 | Customer 7 | $12.17 | delivered |
| ORD-1007 | Customer 8 | $13.91 | cancelled |
| ORD-1008 | Customer 9 | $15.65 | pending |
| ORD-1009 | Customer 10 | $17.39 | shipped |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Order {
id: string;
customer: string;
total: string;
status: "pending" | "shipped" | "delivered" | "cancelled";
}
const statuses: Order["status"][] = [
"pending",
"shipped",
"delivered",
"cancelled",
];
// Deterministic totals for SSR: avoids hydration mismatches from Math.random.
const data: Order[] = Array.from({ length: 47 }, (_, i) => {
const totalNum = Math.round(((i + 1) * 173.91) % 50000) / 100;
return {
id: `ORD-${String(1000 + i)}`,
customer: `Customer ${i + 1}`,
total: `$${totalNum.toFixed(2)}`,
status: statuses[i % statuses.length]!,
};
});
const columns: TableColumnDef<Order>[] = [
{ accessorKey: "id", header: "Order ID" },
{ accessorKey: "customer", header: "Customer" },
{ accessorKey: "total", header: "Total" },
{ accessorKey: "status", header: "Status" },
];
---
<Table
data={data}
columns={columns}
caption="47 orders — paginated 10 per page"
pageSize={10}
pageSizeOptions={[5, 10, 25, 50]}
bordered
/>Enable the built-in global filter with showGlobalFilter. The input is wired to table.setGlobalFilter and filters across all columns.
| react | 19.0.0 | 28,500,000 | MIT |
| vue | 3.5.0 | 5,900,000 | MIT |
| svelte | 5.0.0 | 1,200,000 | MIT |
| angular | 18.0.0 | 3,400,000 | MIT |
| solid-js | 1.8.0 | 180,000 | MIT |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Package {
name: string;
version: string;
downloads: number;
downloadsFormatted: string;
license: string;
}
const raw = [
{ name: "react", version: "19.0.0", downloads: 28_500_000, license: "MIT" },
{ name: "vue", version: "3.5.0", downloads: 5_900_000, license: "MIT" },
{ name: "svelte", version: "5.0.0", downloads: 1_200_000, license: "MIT" },
{ name: "angular", version: "18.0.0", downloads: 3_400_000, license: "MIT" },
{ name: "solid-js", version: "1.8.0", downloads: 180_000, license: "MIT" },
{ name: "preact", version: "10.22.0", downloads: 950_000, license: "MIT" },
{ name: "qwik", version: "1.6.0", downloads: 45_000, license: "MIT" },
{ name: "lit", version: "3.1.0", downloads: 720_000, license: "BSD-3" },
{ name: "ember", version: "5.10.0", downloads: 95_000, license: "MIT" },
{ name: "alpine", version: "3.14.0", downloads: 120_000, license: "MIT" },
];
const data: Package[] = raw.map((p) => ({
...p,
downloadsFormatted: p.downloads.toLocaleString("en-US"),
}));
const columns: TableColumnDef<Package>[] = [
{ accessorKey: "name", header: "Package" },
{ accessorKey: "version", header: "Version" },
{ accessorKey: "downloadsFormatted", header: "Weekly Downloads" },
{ accessorKey: "license", header: "License" },
];
---
<Table
data={data}
columns={columns}
caption="Type in the search box to filter rows across all columns"
showGlobalFilter
globalFilterPlaceholder="Search packages…"
pageSize={5}
/>Set showColumnFilters to render a built-in filter row — one text input per filterable column, wired to column.setFilterValue. It implies enableColumnFilters, which also wires TanStack’s faceted row models (the facetedUniqueValues / facetedMinMaxValues feature slots) so you can build value lists for richer filter UIs. Disable filtering on a column with enableColumnFilter: false in its definition. columnFilterPlaceholder sets the inputs’ placeholder.
| Ada Lovelace | Engineering | London | Principal |
| Grace Hopper | Engineering | New York | Distinguished |
| Katherine Johnson | Research | Hampton | Lead |
| Alan Turing | Research | London | Principal |
| Linus Torvalds | Platform | Portland | Fellow |
| Margaret Hamilton | Platform | Boston | Director |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Employee {
name: string;
department: string;
location: string;
title: string;
}
const data: Employee[] = [
{
name: "Ada Lovelace",
department: "Engineering",
location: "London",
title: "Principal",
},
{
name: "Grace Hopper",
department: "Engineering",
location: "New York",
title: "Distinguished",
},
{
name: "Katherine Johnson",
department: "Research",
location: "Hampton",
title: "Lead",
},
{
name: "Alan Turing",
department: "Research",
location: "London",
title: "Principal",
},
{
name: "Linus Torvalds",
department: "Platform",
location: "Portland",
title: "Fellow",
},
{
name: "Margaret Hamilton",
department: "Platform",
location: "Boston",
title: "Director",
},
];
const columns: TableColumnDef<Employee>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "department", header: "Department" },
{ accessorKey: "location", header: "Location" },
{ accessorKey: "title", header: "Title" },
];
---
<!--
`showColumnFilters` renders a built-in filter row of inputs (one per
filterable column). Each input carries `data-table-column-filter`, which
mountTable's delegated input handler wires to `column.setFilterValue`.
-->
<Table
data={data}
columns={columns}
showColumnFilters
columnFilterPlaceholder="Filter…"
enablePagination={false}
caption="Per-column filtering — type in any column header input"
/>Customize per-column rendering with the cell and header snippets in Svelte, or with named cell-{columnId} / header-{columnId} slots in Astro. The Svelte snippet receives { value, row, columnId, cell, rowApi }. The Astro slot is a static template that mountTable clones into every row, substituting data-table-bind="value" (or data-table-bind="row.<key>") text per row.
| T-1 | Design review | Ada | high | 80 |
| T-2 | Write tests | Grace | medium | 45 |
| T-3 | Update docs | Alan | low | 100 |
---
import Table from "../table.astro";
import Avatar from "@pindoba/astro-avatar";
import Badge from "@pindoba/astro-badge";
import type { TableColumnDef } from "@pindoba/core-table";
interface Task {
id: string;
title: string;
assignee: string;
// First letter of `assignee`; baked into the data so the Astro `<Avatar>`
// template can render it declaratively (`data-table-bind` can't call a
// function per row). Svelte uses the same field for strict parity.
assigneeInitial: string;
priority: "low" | "medium" | "high";
// Numeric rank so Priority sorts semantically (high > medium > low) via
// the `meta.sortByField` convention — a string `sortingFn` that survives
// Astro's SSR→client boundary.
priorityRank: 1 | 2 | 3;
progress: number;
}
const data: Task[] = [
{
id: "T-1",
title: "Design review",
assignee: "Ada",
assigneeInitial: "A",
priority: "high",
priorityRank: 3,
progress: 80,
},
{
id: "T-2",
title: "Write tests",
assignee: "Grace",
assigneeInitial: "G",
priority: "medium",
priorityRank: 2,
progress: 45,
},
{
id: "T-3",
title: "Update docs",
assignee: "Alan",
assigneeInitial: "A",
priority: "low",
priorityRank: 1,
progress: 100,
},
];
const columns: TableColumnDef<Task>[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "title", header: "Task" },
{ accessorKey: "assignee", header: "Assignee" },
{
accessorKey: "priority",
header: "Priority",
// `meta.sortByField` makes createTableOptions synthesize a sortingFn that
// compares numeric `priorityRank` instead of alphabetical "priority".
meta: { sortByField: "priorityRank" },
},
{ accessorKey: "progress", header: "Progress" },
];
---
<Table
data={data}
columns={columns}
caption="Per-column slots compose Pindoba components with row-scoped bindings"
enablePagination={false}
>
{/* Title cell: bold title with the row id underneath. */}
<template data-table-cell-template="title">
<strong data-table-bind="value"></strong>
<small style="display: block; opacity: 0.6;">
row <span data-table-bind="row.id"></span>
</small>
</template>
{
/* Assignee cell: Pindoba Avatar + name side by side. The Avatar's fallback
slot receives the per-row initial via `data-table-bind`. */
}
<template data-table-cell-template="assignee">
<span style="display: inline-flex; align-items: center; gap: 0.5rem;">
<Avatar size="sm">
<span slot="fallback" data-table-bind="row.assigneeInitial"></span>
</Avatar>
<span data-table-bind="value"></span>
</span>
</template>
{
/* Priority cell: one `<Badge>` per variant, mount-table's `data-table-show-if`
hides the non-matching ones per row. `data-table-bind="value"` fills the
label text. */
}
<template data-table-cell-template="priority">
<Badge feedback="danger" data-table-show-if="row.priority=high">
<span data-table-bind="value"></span>
</Badge>
<Badge feedback="warning" data-table-show-if="row.priority=medium">
<span data-table-bind="value"></span>
</Badge>
<Badge feedback="success" data-table-show-if="row.priority=low">
<span data-table-bind="value"></span>
</Badge>
</template>
{
/* Progress cell: hand-rolled bar. Styles are INLINE (not a scoped <style>)
because mountTable clones this <template> into each row at runtime — and
cloned nodes don't carry Astro's component scope attribute, so a scoped
<style> would never match them. `data-table-bind="row.progress"` injects
the numeric value as textContent; the inline <script> below reads it to
set the fill width. The value text is hidden in the fill (font-size: 0)
but never cleared (clearing would re-fire the MutationObserver and read
"" back as 0%). */
}
<template data-table-cell-template="progress">
<span style="display: flex; align-items: center; gap: 0.5rem;">
<span
style="flex: 1; min-width: 60px; height: 6px; border-radius: 9999px; background: rgba(127, 127, 127, 0.2); overflow: hidden;"
>
<span
class="progress-fill"
data-table-bind="row.progress"
style="display: block; height: 100%; font-size: 0; line-height: 0; background: var(--colors-primary-solid, #6366f1);"
></span>
</span>
<span
style="font-variant-numeric: tabular-nums; font-size: 0.75rem; white-space: nowrap;"
>
<span data-table-bind="row.progress"></span>%
</span>
</span>
</template>
</Table>
<script>
/* `data-table-bind` sets the fill span's textContent to the numeric progress.
Turn that text into a `width` (and a 100%-complete green) so the visual
matches the Svelte demo. Runs on every mutation inside `tbody`.
We never clear the textContent: doing so would re-fire the MutationObserver
and read "" back as 0%, making the bar vanish after the first sort. */
function applyProgress(root: Element) {
root.querySelectorAll<HTMLElement>(".progress-fill").forEach((el) => {
const pct = Number(el.textContent ?? 0);
el.style.width = `${pct}%`;
el.style.background =
pct >= 100
? "var(--colors-green-solid, #10b981)"
: "var(--colors-primary-solid, #6366f1)";
});
}
function init() {
document
.querySelectorAll<HTMLElement>('[data-table-cell-template="progress"]')
.forEach((tpl) => {
const root = tpl.closest<HTMLElement>('[data-component="table"]');
if (!root) return;
applyProgress(root);
const tbody = root.querySelector("tbody");
if (!tbody) return;
const mo = new MutationObserver(() => applyProgress(root));
mo.observe(tbody, { childList: true, subtree: true });
});
}
init();
document.addEventListener("astro:page-load", init);
</script>Set enableRowSelection to activate selection state. Add a select column in your column definitions and render a checkbox per row.
bind:instance to read instance.store.state.rowSelection from outside the table; use the cell snippet to render checkboxes wired to rowApi.getToggleSelectedHandler().cell-select / header-select slots tagged with data-table-select-row / data-table-select-all. mountTable wires the change handlers and syncs checked state on every render.0 of 5 selected
| Name | Role | ||
|---|---|---|---|
| Ada Lovelace | ada@example.com | Admin | |
| Alan Turing | alan@example.com | Member | |
| Grace Hopper | grace@example.com | Admin | |
| Linus Torvalds | linus@example.com | Member | |
| Donald Knuth | donald@example.com | Member |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface User {
id: string;
name: string;
email: string;
role: string;
}
const data: User[] = [
{ id: "u1", name: "Ada Lovelace", email: "ada@example.com", role: "Admin" },
{ id: "u2", name: "Alan Turing", email: "alan@example.com", role: "Member" },
{ id: "u3", name: "Grace Hopper", email: "grace@example.com", role: "Admin" },
{
id: "u4",
name: "Linus Torvalds",
email: "linus@example.com",
role: "Member",
},
{
id: "u5",
name: "Donald Knuth",
email: "donald@example.com",
role: "Member",
},
];
const columns: TableColumnDef<User>[] = [
{ id: "select", header: "", cell: () => "", enableSorting: false },
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role" },
];
---
<div
data-demo="table-selectable"
style="display: flex; flex-direction: column; gap: 0.75rem;"
>
<p style="margin: 0; font-size: 0.875rem;">
<strong data-selected-count>0</strong> of {data.length} selected
</p>
<Table
data={data}
columns={columns}
enableRowSelection
enablePagination={false}
enableSorting={false}
getRowId={(row) => row.id}
caption="Row selection via the `select` column"
>
<template data-table-header-template="select">
<input
type="checkbox"
data-table-select-all
aria-label="Select all rows"
/>
</template>
<template data-table-cell-template="select">
<input type="checkbox" data-table-select-row aria-label="Select row" />
</template>
</Table>
</div>
<script>
function syncCounts(scope: ParentNode = document) {
const wrappers = scope.querySelectorAll<HTMLElement>(
'[data-demo="table-selectable"]',
);
wrappers.forEach((wrapper) => {
const counter = wrapper.querySelector<HTMLElement>(
"[data-selected-count]",
);
if (!counter) return;
const update = () => {
const n = wrapper.querySelectorAll(
'tbody tr[data-row-selected="true"]',
).length;
counter.textContent = String(n);
};
wrapper.addEventListener("change", update);
// Defer until mountTable has stamped the initial rows.
queueMicrotask(update);
});
}
syncCounts();
document.addEventListener("astro:page-load", () => syncCounts());
</script>With enableColumnVisibility the toolbar shows a built-in Columns menu — a pindoba Popover (so it isn’t clipped by the table’s rounded overflow) containing a Choice checkbox group (button appearance). No wiring required. For a bespoke control, replace it via the toolbar snippet (Svelte) or toolbar slot (Astro):
instance.getAllLeafColumns() and wire each column’s getToggleVisibilityHandler() to a checkbox.data-table-column-toggle="{columnId}". mountTable’s delegated change listener handles the rest.| Revenue | 128000 | 5.4 | 2h ago | Finance |
| Active users | 42100 | -1.2 | 5m ago | Growth |
| Conversion | 3.8 | 0.3 | 1h ago | Product |
| Churn | 2.1 | -0.4 | 3h ago | Retention |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Metric {
name: string;
value: number;
change: number;
updated: string;
owner: string;
}
const data: Metric[] = [
{
name: "Revenue",
value: 128_000,
change: 5.4,
updated: "2h ago",
owner: "Finance",
},
{
name: "Active users",
value: 42_100,
change: -1.2,
updated: "5m ago",
owner: "Growth",
},
{
name: "Conversion",
value: 3.8,
change: 0.3,
updated: "1h ago",
owner: "Product",
},
{
name: "Churn",
value: 2.1,
change: -0.4,
updated: "3h ago",
owner: "Retention",
},
];
const columns: TableColumnDef<Metric>[] = [
{ accessorKey: "name", header: "Metric" },
{ accessorKey: "value", header: "Value" },
{ accessorKey: "change", header: "Change" },
{ accessorKey: "updated", header: "Updated" },
{ accessorKey: "owner", header: "Owner" },
];
---
<!--
`enableColumnVisibility` renders the built-in "Columns" menu in the toolbar;
its checkboxes carry `data-table-column-toggle`, which mountTable's delegated
change handler wires up automatically. (The `toolbar` slot still overrides it
for a bespoke control.)
-->
<Table
data={data}
columns={columns}
enableColumnVisibility
enablePagination={false}
caption="Toggle columns on and off"
/>Pass enableExpanding + getSubRows to render hierarchical data.
rowApi.getToggleExpandedHandler() inside the cell snippet.<button data-table-expand> into the relevant cell-{columnId} slot — mountTable stamps the row id, manages aria-expanded, and hides the button when the row can’t expand. Add data-table-indent to any element to apply depth-based padding.| 📁 src | — | folder | |
| 📁 docs | — | folder | |
| 📄 package.json | 1.1 KB | file |
---
import Table from "../table.astro";
import Button from "@pindoba/astro-button";
import type { TableColumnDef } from "@pindoba/core-table";
interface Folder {
name: string;
size: string;
type: string;
children?: Folder[];
}
const raw: Folder[] = [
{
name: "src",
size: "—",
type: "folder",
children: [
{
name: "components",
size: "—",
type: "folder",
children: [
{ name: "button.tsx", size: "2.1 KB", type: "file" },
{ name: "table.tsx", size: "8.4 KB", type: "file" },
],
},
{ name: "index.ts", size: "340 B", type: "file" },
],
},
{
name: "docs",
size: "—",
type: "folder",
children: [
{ name: "README.md", size: "4.2 KB", type: "file" },
{ name: "CHANGELOG.md", size: "1.8 KB", type: "file" },
],
},
{ name: "package.json", size: "1.1 KB", type: "file" },
];
// Precompute the icon into `name` so the declarative `data-table-bind`
// template picks it up — `applyRowBindings` doesn't do conditional rendering.
function withIcons(rows: Folder[]): Folder[] {
return rows.map((row) => ({
...row,
name: `${row.type === "folder" ? "📁" : "📄"} ${row.name}`,
children: row.children ? withIcons(row.children) : undefined,
}));
}
const data = withIcons(raw);
const columns: TableColumnDef<Folder>[] = [
{ id: "expand", header: "", cell: () => "", enableSorting: false },
{ accessorKey: "name", header: "Name" },
{ accessorKey: "size", header: "Size" },
{ accessorKey: "type", header: "Type" },
];
---
<Table
data={data}
columns={columns}
enableExpanding
enablePagination={false}
getSubRows={(row) => row.children}
caption="Click › to expand folders"
>
<template data-table-cell-template="expand">
<span data-table-indent>
<Button
type="button"
size="xs"
emphasis="ghost"
shape="square"
aria-label="Toggle"
passThrough={{ root: { props: { "data-table-expand": "" } } }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6"></path>
</svg>
</Button>
</span>
</template>
<template data-table-cell-template="name">
<span data-table-bind="row.name"></span>
</template>
</Table>Set enableGrouping (usually with enableExpanding) and seed initialState.grouping to group rows by one or more columns. Set aggregationFn on each column you want summarized — v9 makes it opt-in per column, so a column without one renders blank on the grouped row rather than guessing an aggregate. mountTable renders the grouping expand toggle and aggregated cells natively in both frameworks — no custom slot needed.
| North | 25 | 710 | |
| North | Widget | 12 | 240 |
| North | Gizmo | 8 | 320 |
| North | Sprocket | 5 | 150 |
| South | 34 | 960 | |
| South | Widget | 20 | 400 |
| South | Gizmo | 14 | 560 |
| East | 20 | 510 | |
| East | Widget | 9 | 180 |
| East | Sprocket | 11 | 330 |
| West | 23 | 850 | |
| West | Gizmo | 16 | 640 |
| West | Sprocket | 7 | 210 |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Sale {
region: string;
product: string;
quantity: number;
revenue: number;
}
const data: Sale[] = [
{ region: "North", product: "Widget", quantity: 12, revenue: 240 },
{ region: "North", product: "Gizmo", quantity: 8, revenue: 320 },
{ region: "North", product: "Sprocket", quantity: 5, revenue: 150 },
{ region: "South", product: "Widget", quantity: 20, revenue: 400 },
{ region: "South", product: "Gizmo", quantity: 14, revenue: 560 },
{ region: "East", product: "Widget", quantity: 9, revenue: 180 },
{ region: "East", product: "Sprocket", quantity: 11, revenue: 330 },
{ region: "West", product: "Gizmo", quantity: 16, revenue: 640 },
{ region: "West", product: "Sprocket", quantity: 7, revenue: 210 },
];
// `cell` and `aggregationFn` functions can't cross the SSR → mountTable
// boundary; the hydrator strips them. mountTable still renders grouped /
// aggregated cells natively (folder-style expand toggle for grouped, `<em>`
// wrap for aggregated values), so the table works without explicit slots.
const columns: TableColumnDef<Sale>[] = [
{ accessorKey: "region", header: "Region", enableGrouping: true },
{ accessorKey: "product", header: "Product" },
{ accessorKey: "quantity", header: "Qty", aggregationFn: "sum" },
{ accessorKey: "revenue", header: "Revenue", aggregationFn: "sum" },
];
---
<Table
data={data}
columns={columns}
enableGrouping
enableExpanding
enablePagination={false}
initialState={{ grouping: ["region"], expanded: true }}
caption="Sales grouped by region (click › to collapse)"
bordered
/>For very large datasets, switch to manual mode with manualPagination, manualSorting, manualFiltering, and supply rowCount. Watch the live state and re-fetch the relevant slice from your backend on every change.
state + onStateChange props (or bind:instance) to drive a $effect that re-fetches.mount.store from an inline <script> and call mount.store.actions.setData(nextRows) once the request resolves — pagination/sorting state survives.120 total rows (server-paginated)
---
import Table from "../table.astro";
import Loading from "@pindoba/astro-loading";
import type { TableColumnDef } from "@pindoba/core-table";
interface Log {
id: number;
level: "info" | "warn" | "error";
message: string;
timestamp: string;
}
const TOTAL_ROWS = 120;
function generateAllRows(): Log[] {
const levels: Log["level"][] = ["info", "warn", "error"];
return Array.from({ length: TOTAL_ROWS }, (_, i) => ({
id: i + 1,
level: levels[i % 3]!,
message: `Log entry #${i + 1}`,
timestamp: new Date(Date.UTC(2026, 0, 1) - i * 60_000).toISOString(),
}));
}
// SSR renders the first page of the unsorted dataset. The inline `<script>`
// below takes over on hydration and answers pagination/sorting changes with a
// fresh slice of the *full* dataset — what a real backend would do.
const initialRows = generateAllRows().slice(0, 10);
const columns: TableColumnDef<Log>[] = [
{ accessorKey: "id", header: "#" },
{ accessorKey: "level", header: "Level" },
{ accessorKey: "message", header: "Message" },
{ accessorKey: "timestamp", header: "Timestamp" },
];
---
<div
data-demo="table-server-side"
style="display: flex; flex-direction: column; gap: 0.5rem;"
>
<p style="margin: 0; font-size: 0.875rem;">
<span data-status>{TOTAL_ROWS} total rows (server-paginated)</span>
</p>
<!-- Wrap the table in <Loading> (busy variant); the fetch script below
toggles its data-loading attribute on each round-trip. Loading is not a
table feature — the demo composes it around the table. -->
<Loading variant="busy">
<Table
data={initialRows}
columns={columns}
manualPagination
manualSorting
rowCount={TOTAL_ROWS}
caption="Mock server-side pagination + sorting"
/>
</Loading>
</div>
<script>
import { mountTable } from "@pindoba/core-table";
import type { SortingState } from "@tanstack/table-core";
type Log = {
id: number;
level: "info" | "warn" | "error";
message: string;
timestamp: string;
};
const TOTAL_ROWS = 120;
function generateAllRows(): Log[] {
const levels: Log["level"][] = ["info", "warn", "error"];
return Array.from({ length: TOTAL_ROWS }, (_, i) => ({
id: i + 1,
level: levels[i % 3]!,
message: `Log entry #${i + 1}`,
timestamp: new Date(Date.UTC(2026, 0, 1) - i * 60_000).toISOString(),
}));
}
const ALL_ROWS: Log[] = generateAllRows();
/** Mock server query: sort the full dataset, then slice the requested page. */
function queryPage(pi: number, ps: number, srt: SortingState): Log[] {
const sorted = ALL_ROWS.slice();
const first = srt[0];
if (first) {
const { id, desc } = first;
sorted.sort((a, b) => {
const va = (a as Record<string, unknown>)[id] as string | number;
const vb = (b as Record<string, unknown>)[id] as string | number;
if (va < vb) return desc ? 1 : -1;
if (va > vb) return desc ? -1 : 1;
return 0;
});
}
return sorted.slice(pi * ps, pi * ps + ps);
}
function init() {
const wrappers = document.querySelectorAll<HTMLElement>(
'[data-demo="table-server-side"]',
);
wrappers.forEach((wrapper) => {
const root = wrapper.querySelector<HTMLElement>(
'[data-component="table"][data-table-hydrate]',
);
const status = wrapper.querySelector<HTMLElement>("[data-status]");
// The <Loading> root carries `data-loading`; toggling it drives the
// busy-shimmer CSS while a round-trip is in flight.
const loadingEl = wrapper.querySelector<HTMLElement>("[data-loading]");
if (!root) return;
// Replace the table.astro auto-mount with a manual mount so we control
// the `data` source via async fetches.
root.removeAttribute("data-table-hydrate");
root.setAttribute("data-table-mounted", "true");
const columns = JSON.parse(
root.getAttribute("data-table-columns") ?? "[]",
);
const opts = JSON.parse(root.getAttribute("data-table-options") ?? "{}");
const mount = mountTable<Log>(root, {
...opts,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columns: columns as any,
data: queryPage(0, opts.pageSize ?? 10, []),
});
let lastKey = "";
const fetchPage = async () => {
const state = mount.store.getState();
const pi = state.pagination?.pageIndex ?? 0;
const ps = state.pagination?.pageSize ?? 10;
const srt = (state.sorting ?? []) as SortingState;
const key = `${pi}|${ps}|${srt[0]?.id ?? ""}|${srt[0]?.desc ? "d" : "a"}`;
if (key === lastKey) return;
lastKey = key;
if (status) status.textContent = "Loading…";
loadingEl?.setAttribute("data-loading", "true");
loadingEl?.setAttribute("aria-busy", "true");
await new Promise((r) => setTimeout(r, 250));
mount.store.actions.setData(queryPage(pi, ps, srt));
loadingEl?.setAttribute("data-loading", "false");
loadingEl?.setAttribute("aria-busy", "false");
if (status) {
status.textContent = `${TOTAL_ROWS} total rows (server-paginated)`;
}
};
mount.store.subscribe(() => {
void fetchPage();
});
void fetchPage();
const teardown = () => mount.destroy();
window.addEventListener("pagehide", teardown, { once: true });
document.addEventListener("astro:before-swap", teardown, { once: true });
});
}
init();
document.addEventListener("astro:page-load", init);
</script>Need a TanStack option that isn’t surfaced as a prop — a custom row model, sortDescFirst, custom filterFns, grouped headers via nested columns? Pass tableOptions and the wrapper builds its reactive store from your options merged over Pindoba’s defaults, so sort/filter/paginate stay interactive. (In Astro, function-valued options can’t cross the SSR→client boundary — for those, call mountTable yourself from an inline <script>.)
The legacy table prop — a pre-built TanStack instance — still works for static, read-only tables, but it does not re-render on interaction; prefer tableOptions.
| Contact | Account | |||
|---|---|---|---|---|
| Donald | Knuth | donald@example.com | 21040 | active |
| Ada | Lovelace | ada@example.com | 12450 | active |
| Alan | Turing | alan@example.com | 8300 | active |
| Linus | Torvalds | linus@example.com | 5670 | pending |
| Grace | Hopper | grace@example.com | -230 | overdue |
---
import Table from "../table.astro";
import type { TableColumnDef } from "@pindoba/core-table";
interface Contact {
firstName: string;
lastName: string;
email: string;
balance: number;
status: "active" | "pending" | "overdue";
}
const data: Contact[] = [
{
firstName: "Donald",
lastName: "Knuth",
email: "donald@example.com",
balance: 21_040,
status: "active",
},
{
firstName: "Ada",
lastName: "Lovelace",
email: "ada@example.com",
balance: 12_450,
status: "active",
},
{
firstName: "Alan",
lastName: "Turing",
email: "alan@example.com",
balance: 8_300,
status: "active",
},
{
firstName: "Linus",
lastName: "Torvalds",
email: "linus@example.com",
balance: 5_670,
status: "pending",
},
{
firstName: "Grace",
lastName: "Hopper",
email: "grace@example.com",
balance: -230,
status: "overdue",
},
];
// Grouped-header columns: TanStack resolves a nested `columns` array into a
// multi-row `<thead>` with the right `colspan`.
const columns: TableColumnDef<Contact>[] = [
{
id: "contact",
header: "Contact",
columns: [
{ accessorKey: "firstName", header: "First" },
{ accessorKey: "lastName", header: "Last" },
{ accessorKey: "email", header: "Email" },
],
},
{
id: "account",
header: "Account",
columns: [
{ accessorKey: "balance", header: "Balance" },
{ accessorKey: "status", header: "Status" },
],
},
];
// `tableOptions` is the reactive escape hatch: the SSR build AND the hydrated
// client both feed these consumer-authored TanStack options through Pindoba's
// options builder, so sorting stays interactive (unlike the old static `table`
// instance prop). `sortDescFirst` is a TanStack option not surfaced as a prop.
// (Function options can't cross the SSR→client boundary; for those, call
// mountTable yourself in an inline <script>.)
---
<Table
data={data}
columns={columns}
tableOptions={{ sortDescFirst: true }}
bordered
caption="Grouped headers + a consumer TanStack option (sortDescFirst) — interactive via the reactive escape hatch"
/>Both frameworks reach feature parity through the same shared core. The author-time API is the only thing that differs:
| Need | Svelte | Astro |
|---|---|---|
| Custom cell content | {#snippet cell({ … })} | <slot name="cell-{id}"> + data-table-bind |
| Custom header content | {#snippet header({ … })} | <slot name="header-{id}"> |
| Row selection toggle | rowApi.getToggleSelectedHandler() | data-table-select-row / data-table-select-all |
| Expand/collapse toggle | rowApi.getToggleExpandedHandler() | data-table-expand |
| Depth indentation | style="padding-left: …" | data-table-indent |
| Column visibility | built-in menu (enableColumnVisibility) | built-in menu; or data-table-column-toggle="{id}" |
| Per-column filters | built-in row (showColumnFilters) | built-in row; or data-table-column-filter="{id}" |
| Custom TanStack options | tableOptions (reactive) | tableOptions (serializable) / inline mountTable |
| Async / server-side data | state + onStateChange | mount.store.subscribe + mount.store.actions.setData |
Astro slot templates are inert HTML — they cost nothing for non-JS clients (the SSR fallback rows above stay visible) and they re-render on every store change, so live state stays in sync. For renderers richer than the binding vocabulary supports, call mountTable(el, { cellRenderers: { [columnId]: (ctx) => "…" } }) yourself against the SSR markup from an inline <script>.
surface.groundPanel-inherited surface background token — same values as Panel and Card.
Panel-inherited border token — same values as Panel and Card.
falseRender vertical cell borders.
Optional `<caption>` text rendered above the table rows.
Optional custom cell renderer (fallback is String(value)).
"Filter…"Placeholder for the per-column filter inputs.
TanStack column definitions. Required unless `table` is supplied. Accepts `accessorKey`, `header`, `cell`, `enableSorting`, `enableGrouping`, `aggregationFn`, etc.
Row data. Required unless `table` is supplied. Each row is a plain object matching your column accessors.
"comfortable"Row height — `compact` tightens vertical padding.
Bindable container element reference.
Panel-inherited emphasis token — same values as Panel and Card.
"No data"Text shown when the row model is empty.
Wire per-column filtering + TanStack faceted row models (unique values / min-max). Implied by `showColumnFilters`.
falseEnable column resizing via TanStack.
falseOpt-in flag indicating columns can be shown/hidden (consumers render the toggle UI).
falseEnable expandable sub-rows. Usually paired with `getSubRows`.
trueEnable filtering (global filter + per-column `setFilterValue`).
falseEnable grouping by column. Usually paired with `enableExpanding` and `initialState.grouping`.
trueAllow multi-select. Defaults to true when selection is on.
trueEnable the pagination row model.
Enable row selection. Accepts `true`/`false` or a `(row) => boolean` predicate.
trueEnable sorting. Click headers to cycle asc/desc/none.
Panel-inherited feedback token — same values as Panel and Card.
Stable id for each row (recommended for selection persistence).
Resolver for nested rows. Return the children array for a given row.
Controlled global filter value.
"Search…"Placeholder for the global-filter input.
Optional custom header renderer.
Initial TanStack state — seed sorting, pagination, grouping, filters, etc.
Bindable reference to the live TanStack Table instance.
falseSwitch filtering to server-side mode — Pindoba won't re-process rows; supply the slice you want rendered.
falseSwitch pagination to server-side mode — Pindoba won't re-process rows; supply the slice you want rendered.
falseSwitch sorting to server-side mode — Pindoba won't re-process rows; supply the slice you want rendered.
Fires whenever TanStack state changes.
Panel-inherited padding token — same values as Panel and Card.
10Initial page size when pagination is on.
[10, 25, 50, 100]Options shown in the built-in page-size `<select>`.
Optional pagination renderer (replaces built-in pagination controls).
Per-slot style/attribute override bag — inject Panda styles or extra HTML attributes into any rendered slot.
Panel-inherited corner radius token — same values as Panel and Card.
Total row count (required with `manualPagination` for accurate page counts).
Panel-inherited shadow token — same values as Panel and Card.
falseRender the built-in per-column filter row (one input per filterable column). Implies `enableColumnFilters`.
falseRender the built-in global-filter `<input>` above the table.
Whether to render built-in pagination controls. Defaults to `enablePagination`. Set `false` to keep pagination active but render your own UI (use the `pagination` snippet).
"md"Cell padding and font-size variant.
Controlled state (merged with internal state). Pair with `onStateChange` for full external control.
falsePin the header to the top of the scroll container.
falseZebra-stripe even-indexed rows.
Advanced (static): pass a pre-built TanStack Table instance for SSR rendering. `data`/`columns`/feature flags are ignored and the table does NOT re-render on interaction. For interactive custom setups, prefer `tableOptions`.
Reactive escape hatch: consumer-authored TanStack `TableOptions` merged over Pindoba's defaults. The wrapper builds its reactive store from these, so sort/filter/paginate stay interactive. In Astro, function-valued options can't cross the SSR→client boundary (use inline `mountTable` for those).
Optional toolbar rendered above the table (replaces built-in filter).
Panel-inherited translucent surface flag — same values as Panel and Card.
Plus all standard <div> HTML
attributes.