component

Table

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.

How to use it

Two levels of configuration, pick whichever fits:

  1. Zero-config: pass data and columns. You get sortable headers, built-in pagination controls, and a ready-to-filter row model automatically.
  2. Opt-in features: set enableRowSelection, enableColumnVisibility, enableExpanding, enableGrouping, manualPagination, etc. to layer in advanced behaviors.

Default

Sorting and pagination are active by default. Click any header to cycle through ascending, descending, and unsorted.

Pioneers — sorting and pagination on by default

Pioneers — sorting and pagination on by default
Ada LovelaceEngineerLondon1843
Alan TuringMathematicianManchester1936
Grace HopperAdmiralNew York1944
Katherine JohnsonMathematicianHampton1953
Hedy LamarrInventorVienna1942
Claude ShannonEngineerMichigan1948
John von NeumannMathematicianBudapest1945
Margaret HamiltonEngineerBoston1961
Dennis RitchieEngineerNew Jersey1972
Barbara LiskovResearcherCalifornia1968
1 - 10 / 15
/ 2
---
// 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"
/>

Without caption

Omit the caption prop when the table sits inside a section that already provides its own heading.

Ada LovelaceEngineerLondon1843
Alan TuringMathematicianManchester1936
Grace HopperAdmiralNew York1944
Katherine JohnsonMathematicianHampton1953
Hedy LamarrInventorVienna1942
Claude ShannonEngineerMichigan1948
John von NeumannMathematicianBudapest1945
Margaret HamiltonEngineerBoston1961
Dennis RitchieEngineerNew Jersey1972
Barbara LiskovResearcherCalifornia1968
1 - 10 / 15
/ 2
---
// 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} />

Card

The table is card-based: it renders its own Card, and its chrome moves into the frame.

  • The 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.
  • The toolbar — the global filter, the column-visibility menu and the toolbar slot — moves to the header’s trailing slot, after any card.trailing you pass.
  • The pagination moves to the footer’s trailing slot, after any card.footer.trailing.
  • The table’s scroll area is the card content, edge to edge (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:

Deployments

The last 12 releases across production and staging

Deployments
billing-apistagingv2.14.0live2m 0s
search-indexerproductionv2.14.1live9m 13s
web-shellproductionv2.13.0rolled back7m 26s
notifierstagingv2.13.1building5m 39s
billing-apiproductionv2.12.0live3m 52s
1 - 5 / 12
/ 3
---
// 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 with card={false}). The <caption> is visually hidden, the toolbar and pagination render right-aligned in the card header and footer, the Svelte element binding is typed HTMLElement (it binds the card root), and in Astro the hydration attributes (data-table-hydrate and friends) live on the card root.

Scrolling body

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}).

Transactions

September 2026

Transactions
2026-09-30Coffee beansSupplies−$12.00
2026-09-30Office rentRent−$49.00
2026-09-29Cloud hostingSoftware−$86.00
2026-09-29Team lunchMeals−$123.00
2026-09-28Design toolsSoftware−$160.00
2026-09-28Client refundIncome+$197.00
2026-09-27Coffee beansSupplies−$234.00
2026-09-27Office rentRent−$271.00
2026-09-26Cloud hostingSoftware−$308.00
2026-09-26Team lunchMeals−$345.00
2026-09-25Design toolsSoftware−$382.00
2026-09-25Client refundIncome+$419.00
2026-09-24Coffee beansSupplies−$456.00
2026-09-24Office rentRent−$13.00
2026-09-23Cloud hostingSoftware−$50.00
2026-09-23Team lunchMeals−$87.00
2026-09-22Design toolsSoftware−$124.00
2026-09-22Client refundIncome+$161.00
2026-09-21Coffee beansSupplies−$198.00
2026-09-21Office rentRent−$235.00
2026-09-20Cloud hostingSoftware−$272.00
2026-09-20Team lunchMeals−$309.00
2026-09-19Design toolsSoftware−$346.00
2026-09-19Client refundIncome+$383.00
2026-09-18Coffee beansSupplies−$420.00
2026-09-18Office rentRent−$457.00
2026-09-17Cloud hostingSoftware−$14.00
2026-09-17Team lunchMeals−$51.00
2026-09-16Design toolsSoftware−$88.00
2026-09-16Client refundIncome+$125.00
2026-09-15Coffee beansSupplies−$162.00
2026-09-15Office rentRent−$199.00
2026-09-14Cloud hostingSoftware−$236.00
2026-09-14Team lunchMeals−$273.00
2026-09-13Design toolsSoftware−$310.00
2026-09-13Client refundIncome+$347.00
2026-09-12Coffee beansSupplies−$384.00
2026-09-12Office rentRent−$421.00
2026-09-11Cloud hostingSoftware−$458.00
2026-09-11Team lunchMeals−$15.00
---
// 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.

