component

Table

A structured way to present rows and columns of data. Pindoba’s Table wraps TanStack Table v8 and ships with sensible defaults — sorting, filtering, and pagination are on out of the box — while still exposing the full TanStack feature set (row selection, column visibility, expandable rows, grouping, server-side mode) for when you need more.

Both the Astro and Svelte components share a single state core (createTableStore in @pindoba/core-table, backed by nanostores). The Svelte component subscribes reactively; the Astro component SSR-renders the first page and ships a tiny mountTable script that hydrates the markup into a live TanStack instance on the client — no framework island required. Every advanced feature (row selection, column visibility, expandable rows, grouping, server-side mode) works in both frameworks; the only difference is how you author per-cell content (Svelte snippets vs. Astro <template> slots).

The table inherits its surface (background, border, radius, shadow) from Panel, so the same design tokens apply. Interior surfaces — the header, footer, filter rows, group cells, and striped rows — automatically pick a contrasting step on the surface ramp for whichever background you choose, so the header never blends into the body.

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
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
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Person {
  name: string;
  role: string;
  location: string;
  joined: string;
}

const data: Person[] = [
  {
    name: "Ada Lovelace",
    role: "Engineer",
    location: "London",
    joined: "1843",
  },
  {
    name: "Alan Turing",
    role: "Mathematician",
    location: "Manchester",
    joined: "1936",
  },
  {
    name: "Grace Hopper",
    role: "Admiral",
    location: "New York",
    joined: "1944",
  },
  {
    name: "Katherine Johnson",
    role: "Mathematician",
    location: "Hampton",
    joined: "1953",
  },
  { name: "Hedy Lamarr", role: "Inventor", location: "Vienna", joined: "1942" },
  {
    name: "Claude Shannon",
    role: "Engineer",
    location: "Michigan",
    joined: "1948",
  },
  {
    name: "John von Neumann",
    role: "Mathematician",
    location: "Budapest",
    joined: "1945",
  },
  {
    name: "Margaret Hamilton",
    role: "Engineer",
    location: "Boston",
    joined: "1961",
  },
  {
    name: "Dennis Ritchie",
    role: "Engineer",
    location: "New Jersey",
    joined: "1972",
  },
  {
    name: "Barbara Liskov",
    role: "Researcher",
    location: "California",
    joined: "1968",
  },
  {
    name: "Donald Knuth",
    role: "Author",
    location: "Stanford",
    joined: "1968",
  },
  {
    name: "Linus Torvalds",
    role: "Engineer",
    location: "Helsinki",
    joined: "1991",
  },
  {
    name: "Radia Perlman",
    role: "Engineer",
    location: "Virginia",
    joined: "1985",
  },
  {
    name: "Tim Berners-Lee",
    role: "Engineer",
    location: "Geneva",
    joined: "1989",
  },
  {
    name: "Anita Borg",
    role: "Researcher",
    location: "California",
    joined: "1986",
  },
];

const columns: ColumnDef<Person>[] = [
  { accessorKey: "name", header: "Name" },
  { accessorKey: "role", header: "Role" },
  { accessorKey: "location", header: "Location" },
  { accessorKey: "joined", header: "Joined" },
];
---

<Table
  data={data}
  columns={columns}
  caption="Pioneers — sorting and pagination on by default"
/>

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
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Person {
  name: string;
  role: string;
  location: string;
  joined: string;
}

const data: Person[] = [
  {
    name: "Ada Lovelace",
    role: "Engineer",
    location: "London",
    joined: "1843",
  },
  {
    name: "Alan Turing",
    role: "Mathematician",
    location: "Manchester",
    joined: "1936",
  },
  {
    name: "Grace Hopper",
    role: "Admiral",
    location: "New York",
    joined: "1944",
  },
  {
    name: "Katherine Johnson",
    role: "Mathematician",
    location: "Hampton",
    joined: "1953",
  },
  { name: "Hedy Lamarr", role: "Inventor", location: "Vienna", joined: "1942" },
  {
    name: "Claude Shannon",
    role: "Engineer",
    location: "Michigan",
    joined: "1948",
  },
  {
    name: "John von Neumann",
    role: "Mathematician",
    location: "Budapest",
    joined: "1945",
  },
  {
    name: "Margaret Hamilton",
    role: "Engineer",
    location: "Boston",
    joined: "1961",
  },
  {
    name: "Dennis Ritchie",
    role: "Engineer",
    location: "New Jersey",
    joined: "1972",
  },
  {
    name: "Barbara Liskov",
    role: "Researcher",
    location: "California",
    joined: "1968",
  },
  {
    name: "Donald Knuth",
    role: "Author",
    location: "Stanford",
    joined: "1968",
  },
  {
    name: "Linus Torvalds",
    role: "Engineer",
    location: "Helsinki",
    joined: "1991",
  },
  {
    name: "Radia Perlman",
    role: "Engineer",
    location: "Virginia",
    joined: "1985",
  },
  {
    name: "Tim Berners-Lee",
    role: "Engineer",
    location: "Geneva",
    joined: "1989",
  },
  {
    name: "Anita Borg",
    role: "Researcher",
    location: "California",
    joined: "1986",
  },
];

