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.

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
Page 1 of 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
Page 1 of 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

Set pageSize to tune page size (default 10). pageSizeOptions controls the built-in page-size selector. To hide the controls but keep pagination, use showPagination={false}. To disable pagination altogether, use enablePagination={false}.

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
Page 1 of 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
Page 1 of 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}
/>

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 (no Pindoba Progress component yet). The
      `data-progress` attribute drives the filled-bar width via CSS calc. */
  }
  <template data-table-cell-template="progress">
    <span class="progress-cell">
      <span class="progress-track">
        <span class="progress-fill" data-table-bind="row.progress"></span>
      </span>
      <span class="progress-value" data-table-bind="row.progress"></span>
    </span>
  </template>
</Table>

<style>
  .progress-cell {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }
  .progress-track {
    flex: 1;
    min-width: 60px;
    height: 6px;
    border-radius: 9999px;
    background: rgba(127, 127, 127, 0.2);
    overflow: hidden;
  }
  .progress-fill {
    display: block;
    height: 100%;
    background: var(--colors-primary-solid, #6366f1);
    /* `data-table-bind` injects the numeric progress as textContent — that's
       our signal for the width below. Hide it visually so the bar shows no
       text. Keeping the text prevents a MutationObserver feedback loop
       (clearing text would re-fire the observer, read "" back as 0, reset
       the width). */
    font-size: 0;
    line-height: 0;
  }
  .progress-value {
    font-variant-numeric: tabular-nums;
    font-size: 0.75rem;
  }
  .progress-value::after {
    content: "%";
  }
</style>

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

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

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
Name Email Role
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 you can hide or show columns from outside the table.

  • Svelte: iterate instance.getAllLeafColumns() and wire each column’s getToggleVisibilityHandler() to a checkbox.
  • Astro: render the toggles inside the toolbar slot with data-table-column-toggle="{columnId}". mountTable’s delegated change listener handles the rest.
Toggle columns:
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" },
];

// The toolbar lives inside the table root (via the `toolbar` slot) so
// mountTable's delegated `[data-table-column-toggle]` change handler picks
// up these checkboxes automatically.
const toggleableColumns = ["name", "value", "change", "updated", "owner"];
---

<Table
  data={data}
  columns={columns}
  enableColumnVisibility
  enablePagination={false}
  caption="Toggle columns on and off"
>
  <Fragment slot="toolbar">
    <strong style="font-size: 0.875rem;">Toggle columns:</strong>
    {
      toggleableColumns.map((id) => (
        <label style="display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.875rem;">
          <input
            type="checkbox"
            checked
            data-table-column-toggle={id}
            aria-label={`Toggle ${id}`}
          />
          {id}
        </label>
      ))
    }
  </Fragment>
</Table>

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

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

const TOTAL_ROWS = 120;

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

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

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

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

  <Table
    data={initialRows}
    columns={columns}
    manualPagination
    manualSorting
    rowCount={TOTAL_ROWS}
    caption="Mock server-side pagination + sorting"
  />