Transactions

September 2026

Transactions
2026-09-30Coffee beansSupplies−$12.00
2026-09-30Office rentRent−$49.00
2026-09-29Cloud hostingSoftware−$86.00
2026-09-29Team lunchMeals−$123.00
2026-09-28Design toolsSoftware−$160.00
2026-09-28Client refundIncome+$197.00
2026-09-27Coffee beansSupplies−$234.00
2026-09-27Office rentRent−$271.00
2026-09-26Cloud hostingSoftware−$308.00
2026-09-26Team lunchMeals−$345.00
2026-09-25Design toolsSoftware−$382.00
2026-09-25Client refundIncome+$419.00
2026-09-24Coffee beansSupplies−$456.00
2026-09-24Office rentRent−$13.00
2026-09-23Cloud hostingSoftware−$50.00
2026-09-23Team lunchMeals−$87.00
2026-09-22Design toolsSoftware−$124.00
2026-09-22Client refundIncome+$161.00
2026-09-21Coffee beansSupplies−$198.00
2026-09-21Office rentRent−$235.00
2026-09-20Cloud hostingSoftware−$272.00
2026-09-20Team lunchMeals−$309.00
2026-09-19Design toolsSoftware−$346.00
2026-09-19Client refundIncome+$383.00
2026-09-18Coffee beansSupplies−$420.00
1 - 25 / 40
/ 2
---
// 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.

Pending

Pending
2026-09-30Coffee beansSupplies−$12.00
2026-09-30Office rentRent−$49.00
2026-09-29Cloud hostingSoftware−$86.00
1 - 3 / 3
/ 1

40 rows: only the rows scroll.

Settled

Settled
2026-09-30Coffee beansSupplies−$12.00
2026-09-30Office rentRent−$49.00
2026-09-29Cloud hostingSoftware−$86.00
2026-09-29Team lunchMeals−$123.00
2026-09-28Design toolsSoftware−$160.00
2026-09-28Client refundIncome+$197.00
2026-09-27Coffee beansSupplies−$234.00
2026-09-27Office rentRent−$271.00
2026-09-26Cloud hostingSoftware−$308.00
2026-09-26Team lunchMeals−$345.00
1 - 10 / 40
/ 4
---
// 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" }}
/>

Stripes, hover and dividers

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

Portfolio

Portfolio
ACMEAcme Corp10$40.00−0.4%
GLOBGlobex27$93.00+2.4%
INITInitech44$146.00+4.4%
UMBRUmbrella61$199.00−1.4%
HOOLHooli78$252.00+3.4%
STARStark Ind.95$45.00+0.4%
ACMEAcme Corp22$98.00−2.4%
GLOBGlobex39$151.00+4.4%
---
// 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".

Portfolio

Portfolio
ACMEAcme Corp10$40.00−0.4%
GLOBGlobex27$93.00+2.4%
INITInitech44$146.00+4.4%
UMBRUmbrella61$199.00−1.4%
HOOLHooli78$252.00+3.4%
STARStark Ind.95$45.00+0.4%
ACMEAcme Corp22$98.00−2.4%
GLOBGlobex39$151.00+4.4%
---
// 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" }}
/>