const columns: ColumnDef<Person>[] = [
  { accessorKey: "name", header: "Name" },
  { accessorKey: "role", header: "Role" },
  { accessorKey: "location", header: "Location" },
  { accessorKey: "joined", header: "Joined" },
];
---

<Table data={data} columns={columns} />

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)
Framework B48000JavaScript
Framework E31200Python
Framework D23500Go
Framework A12000TypeScript
Framework C7400Rust
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Row {
  name: string;
  stars: number;
  language: string;
}

const data: Row[] = [
  { name: "Framework A", stars: 12000, language: "TypeScript" },
  { name: "Framework B", stars: 48000, language: "JavaScript" },
  { name: "Framework C", stars: 7400, language: "Rust" },
  { name: "Framework D", stars: 23500, language: "Go" },
  { name: "Framework E", stars: 31200, language: "Python" },
];

const columns: ColumnDef<Row>[] = [
  { accessorKey: "name", header: "Name" },
  { accessorKey: "stars", header: "★ Stars" },
  { accessorKey: "language", header: "Language" },
];
---

<Table
  data={data}
  columns={columns}
  striped
  caption="Click any header to sort (ascending, descending, unsorted)"
  initialState={{ sorting: [{ id: "stars", desc: true }] }}
  enablePagination={false}
/>

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
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
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Order {
  id: string;
  customer: string;
  total: string;
  status: "pending" | "shipped" | "delivered" | "cancelled";
}

const statuses: Order["status"][] = [
  "pending",
  "shipped",
  "delivered",
  "cancelled",
];

// Deterministic totals for SSR: avoids hydration mismatches from Math.random.
const data: Order[] = Array.from({ length: 47 }, (_, i) => {
  const totalNum = Math.round(((i + 1) * 173.91) % 50000) / 100;
  return {
    id: `ORD-${String(1000 + i)}`,
    customer: `Customer ${i + 1}`,
    total: `$${totalNum.toFixed(2)}`,
    status: statuses[i % statuses.length]!,
  };
});

const columns: ColumnDef<Order>[] = [
  { accessorKey: "id", header: "Order ID" },
  { accessorKey: "customer", header: "Customer" },
  { accessorKey: "total", header: "Total" },
  { accessorKey: "status", header: "Status" },
];
---

<Table
  data={data}
  columns={columns}
  caption="47 orders — paginated 10 per page"
  pageSize={10}
  pageSizeOptions={[5, 10, 25, 50]}
  bordered
/>

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
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
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Package {
  name: string;
  version: string;
  downloads: number;
  downloadsFormatted: string;
  license: string;
}

const raw = [
  { name: "react", version: "19.0.0", downloads: 28_500_000, license: "MIT" },
  { name: "vue", version: "3.5.0", downloads: 5_900_000, license: "MIT" },
  { name: "svelte", version: "5.0.0", downloads: 1_200_000, license: "MIT" },
  { name: "angular", version: "18.0.0", downloads: 3_400_000, license: "MIT" },
  { name: "solid-js", version: "1.8.0", downloads: 180_000, license: "MIT" },
  { name: "preact", version: "10.22.0", downloads: 950_000, license: "MIT" },
  { name: "qwik", version: "1.6.0", downloads: 45_000, license: "MIT" },
  { name: "lit", version: "3.1.0", downloads: 720_000, license: "BSD-3" },
  { name: "ember", version: "5.10.0", downloads: 95_000, license: "MIT" },
  { name: "alpine", version: "3.14.0", downloads: 120_000, license: "MIT" },
];

const data: Package[] = raw.map((p) => ({
  ...p,
  downloadsFormatted: p.downloads.toLocaleString("en-US"),
}));

const columns: ColumnDef<Package>[] = [
  { accessorKey: "name", header: "Package" },
  { accessorKey: "version", header: "Version" },
  { accessorKey: "downloadsFormatted", header: "Weekly Downloads" },
  { accessorKey: "license", header: "License" },
];
---

<Table
  data={data}
  columns={columns}
  caption="Type in the search box to filter rows across all columns"
  showGlobalFilter
  globalFilterPlaceholder="Search packages…"
  pageSize={5}
/>

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 (getFacetedUniqueValues, getFacetedMinMaxValues) so you can build value lists for richer filter UIs. Disable filtering on a column with enableColumnFilter: false in its definition. columnFilterPlaceholder sets the inputs’ placeholder.

Per-column filtering — type in any column header input
Ada LovelaceEngineeringLondonPrincipal
Grace HopperEngineeringNew YorkDistinguished
Katherine JohnsonResearchHamptonLead
Alan TuringResearchLondonPrincipal
Linus TorvaldsPlatformPortlandFellow
Margaret HamiltonPlatformBostonDirector
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Employee {
  name: string;
  department: string;
  location: string;
  title: string;
}

const data: Employee[] = [
  {
    name: "Ada Lovelace",
    department: "Engineering",
    location: "London",
    title: "Principal",
  },
  {
    name: "Grace Hopper",
    department: "Engineering",
    location: "New York",
    title: "Distinguished",
  },
  {
    name: "Katherine Johnson",
    department: "Research",
    location: "Hampton",
    title: "Lead",
  },
  {
    name: "Alan Turing",
    department: "Research",
    location: "London",
    title: "Principal",
  },
  {
    name: "Linus Torvalds",
    department: "Platform",
    location: "Portland",
    title: "Fellow",
  },
  {
    name: "Margaret Hamilton",
    department: "Platform",
    location: "Boston",
    title: "Director",
  },
];

