A structured way to present rows and columns of data. Pindoba’s Table wraps TanStack Table v8 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.
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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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}
/>Set pageSize to tune page size (default 10). pageSizeOptions controls the built-in page-size selector. To hide the controls but keep pagination, use showPagination={false}. To disable pagination altogether, use enablePagination={false}.
| 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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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}
/>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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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 (no Pindoba Progress component yet). The
`data-progress` attribute drives the filled-bar width via CSS calc. */
}
<template data-table-cell-template="progress">
<span class="progress-cell">
<span class="progress-track">
<span class="progress-fill" data-table-bind="row.progress"></span>
</span>
<span class="progress-value" data-table-bind="row.progress"></span>
</span>
</template>
</Table>
<style>
.progress-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.progress-track {
flex: 1;
min-width: 60px;
height: 6px;
border-radius: 9999px;
background: rgba(127, 127, 127, 0.2);
overflow: hidden;
}
.progress-fill {
display: block;
height: 100%;
background: var(--colors-primary-solid, #6366f1);
/* `data-table-bind` injects the numeric progress as textContent — that's
our signal for the width below. Hide it visually so the bar shows no
text. Keeping the text prevents a MutationObserver feedback loop
(clearing text would re-fire the observer, read "" back as 0, reset
the width). */
font-size: 0;
line-height: 0;
}
.progress-value {
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
}
.progress-value::after {
content: "%";
}
</style>
<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.getState().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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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 you can hide or show columns from outside the table.
instance.getAllLeafColumns() and wire each column’s getToggleVisibilityHandler() to a checkbox.toolbar slot with 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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<Metric>[] = [
{ accessorKey: "name", header: "Metric" },
{ accessorKey: "value", header: "Value" },
{ accessorKey: "change", header: "Change" },
{ accessorKey: "updated", header: "Updated" },
{ accessorKey: "owner", header: "Owner" },
];
// The toolbar lives inside the table root (via the `toolbar` slot) so
// mountTable's delegated `[data-table-column-toggle]` change handler picks
// up these checkboxes automatically.
const toggleableColumns = ["name", "value", "change", "updated", "owner"];
---
<Table
data={data}
columns={columns}
enableColumnVisibility
enablePagination={false}
caption="Toggle columns on and off"
>
<Fragment slot="toolbar">
<strong style="font-size: 0.875rem;">Toggle columns:</strong>
{
toggleableColumns.map((id) => (
<label style="display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
<input
type="checkbox"
checked
data-table-column-toggle={id}
aria-label={`Toggle ${id}`}
/>
{id}
</label>
))
}
</Fragment>
</Table>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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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. Use aggregationFn on the column definition to summarize children. 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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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)
| 1 | info | Log entry #1 | 2026-01-01T00:00:00.000Z |
| 2 | warn | Log entry #2 | 2025-12-31T23:59:00.000Z |
| 3 | error | Log entry #3 | 2025-12-31T23:58:00.000Z |
| 4 | info | Log entry #4 | 2025-12-31T23:57:00.000Z |
| 5 | warn | Log entry #5 | 2025-12-31T23:56:00.000Z |
| 6 | error | Log entry #6 | 2025-12-31T23:55:00.000Z |
| 7 | info | Log entry #7 | 2025-12-31T23:54:00.000Z |
| 8 | warn | Log entry #8 | 2025-12-31T23:53:00.000Z |
| 9 | error | Log entry #9 | 2025-12-31T23:52:00.000Z |
| 10 | info | Log entry #10 | 2025-12-31T23:51:00.000Z |
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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>
<Table
data={initialRows}
columns={columns}
manualPagination
manualSorting
rowCount={TOTAL_ROWS}
caption="Mock server-side pagination + sorting"
/>
</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]");
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…";
await new Promise((r) => setTimeout(r, 250));
mount.store.actions.setData(queryPage(pi, ps, srt));
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>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 toolbar | column.getToggleVisibilityHandler() | data-table-column-toggle="{id}" in toolbar slot |
| 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>.
| prop | type | default | req | description |
|---|---|---|---|---|
| data | TData[] | undefined | Row data. Required unless `table` is supplied. Each row is a plain object matching your column accessors. | |
| columns | ColumnDef<TData>[] | undefined | TanStack column definitions. Required unless `table` is supplied. Accepts `accessorKey`, `header`, `cell`, `enableSorting`, `enableGrouping`, `aggregationFn`, etc. | |
| table | Table<TData> | undefined | Advanced: pass a pre-built TanStack Table instance for SSR rendering. When supplied, `data`/`columns` and every feature flag are ignored; Pindoba only renders what the instance provides. (Interactive…
Advanced: pass a pre-built TanStack Table instance for SSR rendering. When supplied, `data`/`columns` and every feature flag are ignored; Pindoba only renders what the instance provides. (Interactive re-rendering from a consumer-owned state loop is not wired end-to-end yet — stick to the `data` + `columns` path for sort/filter/pagination interactivity.) Type Default Required | |
| enableSorting | boolean | true | Enable sorting. Click headers to cycle asc/desc/none. | |
| enableFiltering | boolean | true | Enable filtering (global filter + per-column `setFilterValue`). | |
| enablePagination | boolean | true | Enable pagination row model. | |
| showPagination | boolean | enablePagination | 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). | |
| pageSize | number | 10 | Initial page size when pagination is on. | |
| pageSizeOptions | number[] | [10, 25, 50, 100] | Options shown in the built-in page-size `<select>`. | |
| showGlobalFilter | boolean | false | Render the built-in global-filter `<input>` above the table. | |
| globalFilterPlaceholder | string | "Search…" | Placeholder for the global-filter input. | |
| enableRowSelection | boolean((row: Row<TData>) => boolean) | undefined | Enable row selection. Accepts `true`/`false` or a `(row) => boolean` predicate. | |
| enableMultiRowSelection | boolean | true | Allow multi-select. Defaults to true when selection is on. | |
| enableColumnVisibility | boolean | false | Opt-in flag indicating columns can be shown/hidden (consumers render the toggle UI). | |
| enableExpanding | boolean | false | Enable expandable sub-rows. Usually paired with `getSubRows`. | |
| enableGrouping | boolean | false | Enable grouping by column. Usually paired with `enableExpanding` and `initialState.grouping`. | |
| enableColumnResizing | boolean | false | Enable column resizing via TanStack. | |
| getSubRows | (row: TData, index: number) => TData[] | undefined | undefined | Resolver for nested rows. Return the children array for a given row. | |
| getRowId | (row, index, parent?) => string | undefined | Stable id for each row (recommended for selection persistence). | |
| manualPagination | manualSorting | manualFiltering | boolean | false | Switch to server-side mode — Pindoba won't re-process rows; supply the slice you want rendered. | |
| rowCount | number | undefined | Total row count (required with `manualPagination` for accurate page counts). | |
| initialState | Partial<TableState> | undefined | Initial TanStack state — seed sorting, pagination, grouping, filters, etc. | |
| state | Partial<TableState> | undefined | Controlled state (merged with internal state). Pair with `onStateChange` for full external control. | |
| onStateChange | OnChangeFn<TableState> | undefined | Fires whenever TanStack state changes. | |
| caption | string | undefined | Optional `<caption>` text rendered above the table rows. | |
| emptyMessage | string | "No data" | Text shown when the row model is empty. | |
| size | "sm""md""lg" | "md" | Cell padding and font-size variant. | |
| density | "comfortable""compact" | "comfortable" | Row height — `compact` tightens vertical padding. | |
| striped | boolean | false | Zebra-stripe even-indexed rows. | |
| bordered | boolean | false | Render vertical cell borders. | |
| stickyHeader | boolean | false | Pin the header to the top of the scroll container. | |
| background | feedback | padding | radius | border | shadow | PanelStyleProps | Panel defaults | Panel-inherited surface tokens — same values as Panel and Card. | |
| cell | Snippet<[TableCellContext<TData>]> | undefined | Svelte snippet that receives `{ value, row, columnId, cell, rowApi }` for custom cell rendering. | |
| header | Snippet<[TableHeaderContext<TData>]> | undefined | Svelte snippet that receives `{ header, columnId }` for custom header rendering. | |
| toolbar | Snippet<[{ table: Table<TData> }]> | undefined | Svelte snippet rendered above the table. Replaces the built-in global-filter input. | |
| pagination | Snippet<[{ table: Table<TData> }]> | undefined | Svelte snippet rendered below the table. Replaces the built-in pagination controls. | |
| instance | Table<TData>null | null | Bindable reference to the live TanStack Table instance. Use it to read/control state from outside. Svelte only. | |
| passThrough | TablePassThrough | undefined | Escape hatch to inject styles/attributes into any slot — includes all the new slots: `pagination`, `paginationButton`, `pageInfo`, `pageSizeSelect`, `toolbar`, `globalFilterInput`, `selectionCheckbox`…
Escape hatch to inject styles/attributes into any slot — includes all the new slots: `pagination`, `paginationButton`, `pageInfo`, `pageSizeSelect`, `toolbar`, `globalFilterInput`, `selectionCheckbox`, `expandToggle`, `groupCell`. Type Default Required |