component
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. 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 { 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}
/>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 { 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}
/>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 (getFacetedUniqueValues, getFacetedMinMaxValues) 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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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 { 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. 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.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 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 { 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" },
];
---
<!--
`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 { 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)
---
import Table from "../table.astro";
import Loading from "@pindoba/astro-loading";
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>
<!-- 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 { ColumnDef } from "@tanstack/table-core";
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: ColumnDef<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.