const columns: ColumnDef<Employee>[] = [
  { accessorKey: "name", header: "Name" },
  { accessorKey: "department", header: "Department" },
  { accessorKey: "location", header: "Location" },
  { accessorKey: "title", header: "Title" },
];
---

<!--
  `showColumnFilters` renders a built-in filter row of inputs (one per
  filterable column). Each input carries `data-table-column-filter`, which
  mountTable's delegated input handler wires to `column.setFilterValue`.
-->
<Table
  data={data}
  columns={columns}
  showColumnFilters
  columnFilterPlaceholder="Filter…"
  enablePagination={false}
  caption="Per-column filtering — type in any column header input"
/>

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 mountTable 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
T-1Design reviewAdahigh80
T-2Write testsGracemedium45
T-3Update docsAlanlow100
---
import Table from "../table.astro";
import Avatar from "@pindoba/astro-avatar";
import Badge from "@pindoba/astro-badge";
import type { ColumnDef } from "@tanstack/table-core";

interface Task {
  id: string;
  title: string;
  assignee: string;
  // First letter of `assignee`; baked into the data so the Astro `<Avatar>`
  // template can render it declaratively (`data-table-bind` can't call a
  // function per row). Svelte uses the same field for strict parity.
  assigneeInitial: string;
  priority: "low" | "medium" | "high";
  // Numeric rank so Priority sorts semantically (high > medium > low) via
  // the `meta.sortByField` convention — a string `sortingFn` that survives
  // Astro's SSR→client boundary.
  priorityRank: 1 | 2 | 3;
  progress: number;
}

const data: Task[] = [
  {
    id: "T-1",
    title: "Design review",
    assignee: "Ada",
    assigneeInitial: "A",
    priority: "high",
    priorityRank: 3,
    progress: 80,
  },
  {
    id: "T-2",
    title: "Write tests",
    assignee: "Grace",
    assigneeInitial: "G",
    priority: "medium",
    priorityRank: 2,
    progress: 45,
  },
  {
    id: "T-3",
    title: "Update docs",
    assignee: "Alan",
    assigneeInitial: "A",
    priority: "low",
    priorityRank: 1,
    progress: 100,
  },
];

const columns: ColumnDef<Task>[] = [
  { accessorKey: "id", header: "ID" },
  { accessorKey: "title", header: "Task" },
  { accessorKey: "assignee", header: "Assignee" },
  {
    accessorKey: "priority",
    header: "Priority",
    // `meta.sortByField` makes createTableOptions synthesize a sortingFn that
    // compares numeric `priorityRank` instead of alphabetical "priority".
    meta: { sortByField: "priorityRank" },
  },
  { accessorKey: "progress", header: "Progress" },
];
---

<Table
  data={data}
  columns={columns}
  caption="Per-column slots compose Pindoba components with row-scoped bindings"
  enablePagination={false}
>
  {/* Title cell: bold title with the row id underneath. */}
  <template data-table-cell-template="title">
    <strong data-table-bind="value"></strong>
    <small style="display: block; opacity: 0.6;">
      row <span data-table-bind="row.id"></span>
    </small>
  </template>

  {
    /* Assignee cell: Pindoba Avatar + name side by side. The Avatar's fallback
      slot receives the per-row initial via `data-table-bind`. */
  }
  <template data-table-cell-template="assignee">
    <span style="display: inline-flex; align-items: center; gap: 0.5rem;">
      <Avatar size="sm">
        <span slot="fallback" data-table-bind="row.assigneeInitial"></span>
      </Avatar>
      <span data-table-bind="value"></span>
    </span>
  </template>

  {
    /* Priority cell: one `<Badge>` per variant, mount-table's `data-table-show-if`
      hides the non-matching ones per row. `data-table-bind="value"` fills the
      label text. */
  }
  <template data-table-cell-template="priority">
    <Badge feedback="danger" data-table-show-if="row.priority=high">
      <span data-table-bind="value"></span>
    </Badge>
    <Badge feedback="warning" data-table-show-if="row.priority=medium">
      <span data-table-bind="value"></span>
    </Badge>
    <Badge feedback="success" data-table-show-if="row.priority=low">
      <span data-table-bind="value"></span>
    </Badge>
  </template>

  {
    /* Progress cell: hand-rolled bar. Styles are INLINE (not a scoped <style>)
      because mountTable clones this <template> into each row at runtime — and
      cloned nodes don't carry Astro's component scope attribute, so a scoped
      <style> would never match them. `data-table-bind="row.progress"` injects
      the numeric value as textContent; the inline <script> below reads it to
      set the fill width. The value text is hidden in the fill (font-size: 0)
      but never cleared (clearing would re-fire the MutationObserver and read
      "" back as 0%). */
  }
  <template data-table-cell-template="progress">
    <span style="display: flex; align-items: center; gap: 0.5rem;">
      <span
        style="flex: 1; min-width: 60px; height: 6px; border-radius: 9999px; background: rgba(127, 127, 127, 0.2); overflow: hidden;"
      >
        <span
          class="progress-fill"
          data-table-bind="row.progress"
          style="display: block; height: 100%; font-size: 0; line-height: 0; background: var(--colors-primary-solid, #6366f1);"
        ></span>
      </span>
      <span
        style="font-variant-numeric: tabular-nums; font-size: 0.75rem; white-space: nowrap;"
      >
        <span data-table-bind="row.progress"></span>%
      </span>
    </span>
  </template>