Active rows, row props and responsive columns

  • 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".
  • A column’s 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.

Playlist

Playlist
Low TideHarbour LightsMar 2, 20263:41
Paper KitesHarbour LightsMar 2, 20264:05
NorthboundLong Way RoundApr 18, 20262:58
Slow SignalLong Way RoundMay 9, 20265:12
Glass HouseQuiet RoomsJun 30, 20263:27
---
// 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>

Sortable

Sorting is on by default. To seed the initial sort, use initialState. To disable sorting entirely, set enableSorting={false}.

Click any header to sort (ascending, descending, unsorted)

Click any header to sort (ascending, descending, unsorted)
Framework B48000JavaScript
Framework E31200Python
Framework D23500Go
Framework A12000TypeScript
Framework C7400Rust
---
// 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}
/>

Pagination

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

47 orders — paginated 10 per page

47 orders — paginated 10 per page
ORD-1000Customer 1$1.74pending
ORD-1001Customer 2$3.48shipped
ORD-1002Customer 3$5.22delivered
ORD-1003Customer 4$6.96cancelled
ORD-1004Customer 5$8.70pending
ORD-1005Customer 6$10.43shipped
ORD-1006Customer 7$12.17delivered
ORD-1007Customer 8$13.91cancelled
ORD-1008Customer 9$15.65pending
ORD-1009Customer 10$17.39shipped
1 - 10 / 47
/ 5
---
// 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
/>

Filtering

Enable the built-in global filter with showGlobalFilter. The input is wired to table.setGlobalFilter and filters across all columns.

Type in the search box to filter rows across all columns

Type in the search box to filter rows across all columns
react19.0.028,500,000MIT
vue3.5.05,900,000MIT
svelte5.0.01,200,000MIT
angular18.0.03,400,000MIT
solid-js1.8.0180,000MIT
1 - 5 / 10
/ 2
---
// 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}
/>

Per-column filters

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.

Per-column filtering — type in any column header input

Per-column filtering — type in any column header input
Ada LovelaceEngineeringLondonPrincipal
Grace HopperEngineeringNew YorkDistinguished
Katherine JohnsonResearchHamptonLead
Alan TuringResearchLondonPrincipal
Linus TorvaldsPlatformPortlandFellow
Margaret HamiltonPlatformBostonDirector
---
// 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"
/>

Custom cells

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.

Per-column slots compose Pindoba components with row-scoped bindings

Per-column slots compose Pindoba components with row-scoped bindings
T-1Design reviewAdahigh80
T-2Write testsGracemedium45
T-3Update docsAlanlow100
---
// 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>

Row selection

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.

  • Svelte / React / Vue / Solid: own the selection with state={{ rowSelection }} + onRowSelectionChange, or read it from bind:instance.
  • Astro: from an inline <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

Team members, with a built-in selection column

Team members, with a built-in selection column
NameEmailRole
Mira Okaformira@acme.testOwner
Teo Brandtteo@acme.testEditor
Ines Vargaines@acme.testEditor
Jonas Pikejonas@acme.testViewer
Lena Ruizlena@acme.testViewer
---
// 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>

Custom selection column

For checkboxes you draw yourself, set enableRowSelection and add a select column of your own.

  • Svelte: pair with bind:instance to read instance.store.state.rowSelection from outside the table; use the cell snippet to render checkboxes wired to rowApi.getToggleSelectedHandler().
  • Astro: drop a checkbox into the 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

Row selection via the `select` column

Row selection via the `select` column
NameEmailRole
Ada Lovelaceada@example.comAdmin
Alan Turingalan@example.comMember
Grace Hoppergrace@example.comAdmin
Linus Torvaldslinus@example.comMember
Donald Knuthdonald@example.comMember
---
// 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>

Column visibility

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):

  • Svelte: iterate instance.getAllLeafColumns() and wire each column’s getToggleVisibilityHandler() to a checkbox.
  • Astro: render the toggles with data-table-column-toggle="{columnId}". The table runtime’s delegated change listener handles the rest.