</div>

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

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

  const TOTAL_ROWS = 120;

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

  const ALL_ROWS: Log[] = generateAllRows();

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

  function init() {
    const wrappers = document.querySelectorAll<HTMLElement>(
      '[data-demo="table-server-side"]',
    );
    wrappers.forEach((wrapper) => {
      const root = wrapper.querySelector<HTMLElement>(
        '[data-component="table"][data-table-hydrate]',
      );
      const status = wrapper.querySelector<HTMLElement>("[data-status]");
      if (!root) return;

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

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

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

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

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

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

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

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 visibility toolbarcolumn.getToggleVisibilityHandler()data-table-column-toggle="{id}" in toolbar slot
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

props · 38 total
prop type default req description
data TData[] undefined Row data. Required unless `table` is supplied. Each row is a plain object matching your column accessors.
columns ColumnDef<TData>[] undefined TanStack column definitions. Required unless `table` is supplied. Accepts `accessorKey`, `header`, `cell`, `enableSorting`, `enableGrouping`, `aggregationFn`, etc.
table Table<TData> undefined Advanced: pass a pre-built TanStack Table instance for SSR rendering. When supplied, `data`/`columns` and every feature flag are ignored; Pindoba only renders what the instance provides. (Interactive…

table

Advanced: pass a pre-built TanStack Table instance for SSR rendering. When supplied, `data`/`columns` and every feature flag are ignored; Pindoba only renders what the instance provides. (Interactive re-rendering from a consumer-owned state loop is not wired end-to-end yet — stick to the `data` + `columns` path for sort/filter/pagination interactivity.)

Type Table<TData>
Default undefined
Required No
enableSorting boolean true Enable sorting. Click headers to cycle asc/desc/none.
enableFiltering boolean true Enable filtering (global filter + per-column `setFilterValue`).
enablePagination boolean true Enable pagination row model.
showPagination boolean enablePagination Whether to render built-in pagination controls. Defaults to `enablePagination`. Set `false` to keep pagination active but render your own UI (use the `pagination` snippet).
pageSize number 10 Initial page size when pagination is on.
pageSizeOptions number[] [10, 25, 50, 100] Options shown in the built-in page-size `<select>`.
showGlobalFilter boolean false Render the built-in global-filter `<input>` above the table.
globalFilterPlaceholder string "Search…" Placeholder for the global-filter input.
enableRowSelection boolean((row: Row<TData>) => boolean) undefined Enable row selection. Accepts `true`/`false` or a `(row) => boolean` predicate.
enableMultiRowSelection boolean true Allow multi-select. Defaults to true when selection is on.
enableColumnVisibility boolean false Opt-in flag indicating columns can be shown/hidden (consumers render the toggle UI).
enableExpanding boolean false Enable expandable sub-rows. Usually paired with `getSubRows`.
enableGrouping boolean false Enable grouping by column. Usually paired with `enableExpanding` and `initialState.grouping`.
enableColumnResizing boolean false Enable column resizing via TanStack.
getSubRows (row: TData, index: number) => TData[] | undefined undefined Resolver for nested rows. Return the children array for a given row.
getRowId (row, index, parent?) => string undefined Stable id for each row (recommended for selection persistence).
manualPagination | manualSorting | manualFiltering boolean false Switch to server-side mode — Pindoba won't re-process rows; supply the slice you want rendered.
rowCount number undefined Total row count (required with `manualPagination` for accurate page counts).
initialState Partial<TableState> undefined Initial TanStack state — seed sorting, pagination, grouping, filters, etc.
state Partial<TableState> undefined Controlled state (merged with internal state). Pair with `onStateChange` for full external control.
onStateChange OnChangeFn<TableState> undefined Fires whenever TanStack state changes.
caption string undefined Optional `<caption>` text rendered above the table rows.
emptyMessage string "No data" Text shown when the row model is empty.
size "sm""md""lg" "md" Cell padding and font-size variant.
density "comfortable""compact" "comfortable" Row height — `compact` tightens vertical padding.
striped boolean false Zebra-stripe even-indexed rows.
bordered boolean false Render vertical cell borders.
stickyHeader boolean false Pin the header to the top of the scroll container.
background | feedback | padding | radius | border | shadow PanelStyleProps Panel defaults Panel-inherited surface tokens — same values as Panel and Card.
cell Snippet<[TableCellContext<TData>]> undefined Svelte snippet that receives `{ value, row, columnId, cell, rowApi }` for custom cell rendering.
header Snippet<[TableHeaderContext<TData>]> undefined Svelte snippet that receives `{ header, columnId }` for custom header rendering.
toolbar Snippet<[{ table: Table<TData> }]> undefined Svelte snippet rendered above the table. Replaces the built-in global-filter input.
pagination Snippet<[{ table: Table<TData> }]> undefined Svelte snippet rendered below the table. Replaces the built-in pagination controls.
instance Table<TData>null null Bindable reference to the live TanStack Table instance. Use it to read/control state from outside. Svelte only.
passThrough TablePassThrough undefined Escape hatch to inject styles/attributes into any slot — includes all the new slots: `pagination`, `paginationButton`, `pageInfo`, `pageSizeSelect`, `toolbar`, `globalFilterInput`, `selectionCheckbox`…

passThrough

Escape hatch to inject styles/attributes into any slot — includes all the new slots: `pagination`, `paginationButton`, `pageInfo`, `pageSizeSelect`, `toolbar`, `globalFilterInput`, `selectionCheckbox`, `expandToggle`, `groupCell`.

Type TablePassThrough
Default undefined
Required No