</Table>

<script>
  /* `data-table-bind` sets the fill span's textContent to the numeric progress.
     Turn that text into a `width` (and a 100%-complete green) so the visual
     matches the Svelte demo. Runs on every mutation inside `tbody`.
     We never clear the textContent: doing so would re-fire the MutationObserver
     and read "" back as 0%, making the bar vanish after the first sort. */
  function applyProgress(root: Element) {
    root.querySelectorAll<HTMLElement>(".progress-fill").forEach((el) => {
      const pct = Number(el.textContent ?? 0);
      el.style.width = `${pct}%`;
      el.style.background =
        pct >= 100
          ? "var(--colors-green-solid, #10b981)"
          : "var(--colors-primary-solid, #6366f1)";
    });
  }

  function init() {
    document
      .querySelectorAll<HTMLElement>('[data-table-cell-template="progress"]')
      .forEach((tpl) => {
        const root = tpl.closest<HTMLElement>('[data-component="table"]');
        if (!root) return;
        applyProgress(root);
        const tbody = root.querySelector("tbody");
        if (!tbody) return;
        const mo = new MutationObserver(() => applyProgress(root));
        mo.observe(tbody, { childList: true, subtree: true });
      });
  }
  init();
  document.addEventListener("astro:page-load", init);
</script>

Row selection

Set enableRowSelection to activate selection state. Add a select column in your column definitions and render a checkbox per row.

  • Svelte: pair with bind:instance to read instance.getState().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. mountTable wires the change handlers and syncs checked state on every render.

0 of 5 selected

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
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

const data: User[] = [
  { id: "u1", name: "Ada Lovelace", email: "ada@example.com", role: "Admin" },
  { id: "u2", name: "Alan Turing", email: "alan@example.com", role: "Member" },
  { id: "u3", name: "Grace Hopper", email: "grace@example.com", role: "Admin" },
  {
    id: "u4",
    name: "Linus Torvalds",
    email: "linus@example.com",
    role: "Member",
  },
  {
    id: "u5",
    name: "Donald Knuth",
    email: "donald@example.com",
    role: "Member",
  },
];

const columns: ColumnDef<User>[] = [
  { id: "select", header: "", cell: () => "", enableSorting: false },
  { accessorKey: "name", header: "Name" },
  { accessorKey: "email", header: "Email" },
  { accessorKey: "role", header: "Role" },
];
---

<div
  data-demo="table-selectable"
  style="display: flex; flex-direction: column; gap: 0.75rem;"
>
  <p style="margin: 0; font-size: 0.875rem;">
    <strong data-selected-count>0</strong> of {data.length} selected
  </p>

  <Table
    data={data}
    columns={columns}
    enableRowSelection
    enablePagination={false}
    enableSorting={false}
    getRowId={(row) => row.id}
    caption="Row selection via the `select` column"
  >
    <template data-table-header-template="select">
      <input
        type="checkbox"
        data-table-select-all
        aria-label="Select all rows"
      />
    </template>
    <template data-table-cell-template="select">
      <input type="checkbox" data-table-select-row aria-label="Select row" />
    </template>
  </Table>
</div>

<script>
  function syncCounts(scope: ParentNode = document) {
    const wrappers = scope.querySelectorAll<HTMLElement>(
      '[data-demo="table-selectable"]',
    );
    wrappers.forEach((wrapper) => {
      const counter = wrapper.querySelector<HTMLElement>(
        "[data-selected-count]",
      );
      if (!counter) return;
      const update = () => {
        const n = wrapper.querySelectorAll(
          'tbody tr[data-row-selected="true"]',
        ).length;
        counter.textContent = String(n);
      };
      wrapper.addEventListener("change", update);
      // Defer until mountTable has stamped the initial rows.
      queueMicrotask(update);
    });
  }

  syncCounts();
  document.addEventListener("astro:page-load", () => syncCounts());
</script>

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}". mountTable’s delegated change listener handles the rest.
Toggle columns on and off
Revenue1280005.42h agoFinance
Active users42100-1.25m agoGrowth
Conversion3.80.31h agoProduct
Churn2.1-0.43h agoRetention
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Metric {
  name: string;
  value: number;
  change: number;
  updated: string;
  owner: string;
}

const data: Metric[] = [
  {
    name: "Revenue",
    value: 128_000,
    change: 5.4,
    updated: "2h ago",
    owner: "Finance",
  },
  {
    name: "Active users",
    value: 42_100,
    change: -1.2,
    updated: "5m ago",
    owner: "Growth",
  },
  {
    name: "Conversion",
    value: 3.8,
    change: 0.3,
    updated: "1h ago",
    owner: "Product",
  },
  {
    name: "Churn",
    value: 2.1,
    change: -0.4,
    updated: "3h ago",
    owner: "Retention",
  },
];