Toggle columns on and off

Toggle columns on and off
Revenue1280005.42h agoFinance
Active users42100-1.25m agoGrowth
Conversion3.80.31h agoProduct
Churn2.1-0.43h agoRetention
---
// 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"
/>

Expandable rows

Pass enableExpanding + getSubRows to render hierarchical data.

  • Svelte: wire your toggle button to rowApi.getToggleExpandedHandler() inside the cell snippet.
  • Astro: drop a <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.

Click › to expand folders

Click › to expand folders
src—folder
docs—folder
package.json1.1 KBfile
---
// 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>

Grouped rows

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;
  },
});

Sales grouped by region (click › to collapse)

Sales grouped by region (click › to collapse)
North (3)25710
Widget12240
Gizmo8320
Sprocket5150
South (2)34960
Widget20400
Gizmo14560
East (2)20510
Widget9180
Sprocket11330
West (2)23850
Gizmo16640
Sprocket7210
---
// 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
/>

Server-side data

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.

  • Svelte / React / Vue / Solid: observe the slices you fetch by — 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).
  • Astro: from an inline <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)

Mock server-side pagination + sorting

Mock server-side pagination + sorting
1infoLog entry #12026-01-01T00:00:00.000Z
2warnLog entry #22025-12-31T23:59:00.000Z
3errorLog entry #32025-12-31T23:58:00.000Z
4infoLog entry #42025-12-31T23:57:00.000Z
5warnLog entry #52025-12-31T23:56:00.000Z
6errorLog entry #62025-12-31T23:55:00.000Z
7infoLog entry #72025-12-31T23:54:00.000Z
8warnLog entry #82025-12-31T23:53:00.000Z
9errorLog entry #92025-12-31T23:52:00.000Z
10infoLog entry #102025-12-31T23:51:00.000Z
1 - 10 / 120
/ 12
---
// 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>

Controlled state

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.

Function registries

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

Escape hatch (custom TanStack options)

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.

Grouped headers + a consumer TanStack option (sortDescFirst) — interactive via the reactive escape hatch

Grouped headers + a consumer TanStack option (sortDescFirst) — interactive via the reactive escape hatch
ContactAccount
DonaldKnuthdonald@example.com21040active
AdaLovelaceada@example.com12450active
AlanTuringalan@example.com8300active
LinusTorvaldslinus@example.com5670pending
GraceHoppergrace@example.com-230overdue
1 - 5 / 5
/ 1
---
// 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"
/>

Localization

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}",
  },
};
KeyValuesDefault
empty—"No data"
globalFilter—"Filter rows"
globalFilterPlaceholder—"Search…"
columnFiltercolumn, columnId"Filter {column}"
columnFilterPlaceholdercolumn, columnId"Filter…"
columns—"Columns"
expand / collapse—"Expand" / "Collapse"
groupvalue, count"{value} ({count})"
paginationsee 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.

Framework notes

Both frameworks reach feature parity through the same shared core. The author-time API is the only thing that differs:

NeedSvelteAstro
Custom cell content{#snippet cell({ … })}<slot name="cell-{id}"> + data-table-bind
Custom header content{#snippet header({ … })}<slot name="header-{id}">
Row selection togglerowApi.getToggleSelectedHandler()data-table-select-row / data-table-select-all
Expand/collapse togglerowApi.getToggleExpandedHandler()data-table-expand
Depth indentationstyle="padding-left: …"data-table-indent
Column visibilitybuilt-in menu (enableColumnVisibility)built-in menu; or data-table-column-toggle="{id}"
Per-column filtersbuilt-in row (showColumnFilters)built-in row; or data-table-column-filter="{id}"
Custom TanStack optionstableOptions (reactive)tableOptions (serializable) / tableHydration.get(el)?.update()
Async / server-side dataon<Slice>Change / onStateChangetable.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.

  • Components
  • Blocks