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 hydrates the markup into a live TanStack instance on the client with the shared table runtime (tableHydration from @pindoba/astro-table/runtime) — 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.
---
// GENERATED from packages/demos/table/default.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Person {
name: string;
role: string;
location: string;
joined: string;
}
const data = [
{
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",
},
] as Person[];
const columns = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "location", header: "Location" },
{ accessorKey: "joined", header: "Joined" },
] as TableColumnDef<Person>[];
---
<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.
---
// GENERATED from packages/demos/table/no-caption.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Person {
name: string;
role: string;
location: string;
joined: string;
}
const data = [
{
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",
},
] as Person[];
const columns = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "location", header: "Location" },
{ accessorKey: "joined", header: "Joined" },
] as TableColumnDef<Person>[];
---
<Table data={data} columns={columns} />The table is card-based: it renders its own Card, and its chrome moves into the frame.
caption becomes the card heading. A visually hidden <caption> with the same text keeps naming the table for assistive tech; when the heading is rich content instead of a string, the table is aria-labelledby the heading.toolbar slot — moves to the header’s trailing slot, after any card.trailing you pass.card.footer.trailing.content.padding: "none"); cells keep their own padding and the header lines up with them.The table’s own surface props (background, border, radius…) are the card’s defaults, and a flush table card is divided so the header and footer keep their hairlines. Add contentBackground for the inset look — the table on its own panel inside a shell:
---
// GENERATED from packages/demos/table/card.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import Button from "@pindoba/astro-button";
import type { TableColumnDef } from "@pindoba/core-table";
interface Deployment {
service: string;
environment: "production" | "staging";
version: string;
status: "live" | "rolled back" | "building";
duration: string;
}
const services = ["billing-api", "search-indexer", "web-shell", "notifier"];
const statuses = [
"live",
"live",
"rolled back",
"building",
] as Deployment["status"][];
const data = Array.from({ length: 12 }, (_, i) => ({
service: services[i % services.length]!,
environment: i % 3 === 0 ? "staging" : "production",
version: `v2.${14 - Math.floor(i / 2)}.${i % 2}`,
status: statuses[i % statuses.length]!,
duration: `${2 + ((i * 7) % 9)}m ${(i * 13) % 60}s`,
})) as Deployment[];
const columns = [
{ accessorKey: "service", header: "Service" },
{ accessorKey: "environment", header: "Environment" },
{ accessorKey: "version", header: "Version" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "duration", header: "Duration" },
] as TableColumnDef<Deployment>[];
---
<Table
data={data}
columns={columns}
showGlobalFilter
globalFilterPlaceholder="Search releases…"
pageSize={5}
pageSizeOptions={[5, 10]}
card={{
heading: "Deployments",
subheading: "The last 12 releases across production and staging",
contentBackground: "surface.peak",
}}
>
<Button slot="card-trailing" size="sm" emphasis="primary">
New deployment
</Button>
</Table>passThrough.card is forwarded to the Card as its own passThrough (root, header, heading, content, footer…); the card prop’s own passThrough wins over it. The footer’s flanks, where the pagination sits, take card.footer.passThrough.leading / .trailing. On a narrow screen the pagination wraps inside its flank instead of being clipped. card={false} renders the bare table root with the pre-card DOM — toolbar above the table, pagination below — for a table inside a surface that already frames it.
<Table {data} {columns} card={false} />
Migrating from the pre-card table: the root is now the Card — select it with
[data-card-kind="table"]([data-component="table"]only matches withcard={false}). The<caption>is visually hidden, the toolbar and pagination render right-aligned in the card header and footer, the Svelteelementbinding is typedHTMLElement(it binds the card root), and in Astro the hydration attributes (data-table-hydrateand friends) live on the card root.
Bound the table’s height to scroll only the rows. The header row stays pinned (a bounded table turns stickyHeader on; pass stickyHeader={false} to opt out), and so do the card header and footer: the heading, toolbar, total and pagination never scroll out of view.
maxHeight caps the scroll area at a CSS length: "24rem", "calc(100dvh - 20rem)".fill stretches the table to its parent’s height. In a column-flex parent it takes the space its siblings leave, which is the full-page ledger case. The parent needs a definite height (or has to be a column flexbox that has one).fill="shrink" hugs the rows instead of stretching. A short table stays short, and in a column-flex parent with a definite height a long one stops at the space its siblings leave and scrolls its rows past it. Use it when the room left depends on the page, so maxHeight can’t be a fixed length.minHeight keeps the scroll area at least this tall, so a fill table in a short container never collapses to its header.With no rows, the empty state fills the scroll area with its message centred, so a fill table keeps its height instead of collapsing to a strip under the header. The empty row is never hovered or striped.
In card mode the scroll area is the card content. With card={false} the <table> gets its own scroll wrapper (passThrough.scroll) between the toolbar and the pagination.
The scroll area is a keyboard stop (tabindex="0"), because a wide table on a narrow screen overflows even when no cell is focusable, and a keyboard user can only scroll a region that takes focus. It’s a region named by the card heading (or, with card={false}, by the caption), and its focus ring is drawn inset so the card’s clipping never hides it. When every row already holds a focusable control, you can drop the extra stop with card={{ passThrough: { content: { props: { tabindex: -1 } } } }} (passThrough.scroll.props with card={false}).
---
// GENERATED from packages/demos/table/max-height.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Transaction {
date: string;
description: string;
category: string;
amount: string;
}
const descriptions = [
"Coffee beans",
"Office rent",
"Cloud hosting",
"Team lunch",
"Design tools",
"Client refund",
];
const categories = [
"Supplies",
"Rent",
"Software",
"Meals",
"Software",
"Income",
];
const data = Array.from({ length: 40 }, (_, i) => ({
date: `2026-09-${30 - Math.floor(i / 2)}`,
description: descriptions[i % descriptions.length]!,
category: categories[i % categories.length]!,
amount: `${i % 6 === 5 ? "+" : "−"}$${(12 + ((i * 37) % 480)).toFixed(2)}`,
})) as Transaction[];
const columns = [
{ accessorKey: "date", header: "Date" },
{ accessorKey: "description", header: "Description" },
{ accessorKey: "category", header: "Category" },
{ accessorKey: "amount", header: "Amount" },
] as TableColumnDef<Transaction>[];
---
<Table
data={data}
columns={columns}
maxHeight="18rem"
enablePagination={false}
card={{ heading: "Transactions", subheading: "September 2026" }}
/>The table below fills the rest of this 26rem panel.
---
// GENERATED from packages/demos/table/fill.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import { css } from "@pindoba/styled-system/css";
import type { TableColumnDef } from "@pindoba/core-table";
interface Transaction {
date: string;
description: string;
category: string;
amount: string;
}
const descriptions = [
"Coffee beans",
"Office rent",
"Cloud hosting",
"Team lunch",
"Design tools",
"Client refund",
];
const categories = [
"Supplies",
"Rent",
"Software",
"Meals",
"Software",
"Income",
];
const data = Array.from({ length: 40 }, (_, i) => ({
date: `2026-09-${30 - Math.floor(i / 2)}`,
description: descriptions[i % descriptions.length]!,
category: categories[i % categories.length]!,
amount: `${i % 6 === 5 ? "+" : "−"}$${(12 + ((i * 37) % 480)).toFixed(2)}`,
})) as Transaction[];
const columns = [
{ accessorKey: "date", header: "Date" },
{ accessorKey: "description", header: "Description" },
{ accessorKey: "category", header: "Category" },
{ accessorKey: "amount", header: "Amount" },
] as TableColumnDef<Transaction>[];
---
<div
class={css({
display: "flex",
flexDirection: "column",
gap: "sm",
height: "26rem",
})}
>
<p
class={css({
textStyle: "body.sm",
color: "colorPalette.text.muted",
margin: "0",
})}
>
The table below fills the rest of this 26rem panel.
</p>
<Table
data={data}
columns={columns}
fill
pageSize={25}
pageSizeOptions={[25, 50]}
card={{ heading: "Transactions", subheading: "September 2026" }}
/>
</div>3 rows: the card hugs them.
40 rows: only the rows scroll.
---
// GENERATED from packages/demos/table/fill-shrink.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import { css } from "@pindoba/styled-system/css";
import type { TableColumnDef } from "@pindoba/core-table";
interface Transaction {
date: string;
description: string;
category: string;
amount: string;
}
const descriptions = [
"Coffee beans",
"Office rent",
"Cloud hosting",
"Team lunch",
"Design tools",
"Client refund",
];
const categories = [
"Supplies",
"Rent",
"Software",
"Meals",
"Software",
"Income",
];
const data = Array.from({ length: 40 }, (_, i) => ({
date: `2026-09-${30 - Math.floor(i / 2)}`,
description: descriptions[i % descriptions.length]!,
category: categories[i % categories.length]!,
amount: `${i % 6 === 5 ? "+" : "−"}$${(12 + ((i * 37) % 480)).toFixed(2)}`,
})) as Transaction[];
const pending = data.slice(0, 3);
const panelClass = css({
display: "flex",
flexDirection: "column",
gap: "sm",
height: "40rem",
});
const noteClass = css({
textStyle: "body.sm",
color: "colorPalette.text.muted",
margin: "0",
});
const columns = [
{ accessorKey: "date", header: "Date" },
{ accessorKey: "description", header: "Description" },
{ accessorKey: "category", header: "Category" },
{ accessorKey: "amount", header: "Amount" },
] as TableColumnDef<Transaction>[];
---
<div
class={css({
display: "grid",
gridTemplateColumns: { base: "1fr", md: "1fr 1fr" },
gap: "md",
})}
>
<div class={panelClass}>
<p class={noteClass}>3 rows: the card hugs them.</p>
<Table
data={pending}
columns={columns}
fill="shrink"
card={{ heading: "Pending" }}
/>
</div>
<div class={panelClass}>
<p class={noteClass}>40 rows: only the rows scroll.</p>
<Table
data={data}
columns={columns}
fill="shrink"
card={{ heading: "Settled" }}
/>
</div>
</div><!-- A full-page ledger: the page is a column flexbox with a definite height -->
<Table
{data}
{columns}
fill
minHeight="16rem"
card={{ heading: "Transactions" }}
/>
striped fills alternate rows: true or "even" for even rows, "odd" for odd rows. The fill is the header’s raised surface step, so it follows whatever background you pick. stripeColor ("primary", "success", "warning", "danger") tints that step toward a tone colour.hoverable highlights the row under the pointer as one even band, edged top and bottom in the default border tier so it pops out. The same colour on striped and plain rows. By default (hoverBackground="overlay") it’s a translucent tint of the text colour laid over the table’s body colour, covering any stripe or tint, so every hovered row looks the same on any surface: lighter in dark mode, darker in light. To paint an exact colour instead, pass a surface step ("surface.peak", "surface.hill", "surface.base", "surface.valley", "surface.ground") or "raised", the hover step the table derives from its own background.stripedColumns lays a faint fill over every even column, which helps with wide numeric tables. It’s an overlay, so it combines with row stripes, tints and hover. Combined with striped it reads as a checkerboard, so on wide numeric tables pick one: row stripes to follow a record across, column stripes to scan a figure down.---
// GENERATED from packages/demos/table/striped.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Holding {
ticker: string;
name: string;
shares: number;
price: string;
change: string;
}
const names = [
"Acme Corp",
"Globex",
"Initech",
"Umbrella",
"Hooli",
"Stark Ind.",
];
const data = Array.from({ length: 8 }, (_, i) => ({
ticker: names[i % names.length]!.slice(0, 4).toUpperCase(),
name: names[i % names.length]!,
shares: 10 + ((i * 17) % 90),
price: `$${(40 + ((i * 53) % 260)).toFixed(2)}`,
change: `${i % 3 === 0 ? "−" : "+"}${((i * 7) % 5) + 0.4}%`,
})) as Holding[];
const columns = [
{ accessorKey: "ticker", header: "Ticker" },
{ accessorKey: "name", header: "Name" },
{ accessorKey: "shares", header: "Shares" },
{ accessorKey: "price", header: "Price" },
{ accessorKey: "change", header: "Change" },
] as TableColumnDef<Holding>[];
---
<Table
data={data}
columns={columns}
striped="odd"
stripeColor="primary"
hoverable
enablePagination={false}
card={{ heading: "Portfolio" }}
/>dividers picks which hairlines separate the cells: "rows" (the default), "columns", "grid" or "none". The rule under the header always stays. Every hairline uses the decorative border.muted tier; dividerTone="default" switches them to the stronger border tier. bordered still works as the old spelling of dividers="grid".
---
// GENERATED from packages/demos/table/dividers.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Holding {
ticker: string;
name: string;
shares: number;
price: string;
change: string;
}
const names = [
"Acme Corp",
"Globex",
"Initech",
"Umbrella",
"Hooli",
"Stark Ind.",
];
const data = Array.from({ length: 8 }, (_, i) => ({
ticker: names[i % names.length]!.slice(0, 4).toUpperCase(),
name: names[i % names.length]!,
shares: 10 + ((i * 17) % 90),
price: `$${(40 + ((i * 53) % 260)).toFixed(2)}`,
change: `${i % 3 === 0 ? "−" : "+"}${((i * 7) % 5) + 0.4}%`,
})) as Holding[];
const columns = [
{ accessorKey: "ticker", header: "Ticker" },
{ accessorKey: "name", header: "Name" },
{ accessorKey: "shares", header: "Shares" },
{ accessorKey: "price", header: "Price" },
{ accessorKey: "change", header: "Change" },
] as TableColumnDef<Holding>[];
---
<Table
data={data}
columns={columns}
striped
stripedColumns
dividers="grid"
dividerTone="default"
enablePagination={false}
card={{ heading: "Portfolio" }}
/>activeRowId marks the current row (a TanStack row id, so set getRowId to key rows by your own ids), such as the track that’s playing. That row gets data-row-active and aria-current="true", and its cells take the next step of the table’s own surface ramp with bold text. The fill covers any stripe, and the hoverable layer still lays over it.getRowProps(row, rowApi) adds attributes and handlers to each body <tr>: data-*, aria-*, tabindex, a class that merges with the row’s own, and DOM-named handlers such as ondblclick and onkeydown, written in lowercase in every framework. React, Vue and Solid rename them at the boundary. The row’s data-row-selected and data-row-active always win. In Astro the server render keeps the attributes and drops the handlers, which the client runtime wires up when it receives getRowProps.rowRadius ("xs" to "lg", or "full") rounds each body row’s fill, whether hover, active or stripe, at its first and last cell, using logical corners so RTL works too. It pairs best with dividers="none".meta.hideBelow hides it, header and cells together, while the table’s own width is under that many pixels: 400, 480, 560, 640, 720, 840, 960 or 1200. It’s a container query on the table’s scroll area, so it follows the table, not the viewport. Avoid it on the first or last column of a rowRadius table, because the rounded corner sits on that cell.---
// GENERATED from packages/demos/table/track-list.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Track {
id: string;
title: string;
album: string;
added: string;
duration: string;
}
const data = [
{
id: "t1",
title: "Low Tide",
album: "Harbour Lights",
added: "Mar 2, 2026",
duration: "3:41",
},
{
id: "t2",
title: "Paper Kites",
album: "Harbour Lights",
added: "Mar 2, 2026",
duration: "4:05",
},
{
id: "t3",
title: "Northbound",
album: "Long Way Round",
added: "Apr 18, 2026",
duration: "2:58",
},
{
id: "t4",
title: "Slow Signal",
album: "Long Way Round",
added: "May 9, 2026",
duration: "5:12",
},
{
id: "t5",
title: "Glass House",
album: "Quiet Rooms",
added: "Jun 30, 2026",
duration: "3:27",
},
] as Track[];
const columns = [
{ accessorKey: "title", header: "Title" },
{ accessorKey: "album", header: "Album", meta: { hideBelow: 560 } },
{ accessorKey: "added", header: "Date added", meta: { hideBelow: 840 } },
{ accessorKey: "duration", header: "Time" },
] as TableColumnDef<Track>[];
const playing = "t2";
---
<Table
data={data}
columns={columns}
getRowId={(row) => row.id}
activeRowId={playing}
getRowProps={(row) => ({
tabindex: 0,
"data-track-id": row.id,
})}
rowRadius="md"
dividers="none"
hoverable
enablePagination={false}
card={{ heading: "Playlist" }}
data-ir-root="table/track-list"
data-ir="1"
/>
<script>
import { hydrate } from "./_client/track-list.ts";
document
.querySelectorAll('[data-ir-root="table/track-list"]')
.forEach((root) => hydrate([root]));
</script>Sorting is on by default. To seed the initial sort, use initialState. To disable sorting entirely, set enableSorting={false}.
---
// GENERATED from packages/demos/table/sortable.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Row {
name: string;
stars: number;
language: string;
}
const data = [
{ 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" },
] as Row[];
const columns = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "stars", header: "★ Stars" },
{ accessorKey: "language", header: "Language" },
] as TableColumnDef<Row>[];
---
<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).
---
// GENERATED from packages/demos/table/pagination.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Order {
id: string;
customer: string;
total: string;
status: "pending" | "shipped" | "delivered" | "cancelled";
}
const statuses = [
"pending",
"shipped",
"delivered",
"cancelled",
] as Order["status"][];
const data = 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]!,
};
}) as Order[];
const columns = [
{ accessorKey: "id", header: "Order ID" },
{ accessorKey: "customer", header: "Customer" },
{ accessorKey: "total", header: "Total" },
{ accessorKey: "status", header: "Status" },
] as TableColumnDef<Order>[];
---
<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.
---
// GENERATED from packages/demos/table/filterable.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
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 = raw.map((p) => ({
...p,
downloadsFormatted: p.downloads.toLocaleString("en-US"),
})) as Package[];
const columns = [
{ accessorKey: "name", header: "Package" },
{ accessorKey: "version", header: "Version" },
{ accessorKey: "downloadsFormatted", header: "Weekly Downloads" },
{ accessorKey: "license", header: "License" },
] as TableColumnDef<Package>[];
---
<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.
---
// GENERATED from packages/demos/table/column-filters.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Employee {
name: string;
department: string;
location: string;
title: string;
}
const data = [
{
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",
},
] as Employee[];
const columns = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "department", header: "Department" },
{ accessorKey: "location", header: "Location" },
{ accessorKey: "title", header: "Title" },
] as TableColumnDef<Employee>[];
---
<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 the table runtime clones into every row, substituting data-table-bind="value" (or data-table-bind="row.<key>") text per row.
---
// GENERATED from packages/demos/table/custom-cell.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Task {
id: string;
title: string;
assignee: string;
// Mirror of the Astro data shape: the Astro templates can't derive
// per-row initials dynamically, so the field is baked into both demos'
// seed data for strict parity.
assigneeInitial: string;
priority: "low" | "medium" | "high";
// Numeric rank so Priority sorts semantically (high > medium > low) via
// the `meta.sortByField` convention — same as the Astro demo.
priorityRank: 1 | 2 | 3;
progress: number;
}
const data = [
{
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,
},
] as Task[];
const columns = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "title", header: "Task" },
{ accessorKey: "assignee", header: "Assignee" },
{
accessorKey: "priority",
header: "Priority",
meta: { sortByField: "priorityRank" },
},
{ accessorKey: "progress", header: "Progress" },
] as TableColumnDef<Task>[];
---
<Table
data={data}
columns={columns}
caption="Per-column slots compose Pindoba components with row-scoped bindings"
enablePagination={false}
data-ir-root="table/custom-cell"
data-ir="1"
/>
<script>
import { hydrate } from "./_client/custom-cell.ts";
document
.querySelectorAll('[data-ir-root="table/custom-cell"]')
.forEach((root) => hydrate([root]));
</script>Set selectionColumn and the table renders a leading column of checkboxes: one per row, and a select-all checkbox in the header. The other headers keep their sorting, and there are no cell / header renderers to write. It works in every framework, Astro included.
The header checkbox is tri-state: checked when every selectable row is selected, indeterminate when only some are, unchecked when none are. It covers the whole collection (every row that passes the filters, on every page) when all the data is in the client.
To disable some rows, pass a predicate to enableRowSelection; each row it rejects gets a disabled checkbox and the header skips it. Below, the owner can’t be selected.
With server-side data (manualPagination, or a rowCount larger than the loaded rows) the table can’t reach the rows it doesn’t have, so the header checkbox reflects and toggles the current page instead. Force either behaviour with selectionColumn={{ scope: "all" | "page" }}.
Each row’s checkbox is labelled Select row {row} using the first visible column’s value; selectionColumn={{ rowLabel }} takes a key of the row ("name") or a function (row) => string to name it better. The wording lives in labels (selectAll, selectPage, selectRow). Style the checkboxes and cells with passThrough.selectionCheckbox and passThrough.selectCell.
state={{ rowSelection }} + onRowSelectionChange, or read it from bind:instance.<script>, take the hydrated handle with tableHydration.get(el) (from @pindoba/astro-table/runtime) and read handle.table.store.state.rowSelection; handle.table.store.subscribe(…) runs on every change, and handle.update({ state: { rowSelection }, onRowSelectionChange }) controls it.0 of 5 selected
---
// GENERATED from packages/demos/table/selection.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import { css } from "@pindoba/styled-system/css";
import type { TableColumnDef } from "@pindoba/core-table";
interface Member {
id: string;
name: string;
email: string;
role: string;
}
const data = [
{ id: "m1", name: "Mira Okafor", email: "mira@acme.test", role: "Owner" },
{ id: "m2", name: "Teo Brandt", email: "teo@acme.test", role: "Editor" },
{ id: "m3", name: "Ines Varga", email: "ines@acme.test", role: "Editor" },
{ id: "m4", name: "Jonas Pike", email: "jonas@acme.test", role: "Viewer" },
{ id: "m5", name: "Lena Ruiz", email: "lena@acme.test", role: "Viewer" },
] as Member[];
const columns = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role" },
] as TableColumnDef<Member>[];
const wrap = css({ display: "flex", flexDirection: "column", gap: "0.75rem" });
const summary = css({ margin: "0", fontSize: "0.875rem" });
const rowSelection = {} as Record<string, true>;
const count = Object.keys(rowSelection).length;
---
<div class={wrap} data-ir-root="table/selection">
<p class={summary}>
<strong>
<!--ir:1-->
{String(count)}
<!--/ir-->
</strong>{" "}
of <!--ir:2-->
{String(data.length)}
<!--/ir--> selected
</p>
<Table
data={data}
columns={columns}
selectionColumn={{ rowLabel: "name" }}
enableRowSelection={(row: { original: Member }) =>
row.original.role !== "Owner"
}
enablePagination={false}
enableSorting={false}
getRowId={(row) => row.id}
state={{ rowSelection }}
caption="Team members, with a built-in selection column"
data-ir="3"
/>
</div>
<script>
import { hydrate } from "./_client/selection.ts";
document
.querySelectorAll('[data-ir-root="table/selection"]')
.forEach((root) => hydrate([root]));
</script>For checkboxes you draw yourself, set enableRowSelection and add a select column of your own.
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. The table runtime wires the change handlers and syncs checked state on every render.0 of 5 selected
---
// GENERATED from packages/demos/table/selectable.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import { css } from "@pindoba/styled-system/css";
import type { TableColumnDef, TableInstance } from "@pindoba/core-table";
interface User {
id: string;
name: string;
email: string;
role: string;
}
const data = [
{ 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",
},
] as User[];
const columns = [
{ id: "select", header: "", cell: () => "", enableSorting: false },
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role" },
] as TableColumnDef<User>[];
const wrap = css({ display: "flex", flexDirection: "column", gap: "0.75rem" });
const summary = css({ margin: "0", fontSize: "0.875rem" });
const version = 0;
const instance = undefined as TableInstance<User> | undefined;
const selectedCount =
version >= 0
? Object.keys(instance?.store.state.rowSelection ?? {}).length
: 0;
---
<div class={wrap} data-ir-root="table/selectable">
<p class={summary}>
<strong>
<!--ir:1-->
{String(selectedCount)}
<!--/ir-->
</strong>{" "}
of <!--ir:2-->
{String(data.length)}
<!--/ir--> selected
</p>
<Table
data={data}
columns={columns}
enableRowSelection
enablePagination={false}
enableSorting={false}
getRowId={(row) => row.id}
caption="Row selection via the `select` column"
data-ir="3"
/>
</div>
<script>
import { hydrate } from "./_client/selectable.ts";
document
.querySelectorAll('[data-ir-root="table/selectable"]')
.forEach((root) => hydrate([root]));
</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}". The table runtime’s delegated change listener handles the rest.---
// GENERATED from packages/demos/table/column-visibility.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Metric {
name: string;
value: number;
change: number;
updated: string;
owner: string;
}
const data = [
{
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",
},
] as Metric[];
const columns = [
{ accessorKey: "name", header: "Metric" },
{ accessorKey: "value", header: "Value" },
{ accessorKey: "change", header: "Change" },
{ accessorKey: "updated", header: "Updated" },
{ accessorKey: "owner", header: "Owner" },
] as TableColumnDef<Metric>[];
---
<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 — the table runtime 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.---
// GENERATED from packages/demos/table/expandable.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Folder {
name: string;
size: string;
type: string;
children?: Folder[];
}
const data = [
{
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" },
] as Folder[];
const columns = [
{
id: "expand",
header: "",
cell: () => "",
enableSorting: false,
},
{ accessorKey: "name", header: "Name" },
{ accessorKey: "size", header: "Size" },
{ accessorKey: "type", header: "Type" },
] as TableColumnDef<Folder>[];
---
<Table
data={data}
columns={columns}
enableExpanding
enablePagination={false}
getSubRows={(row) => row.children}
caption="Click › to expand folders"
data-ir-root="table/expandable"
data-ir="1"
/>
<script>
import { hydrate } from "./_client/expandable.ts";
document
.querySelectorAll('[data-ir-root="table/expandable"]')
.forEach((root) => hydrate([root]));
</script>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. The table runtime renders the grouping expand toggle and aggregated cells natively in both frameworks — no custom slot needed.
Custom aggregators must use the v9 context-based form. TanStack Table v9 replaced the legacy callable (columnId, leafRows, childRows) => result with constructAggregationFn({ aggregate, merge? }), typed as AggregationFnDef (the old AggregationFn and CreatedAggregationFn types are gone). Pass the definition through a column’s aggregationFn or tableOptions:
import { constructAggregationFn } from "@tanstack/table-core";
const average = constructAggregationFn({
aggregate: ({ rows, getValue }) => {
const total = rows.reduce((sum, row) => sum + Number(getValue(row)), 0);
return rows.length ? total / rows.length : undefined;
},
});
---
// GENERATED from packages/demos/table/grouped.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Sale {
region: string;
product: string;
quantity: number;
revenue: number;
}
const data = [
{ 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 },
] as Sale[];
const columns = [
{ accessorKey: "region", header: "Region", enableGrouping: true },
{ accessorKey: "product", header: "Product" },
{ accessorKey: "quantity", header: "Qty", aggregationFn: "sum" },
{ accessorKey: "revenue", header: "Revenue", aggregationFn: "sum" },
] as TableColumnDef<Sale>[];
---
<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.
onPaginationChange, onSortingChange, or the whole snapshot with onStateChange — and re-fetch; swap the rows in with the data prop (see Controlled state to own a slice outright).<script>, watch the live store with tableHydration.get(el)?.table.store.subscribe(…) and, once the request resolves, swap the rows in with tableHydration.get(el)?.update({ data: nextRows, rowCount }).120 total rows (server-paginated)
---
// GENERATED from packages/demos/table/server-side.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import Loading from "@pindoba/astro-loading";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const TOTAL_ROWS = 120;
const LEVELS = ["info", "warn", "error"] as const;
const ALL_ROWS = 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 queryPage = (
pi: number,
ps: number,
sort?: { id: string; desc: boolean },
) => {
const sorted = ALL_ROWS.slice();
if (sort) {
const comparable = (row: (typeof ALL_ROWS)[number]) => {
const v = (row as Record<string, unknown>)[sort.id];
return typeof v === "number" ? v : String(v ?? "");
};
sorted.sort((a, b) => {
const va = comparable(a);
const vb = comparable(b);
if (va < vb) return sort.desc ? 1 : -1;
if (va > vb) return sort.desc ? -1 : 1;
return 0;
});
}
return sorted.slice(pi * ps, pi * ps + ps);
};
const columns = [
{ accessorKey: "id", header: "#" },
{ accessorKey: "level", header: "Level" },
{ accessorKey: "message", header: "Message" },
{ accessorKey: "timestamp", header: "Timestamp" },
];
const rows = queryPage(0, 10);
const loading = false;
---
<div
class={stack({ direction: "column", gap: "0.5rem" })}
data-ir-root="table/server-side"
>
<p class={css({ margin: 0, fontSize: "0.875rem" })}>
<!--ir:1-->
{loading ? "Loading…" : `${TOTAL_ROWS} total rows (server-paginated)`}
<!--/ir-->
</p>
<Loading loading={loading} variant="busy" data-ir="2">
<Table
data={rows}
columns={columns}
manualPagination
manualSorting
rowCount={TOTAL_ROWS}
caption="Mock server-side pagination + sorting"
data-ir="3"
/>
</Loading>
</div>
<script>
import { hydrate } from "./_client/server-side.ts";
document
.querySelectorAll('[data-ir-root="table/server-side"]')
.forEach((root) => hydrate([root]));
</script>The table owns its state by default: sorting, pagination, filters, selection, expansion, grouping, column visibility and sizing. Seed any slice with initialState (it is also what a reset returns to) and the table moves it from there.
To own a slice yourself, pass it in state and listen to its on<Slice>Change callback — onSortingChange, onPaginationChange, onRowSelectionChange, onColumnFiltersChange, onGlobalFilterChange, onExpandedChange, onColumnVisibilityChange, onGroupingChange, onColumnSizingChange. A controlled slice always shows what you pass: an interaction only reports the next value through the callback, and the table moves once you pass that value back. A controlled slice with no callback stays where it is (TanStack’s rule). Slices you don’t put in state stay internal, and their callbacks still fire as observers. onStateChange observes the whole snapshot after every change.
<script lang="ts">
let sorting = $state([{ id: "age", desc: true }]);
</script>
<Table
{data}
{columns}
state={{ sorting }}
onSortingChange={(next) => (sorting = next)}
/>
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
<Table
data={data}
columns={columns}
state={{ pagination }}
onPaginationChange={setPagination}
/>;
In Astro the server render shows the controlled slices, and the callbacks reach the hydrated table through tableHydration.get(el)?.update({ state, onSortingChange }) — functions can’t cross the server boundary.
Every table registers TanStack’s full filterFns, sortFns and aggregationFns, so a column can name any built-in (filterFn: "inNumberRange", sortFn: "datetime", aggregationFn: "mean"). To ship only what you use, pass the registries yourself — the built-ins are exported one by one from @tanstack/table-core, and custom functions register by name:
import { filterFn_includesString, sortFn_datetime } from "@tanstack/table-core";
<Table
data={data}
columns={[
{ accessorKey: "name", filterFn: "includesString" },
{ accessorKey: "joined", sortFn: "datetime" },
]}
filterFns={{ includesString: filterFn_includesString }}
sortFns={{ datetime: sortFn_datetime }}
/>;
The registries also narrow the column types: a column naming a function the table didn’t register (filterFn: "startsWith" above) is a compile error. Columns written inline are checked automatically; a column array declared on its own takes the table’s feature set, TableColumnDef<Row, unknown, PindobaTableFeatures<typeof filterFns, typeof sortFns>>. Astro keeps the full registries (its config is serialized to the client).
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, hand them to the hydrated table with tableHydration.get(el)?.update({ … }) 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.
---
// GENERATED from packages/demos/table/headless.demo.tsx — edit the source, not this file.
import Table from "@pindoba/astro-table";
import type { TableColumnDef } from "@pindoba/core-table";
interface Contact {
firstName: string;
lastName: string;
email: string;
balance: number;
status: "active" | "pending" | "overdue";
}
const data = [
{
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",
},
] as Contact[];
const columns = [
{
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" },
],
},
] as TableColumnDef<Contact>[];
---
<Table
data={data}
columns={columns}
tableOptions={{ sortDescFirst: true }}
bordered
caption="Grouped headers + a consumer TanStack option (sortDescFirst) — interactive via the reactive escape hatch"
/>Every string the table renders — the empty message, the filter inputs’ aria-labels and placeholders, the Columns menu trigger, the grouped-row expand/collapse labels and Engineering (4) readout — is configurable through the labels prop, and labels.pagination is forwarded to the built-in pagination footer (see Pagination → Localization). Omitted keys keep the English defaults.
Labels built from runtime values accept either a function of those values or a {placeholder} template string, so languages with a different word order work:
const labels: TableLabels = {
empty: "Nenhum resultado", // default "No data"
globalFilter: "Filtrar linhas", // default "Filter rows" (aria-label)
globalFilterPlaceholder: "Buscar…", // default "Search…"
columnFilter: ({ column }) => `Filtrar ${column}`, // default "Filter {column}"
columnFilterPlaceholder: "Filtrar…", // default "Filter…"
columns: "Colunas", // default "Columns"
expand: "Expandir", // default "Expand"
collapse: "Recolher", // default "Collapse"
group: "{value} ({count} itens)", // default "{value} ({count})"
pagination: {
previous: { text: "Anterior" },
next: { text: "Próxima" },
page: { text: "Página" },
itemsPerPage: { text: "Itens por página" },
summary: "{start}–{end} de {total}",
pageOf: "de {total}",
},
};
| Key | Values | Default |
|---|---|---|
empty | — | "No data" |
globalFilter | — | "Filter rows" |
globalFilterPlaceholder | — | "Search…" |
columnFilter | column, columnId | "Filter {column}" |
columnFilterPlaceholder | column, columnId | "Filter…" |
columns | — | "Columns" |
expand / collapse | — | "Expand" / "Collapse" |
group | value, count | "{value} ({count})" |
pagination | see Pagination | — |
The older emptyMessage, globalFilterPlaceholder and columnFilterPlaceholder props still work and win over their labels counterparts. In Astro, use template strings for dynamic labels: the table hydrates on the client and functions can’t cross the SSR → client boundary, so a function label would fall back to English after the first interaction. The resolved strings are also available headlessly as connectTable(...).labels.
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) / tableHydration.get(el)?.update() |
| Async / server-side data | on<Slice>Change / onStateChange | table.store.subscribe + tableHydration.get(el)?.update({ data }) |
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, pass a cell renderer through tableHydration.get(el)?.update({ cell }) from an inline <script> — function props like cell and onStateChange can’t cross the server boundary, so they go through update() after the table hydrates.
Panda with jsxFramework. Once a project’s panda.config sets
jsxFramework, Panda reads the props of every capitalised component as
style props, so it can follow styled() wrappers. columns is also a CSS
property, so an inline columns={[…]} makes Panda warn (“columns in
columns.base.id is not a condition or selector”) and emit a rule that does
nothing. Set jsxStyleProps: "minimal" in that config: Panda then reads only
the css prop. A Svelte project loses nothing by it, since Panda generates
no style-prop components for Svelte.