const columns: ColumnDef<Metric>[] = [
  { accessorKey: "name", header: "Metric" },
  { accessorKey: "value", header: "Value" },
  { accessorKey: "change", header: "Change" },
  { accessorKey: "updated", header: "Updated" },
  { accessorKey: "owner", header: "Owner" },
];
---

<!--
  `enableColumnVisibility` renders the built-in "Columns" menu in the toolbar;
  its checkboxes carry `data-table-column-toggle`, which mountTable's delegated
  change handler wires up automatically. (The `toolbar` slot still overrides it
  for a bespoke control.)
-->
<Table
  data={data}
  columns={columns}
  enableColumnVisibility
  enablePagination={false}
  caption="Toggle columns on and off"
/>

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 — mountTable stamps the row id, manages aria-expanded, and hides the button when the row can’t expand. Add data-table-indent to any element to apply depth-based padding.
Click › to expand folders
📁 srcfolder
📁 docsfolder
📄 package.json1.1 KBfile
---
import Table from "../table.astro";
import Button from "@pindoba/astro-button";
import type { ColumnDef } from "@tanstack/table-core";

interface Folder {
  name: string;
  size: string;
  type: string;
  children?: Folder[];
}

const raw: Folder[] = [
  {
    name: "src",
    size: "",
    type: "folder",
    children: [
      {
        name: "components",
        size: "",
        type: "folder",
        children: [
          { name: "button.tsx", size: "2.1 KB", type: "file" },
          { name: "table.tsx", size: "8.4 KB", type: "file" },
        ],
      },
      { name: "index.ts", size: "340 B", type: "file" },
    ],
  },
  {
    name: "docs",
    size: "",
    type: "folder",
    children: [
      { name: "README.md", size: "4.2 KB", type: "file" },
      { name: "CHANGELOG.md", size: "1.8 KB", type: "file" },
    ],
  },
  { name: "package.json", size: "1.1 KB", type: "file" },
];

// Precompute the icon into `name` so the declarative `data-table-bind`
// template picks it up — `applyRowBindings` doesn't do conditional rendering.
function withIcons(rows: Folder[]): Folder[] {
  return rows.map((row) => ({
    ...row,
    name: `${row.type === "folder" ? "📁" : "📄"} ${row.name}`,
    children: row.children ? withIcons(row.children) : undefined,
  }));
}

const data = withIcons(raw);

const columns: ColumnDef<Folder>[] = [
  { id: "expand", header: "", cell: () => "", enableSorting: false },
  { accessorKey: "name", header: "Name" },
  { accessorKey: "size", header: "Size" },
  { accessorKey: "type", header: "Type" },
];
---

<Table
  data={data}
  columns={columns}
  enableExpanding
  enablePagination={false}
  getSubRows={(row) => row.children}
  caption="Click › to expand folders"
>
  <template data-table-cell-template="expand">
    <span data-table-indent>
      <Button
        type="button"
        size="xs"
        emphasis="ghost"
        shape="square"
        aria-label="Toggle"
        passThrough={{ root: { props: { "data-table-expand": "" } } }}
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
        >
          <path d="m9 18 6-6-6-6"></path>
        </svg>
      </Button>
    </span>
  </template>
  <template data-table-cell-template="name">
    <span data-table-bind="row.name"></span>
  </template>
</Table>

Grouped rows

Set enableGrouping (usually with enableExpanding) and seed initialState.grouping to group rows by one or more columns. Use aggregationFn on the column definition to summarize children. mountTable renders the grouping expand toggle and aggregated cells natively in both frameworks — no custom slot needed.

Sales grouped by region (click › to collapse)
North25710
NorthWidget12240
NorthGizmo8320
NorthSprocket5150
South34960
SouthWidget20400
SouthGizmo14560
East20510
EastWidget9180
EastSprocket11330
West23850
WestGizmo16640
WestSprocket7210
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Sale {
  region: string;
  product: string;
  quantity: number;
  revenue: number;
}

const data: Sale[] = [
  { region: "North", product: "Widget", quantity: 12, revenue: 240 },
  { region: "North", product: "Gizmo", quantity: 8, revenue: 320 },
  { region: "North", product: "Sprocket", quantity: 5, revenue: 150 },
  { region: "South", product: "Widget", quantity: 20, revenue: 400 },
  { region: "South", product: "Gizmo", quantity: 14, revenue: 560 },
  { region: "East", product: "Widget", quantity: 9, revenue: 180 },
  { region: "East", product: "Sprocket", quantity: 11, revenue: 330 },
  { region: "West", product: "Gizmo", quantity: 16, revenue: 640 },
  { region: "West", product: "Sprocket", quantity: 7, revenue: 210 },
];

// `cell` and `aggregationFn` functions can't cross the SSR → mountTable
// boundary; the hydrator strips them. mountTable still renders grouped /
// aggregated cells natively (folder-style expand toggle for grouped, `<em>`
// wrap for aggregated values), so the table works without explicit slots.
const columns: ColumnDef<Sale>[] = [
  { accessorKey: "region", header: "Region", enableGrouping: true },
  { accessorKey: "product", header: "Product" },
  { accessorKey: "quantity", header: "Qty", aggregationFn: "sum" },
  { accessorKey: "revenue", header: "Revenue", aggregationFn: "sum" },
];
---

<Table
  data={data}
  columns={columns}
  enableGrouping
  enableExpanding
  enablePagination={false}
  initialState={{ grouping: ["region"], expanded: true }}
  caption="Sales grouped by region (click › to collapse)"
  bordered
/>

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: use state + onStateChange props (or bind:instance) to drive a $effect that re-fetches.
  • Astro: subscribe to mount.store from an inline <script> and call mount.store.actions.setData(nextRows) once the request resolves — pagination/sorting state survives.

120 total rows (server-paginated)

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
---
import Table from "../table.astro";
import Loading from "@pindoba/astro-loading";
import type { ColumnDef } from "@tanstack/table-core";

interface Log {
  id: number;
  level: "info" | "warn" | "error";
  message: string;
  timestamp: string;
}

const TOTAL_ROWS = 120;

function generateAllRows(): Log[] {
  const levels: Log["level"][] = ["info", "warn", "error"];
  return Array.from({ length: TOTAL_ROWS }, (_, i) => ({
    id: i + 1,
    level: levels[i % 3]!,
    message: `Log entry #${i + 1}`,
    timestamp: new Date(Date.UTC(2026, 0, 1) - i * 60_000).toISOString(),
  }));
}

// SSR renders the first page of the unsorted dataset. The inline `<script>`
// below takes over on hydration and answers pagination/sorting changes with a
// fresh slice of the *full* dataset — what a real backend would do.
const initialRows = generateAllRows().slice(0, 10);

const columns: ColumnDef<Log>[] = [
  { accessorKey: "id", header: "#" },
  { accessorKey: "level", header: "Level" },
  { accessorKey: "message", header: "Message" },
  { accessorKey: "timestamp", header: "Timestamp" },
];
---

<div
  data-demo="table-server-side"
  style="display: flex; flex-direction: column; gap: 0.5rem;"
>
  <p style="margin: 0; font-size: 0.875rem;">
    <span data-status>{TOTAL_ROWS} total rows (server-paginated)</span>
  </p>

  <!-- Wrap the table in <Loading> (busy variant); the fetch script below
       toggles its data-loading attribute on each round-trip. Loading is not a
       table feature — the demo composes it around the table. -->
  <Loading variant="busy">
    <Table
      data={initialRows}
      columns={columns}
      manualPagination
      manualSorting
      rowCount={TOTAL_ROWS}
      caption="Mock server-side pagination + sorting"
    />
  </Loading>
</div>

<script>
  import { mountTable } from "@pindoba/core-table";
  import type { SortingState } from "@tanstack/table-core";

  type Log = {
    id: number;
    level: "info" | "warn" | "error";
    message: string;
    timestamp: string;
  };

  const TOTAL_ROWS = 120;

  function generateAllRows(): Log[] {
    const levels: Log["level"][] = ["info", "warn", "error"];
    return Array.from({ length: TOTAL_ROWS }, (_, i) => ({
      id: i + 1,
      level: levels[i % 3]!,
      message: `Log entry #${i + 1}`,
      timestamp: new Date(Date.UTC(2026, 0, 1) - i * 60_000).toISOString(),
    }));
  }

  const ALL_ROWS: Log[] = generateAllRows();

  /** Mock server query: sort the full dataset, then slice the requested page. */
  function queryPage(pi: number, ps: number, srt: SortingState): Log[] {
    const sorted = ALL_ROWS.slice();
    const first = srt[0];
    if (first) {
      const { id, desc } = first;
      sorted.sort((a, b) => {
        const va = (a as Record<string, unknown>)[id] as string | number;
        const vb = (b as Record<string, unknown>)[id] as string | number;
        if (va < vb) return desc ? 1 : -1;
        if (va > vb) return desc ? -1 : 1;
        return 0;
      });
    }
    return sorted.slice(pi * ps, pi * ps + ps);
  }

  function init() {
    const wrappers = document.querySelectorAll<HTMLElement>(
      '[data-demo="table-server-side"]',
    );
    wrappers.forEach((wrapper) => {
      const root = wrapper.querySelector<HTMLElement>(
        '[data-component="table"][data-table-hydrate]',
      );
      const status = wrapper.querySelector<HTMLElement>("[data-status]");
      // The <Loading> root carries `data-loading`; toggling it drives the
      // busy-shimmer CSS while a round-trip is in flight.
      const loadingEl = wrapper.querySelector<HTMLElement>("[data-loading]");
      if (!root) return;

      // Replace the table.astro auto-mount with a manual mount so we control
      // the `data` source via async fetches.
      root.removeAttribute("data-table-hydrate");
      root.setAttribute("data-table-mounted", "true");

      const columns = JSON.parse(
        root.getAttribute("data-table-columns") ?? "[]",
      );
      const opts = JSON.parse(root.getAttribute("data-table-options") ?? "{}");

      const mount = mountTable<Log>(root, {
        ...opts,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        columns: columns as any,
        data: queryPage(0, opts.pageSize ?? 10, []),
      });

      let lastKey = "";
      const fetchPage = async () => {
        const state = mount.store.getState();
        const pi = state.pagination?.pageIndex ?? 0;
        const ps = state.pagination?.pageSize ?? 10;
        const srt = (state.sorting ?? []) as SortingState;
        const key = `${pi}|${ps}|${srt[0]?.id ?? ""}|${srt[0]?.desc ? "d" : "a"}`;
        if (key === lastKey) return;
        lastKey = key;
        if (status) status.textContent = "Loading…";
        loadingEl?.setAttribute("data-loading", "true");
        loadingEl?.setAttribute("aria-busy", "true");
        await new Promise((r) => setTimeout(r, 250));
        mount.store.actions.setData(queryPage(pi, ps, srt));
        loadingEl?.setAttribute("data-loading", "false");
        loadingEl?.setAttribute("aria-busy", "false");
        if (status) {
          status.textContent = `${TOTAL_ROWS} total rows (server-paginated)`;
        }
      };

      mount.store.subscribe(() => {
        void fetchPage();
      });
      void fetchPage();

      const teardown = () => mount.destroy();
      window.addEventListener("pagehide", teardown, { once: true });
      document.addEventListener("astro:before-swap", teardown, { once: true });
    });
  }

  init();
  document.addEventListener("astro:page-load", init);
</script>

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, call mountTable yourself from an inline <script>.)

The legacy table prop — a pre-built TanStack instance — still works for static, read-only tables, but it does not re-render on interaction; prefer tableOptions.

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
---
import Table from "../table.astro";
import type { ColumnDef } from "@tanstack/table-core";

interface Contact {
  firstName: string;
  lastName: string;
  email: string;
  balance: number;
  status: "active" | "pending" | "overdue";
}

const data: Contact[] = [
  {
    firstName: "Donald",
    lastName: "Knuth",
    email: "donald@example.com",
    balance: 21_040,
    status: "active",
  },
  {
    firstName: "Ada",
    lastName: "Lovelace",
    email: "ada@example.com",
    balance: 12_450,
    status: "active",
  },
  {
    firstName: "Alan",
    lastName: "Turing",
    email: "alan@example.com",
    balance: 8_300,
    status: "active",
  },
  {
    firstName: "Linus",
    lastName: "Torvalds",
    email: "linus@example.com",
    balance: 5_670,
    status: "pending",
  },
  {
    firstName: "Grace",
    lastName: "Hopper",
    email: "grace@example.com",
    balance: -230,
    status: "overdue",
  },
];

// Grouped-header columns: TanStack resolves a nested `columns` array into a
// multi-row `<thead>` with the right `colspan`.
const columns: ColumnDef<Contact>[] = [
  {
    id: "contact",
    header: "Contact",
    columns: [
      { accessorKey: "firstName", header: "First" },
      { accessorKey: "lastName", header: "Last" },
      { accessorKey: "email", header: "Email" },
    ],
  },
  {
    id: "account",
    header: "Account",
    columns: [
      { accessorKey: "balance", header: "Balance" },
      { accessorKey: "status", header: "Status" },
    ],
  },
];

// `tableOptions` is the reactive escape hatch: the SSR build AND the hydrated
// client both feed these consumer-authored TanStack options through Pindoba's
// options builder, so sorting stays interactive (unlike the old static `table`
// instance prop). `sortDescFirst` is a TanStack option not surfaced as a prop.
// (Function options can't cross the SSR→client boundary; for those, call
// mountTable yourself in an inline <script>.)
---

<Table
  data={data}
  columns={columns}
  tableOptions={{ sortDescFirst: true }}
  bordered
  caption="Grouped headers + a consumer TanStack option (sortDescFirst) — interactive via the reactive escape hatch"
/>

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) / inline mountTable
Async / server-side datastate + onStateChangemount.store.subscribe + mount.store.actions.setData

Astro slot templates are inert HTML — they cost nothing for non-JS clients (the SSR fallback rows above stay visible) and they re-render on every store change, so live state stays in sync. For renderers richer than the binding vocabulary supports, call mountTable(el, { cellRenderers: { [columnId]: (ctx) => "…" } }) yourself against the SSR markup from an inline <script>.

props · 53 shown · 53 total
background
"surface.peak""surface.hill""surface.base""surface.valley""surface.ground""transparent"
default surface.ground

Panel-inherited surface background token — same values as Panel and Card.

border
"none""bold""default""muted""accent"

Panel-inherited border token — same values as Panel and Card.

bordered
boolean
default false

Render vertical cell borders.

caption
string

Optional `<caption>` text rendered above the table rows.

cell slot svelte
Snippet<[TableCellContext<TData>]>

Optional custom cell renderer (fallback is String(value)).

columnFilterPlaceholder
string
default "Filter…"

Placeholder for the per-column filter inputs.

columns
ColumnDef<TData, unknown>[]

TanStack column definitions. Required unless `table` is supplied. Accepts `accessorKey`, `header`, `cell`, `enableSorting`, `enableGrouping`, `aggregationFn`, etc.

data
TData[]

Row data. Required unless `table` is supplied. Each row is a plain object matching your column accessors.

density
"comfortable""compact"
default "comfortable"

Row height — `compact` tightens vertical padding.

element binding svelte
HTMLDivElementnull

Bindable container element reference.

emphasis
"primary""secondary""tertiary"

Panel-inherited emphasis token — same values as Panel and Card.

emptyMessage
string
default "No data"

Text shown when the row model is empty.

enableColumnFilters
boolean

Wire per-column filtering + TanStack faceted row models (unique values / min-max). Implied by `showColumnFilters`.

enableColumnResizing
boolean
default false

Enable column resizing via TanStack.

enableColumnVisibility
boolean
default false

Opt-in flag indicating columns can be shown/hidden (consumers render the toggle UI).

enableExpanding
boolean
default false

Enable expandable sub-rows. Usually paired with `getSubRows`.

enableFiltering
boolean
default true

Enable filtering (global filter + per-column `setFilterValue`).

enableGrouping
boolean
default false

Enable grouping by column. Usually paired with `enableExpanding` and `initialState.grouping`.

enableMultiRowSelection
boolean
default true

Allow multi-select. Defaults to true when selection is on.

enablePagination
boolean
default true

Enable the pagination row model.

enableRowSelection
boolean(row: Row<TData>) => boolean

Enable row selection. Accepts `true`/`false` or a `(row) => boolean` predicate.

enableSorting
boolean
default true

Enable sorting. Click headers to cycle asc/desc/none.

feedback
"primary""neutral""success""warning""danger""inherit"

Panel-inherited feedback token — same values as Panel and Card.

getRowId
(row: TData, index: number, parent?: Row<TData>) => string

Stable id for each row (recommended for selection persistence).

getSubRows
(row: TData, index: number) => TData[] | undefined

Resolver for nested rows. Return the children array for a given row.

globalFilter
string

Controlled global filter value.

globalFilterPlaceholder
string
default "Search…"

Placeholder for the global-filter input.

header slot svelte
Snippet<[TableHeaderContext<TData>]>

Optional custom header renderer.

initialState
TableOptions<TData>["initialState"]

Initial TanStack state — seed sorting, pagination, grouping, filters, etc.

instance svelte
TableInstance<TData>null

Bindable reference to the live TanStack Table instance.

manualFiltering
boolean
default false

Switch filtering to server-side mode — Pindoba won't re-process rows; supply the slice you want rendered.

manualPagination
boolean
default false

Switch pagination to server-side mode — Pindoba won't re-process rows; supply the slice you want rendered.

manualSorting
boolean
default false

Switch sorting to server-side mode — Pindoba won't re-process rows; supply the slice you want rendered.

onStateChange
OnChangeFn<TableState>

Fires whenever TanStack state changes.

padding
"sm""md""lg""xl""2xl""none""xs""3xl""4xl""5xl""6xl""7xl""8xl""4xs""3xs""2xs""9xl""10xl""11xl"

Panel-inherited padding token — same values as Panel and Card.

pageSize
number
default 10

Initial page size when pagination is on.

pageSizeOptions
number[]
default [10, 25, 50, 100]

Options shown in the built-in page-size `<select>`.

pagination slot svelte
Snippet<[{ table: TableInstance<TData> }]>

Optional pagination renderer (replaces built-in pagination controls).

passThrough
TablePassThrough<RootElementAttributes>

Per-slot style/attribute override bag — inject Panda styles or extra HTML attributes into any rendered slot.

radius
"sm""md""lg""xl""2xl""none""xs""3xl""4xl""5xl""6xl""full""2xs""inner""inherit"

Panel-inherited corner radius token — same values as Panel and Card.

rowCount
number

Total row count (required with `manualPagination` for accurate page counts).

shadow
"sm""md""lg""xl""none""xs"

Panel-inherited shadow token — same values as Panel and Card.

showColumnFilters
boolean
default false

Render the built-in per-column filter row (one input per filterable column). Implies `enableColumnFilters`.

showGlobalFilter
boolean
default false

Render the built-in global-filter `<input>` above the table.

showPagination
boolean

Whether to render built-in pagination controls. Defaults to `enablePagination`. Set `false` to keep pagination active but render your own UI (use the `pagination` snippet).

size
"sm""md""lg"
default "md"

Cell padding and font-size variant.

state
Partial<TableState>

Controlled state (merged with internal state). Pair with `onStateChange` for full external control.

stickyHeader
boolean
default false

Pin the header to the top of the scroll container.

striped
boolean
default false

Zebra-stripe even-indexed rows.

table
Table<TData>

Advanced (static): pass a pre-built TanStack Table instance for SSR rendering. `data`/`columns`/feature flags are ignored and the table does NOT re-render on interaction. For interactive custom setups, prefer `tableOptions`.

tableOptions
Partial<TableOptions<TData>>

Reactive escape hatch: consumer-authored TanStack `TableOptions` merged over Pindoba's defaults. The wrapper builds its reactive store from these, so sort/filter/paginate stay interactive. In Astro, function-valued options can't cross the SSR→client boundary (use inline `mountTable` for those).

toolbar slot svelte
Snippet<[{ table: TableInstance<TData> }]>

Optional toolbar rendered above the table (replaces built-in filter).

translucent
boolean

Panel-inherited translucent surface flag — same values as Panel and Card.

Plus all standard <div> HTML attributes.

Type

  • Components
  • Blocks