Skip to content
On this page Groups, resize, reorder and interactive cells

Display

DataTable

Table with search, sorting, pagination, column visibility, row groups, resizable columns, drag-to-reorder rows and interactive cells, built on TanStack Table.

DataTable lives in its own entry point so that TanStack Table stays out of bundles that do not need it. Install the optional peer first.

bun add @tanstack/svelte-table
<script lang="ts">
  import { DataTable, createCaColumnHelper } from "@codeatlasdev/ui/table";
  type User = { name: string };
  const users: User[] = [{ name: "Ana Souza" }, { name: "Bruno Lima" }];

  const col = createCaColumnHelper<User>();
  const columns = col.columns([
    col.accessor("name", { header: "Name", meta: { label: "Name" } }),
  ]);
</script>

<DataTable {columns} data={users} />

There are two modes. In simple mode you pass columns and data and the table searches, sorts and paginates locally. In controlled mode you pass globalFilter, pagination and sorting together with their change handlers, and feed it one page of data plus total, which is what you want for server-side data.

#123

Simple mode

Pass `columns` and `data`. Search, sort and pagination are managed automatically.

12 results
ID Name Email Role Status
1Ana Souzaana@example.comAdminactive
2Bruno Limabruno@example.comDeveloperactive
3Carla Mendescarla@example.comDesignerinactive
4Diego Ferreiradiego@example.comDeveloperactive
5Elena Castroelena@example.comManagerpending
6Felipe Rochafelipe@example.comDeveloperactive
7Gabriela Nunesgabi@example.comDesigneractive
8Henrique Lopeshenrique@example.comAdmininactive
9Isabela Torresisa@example.comDeveloperactive
10Joao Almeidajoao@example.comManagerpending
Rows
1 / 2 · 12

Reference #123 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { DataTable, createCaColumnHelper } from '@codeatlasdev/ui/table';

  interface User {
    id: number;
    name: string;
    email: string;
    role: string;
    status: 'active' | 'inactive' | 'pending';
  }

  const data: User[] = [
    { id: 1, name: 'Ana Souza', email: 'ana@example.com', role: 'Admin', status: 'active' },
    { id: 2, name: 'Bruno Lima', email: 'bruno@example.com', role: 'Developer', status: 'active' },
    { id: 3, name: 'Carla Mendes', email: 'carla@example.com', role: 'Designer', status: 'inactive' },
    { id: 4, name: 'Diego Ferreira', email: 'diego@example.com', role: 'Developer', status: 'active' },
    { id: 5, name: 'Elena Castro', email: 'elena@example.com', role: 'Manager', status: 'pending' },
    { id: 6, name: 'Felipe Rocha', email: 'felipe@example.com', role: 'Developer', status: 'active' },
    { id: 7, name: 'Gabriela Nunes', email: 'gabi@example.com', role: 'Designer', status: 'active' },
    { id: 8, name: 'Henrique Lopes', email: 'henrique@example.com', role: 'Admin', status: 'inactive' },
    { id: 9, name: 'Isabela Torres', email: 'isa@example.com', role: 'Developer', status: 'active' },
    { id: 10, name: 'Joao Almeida', email: 'joao@example.com', role: 'Manager', status: 'pending' },
    { id: 11, name: 'Karina Bastos', email: 'karina@example.com', role: 'Developer', status: 'active' },
    { id: 12, name: 'Lucas Carvalho', email: 'lucas@example.com', role: 'Designer', status: 'active' },
  ];

  const col = createCaColumnHelper<User>();
  const columns = col.columns([
    col.accessor('id', { header: 'ID', meta: { label: 'ID' } }),
    col.accessor('name', { header: 'Name', meta: { label: 'Name' } }),
    col.accessor('email', { header: 'Email', meta: { label: 'Email' } }),
    col.accessor('role', { header: 'Role', meta: { label: 'Role' } }),
    col.accessor('status', { header: 'Status', meta: { label: 'Status' } }),
  ]);
</script>

<div class="wrap"><DataTable {columns} {data} /></div>

<style>
  .wrap { width: 100%; min-width: 0; }
</style>
#122

Controlled mode

Own the state to drive server-side filtering, sorting and pagination.

12 results
ID Name Email Role Status
1Ana Souzaana@example.comAdminactive
2Bruno Limabruno@example.comDeveloperactive
3Carla Mendescarla@example.comDesignerinactive
4Diego Ferreiradiego@example.comDeveloperactive
5Elena Castroelena@example.comManagerpending
Rows
1 / 3 · 12

Reference #122 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { DataTable, createCaColumnHelper } from '@codeatlasdev/ui/table';
  import type { PaginationState, SortingState, Updater } from '@codeatlasdev/ui/table';

  interface User {
    id: number;
    name: string;
    email: string;
    role: string;
    status: 'active' | 'inactive' | 'pending';
  }

  const allData: User[] = [
    { id: 1, name: 'Ana Souza', email: 'ana@example.com', role: 'Admin', status: 'active' },
    { id: 2, name: 'Bruno Lima', email: 'bruno@example.com', role: 'Developer', status: 'active' },
    { id: 3, name: 'Carla Mendes', email: 'carla@example.com', role: 'Designer', status: 'inactive' },
    { id: 4, name: 'Diego Ferreira', email: 'diego@example.com', role: 'Developer', status: 'active' },
    { id: 5, name: 'Elena Castro', email: 'elena@example.com', role: 'Manager', status: 'pending' },
    { id: 6, name: 'Felipe Rocha', email: 'felipe@example.com', role: 'Developer', status: 'active' },
    { id: 7, name: 'Gabriela Nunes', email: 'gabi@example.com', role: 'Designer', status: 'active' },
    { id: 8, name: 'Henrique Lopes', email: 'henrique@example.com', role: 'Admin', status: 'inactive' },
    { id: 9, name: 'Isabela Torres', email: 'isa@example.com', role: 'Developer', status: 'active' },
    { id: 10, name: 'Joao Almeida', email: 'joao@example.com', role: 'Manager', status: 'pending' },
    { id: 11, name: 'Karina Bastos', email: 'karina@example.com', role: 'Developer', status: 'active' },
    { id: 12, name: 'Lucas Carvalho', email: 'lucas@example.com', role: 'Designer', status: 'active' },
  ];

  const col = createCaColumnHelper<User>();
  const columns = col.columns([
    col.accessor('id', { header: 'ID', meta: { label: 'ID' } }),
    col.accessor('name', { header: 'Name', meta: { label: 'Name' } }),
    col.accessor('email', { header: 'Email', meta: { label: 'Email' } }),
    col.accessor('role', { header: 'Role', meta: { label: 'Role' } }),
    col.accessor('status', { header: 'Status', meta: { label: 'Status' } }),
  ]);

  let globalFilter = $state('');
  let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 5 });
  let sorting = $state<SortingState>([]);

  // Stand-in for a server: filter, sort, then slice the requested page.
  const filtered = $derived.by(() => {
    const q = globalFilter.toLowerCase();
    let rows = q
      ? allData.filter((r) =>
          [r.name, r.email, r.role, r.status].some((v) => v.toLowerCase().includes(q)),
        )
      : allData;
    if (sorting.length > 0) {
      const { id, desc } = sorting[0];
      rows = [...rows].sort((a, b) => {
        const av = String(a[id as keyof User]);
        const bv = String(b[id as keyof User]);
        return desc ? bv.localeCompare(av) : av.localeCompare(bv);
      });
    }
    return rows;
  });

  const pageData = $derived(
    filtered.slice(
      pagination.pageIndex * pagination.pageSize,
      (pagination.pageIndex + 1) * pagination.pageSize,
    ),
  );

  const onGlobalFilterChange = (v: string) => {
    globalFilter = v;
    pagination = { ...pagination, pageIndex: 0 };
  };
  const onPaginationChange = (v: Updater<PaginationState>) => {
    pagination = typeof v === 'function' ? v(pagination) : v;
  };
  const onSortingChange = (v: Updater<SortingState>) => {
    sorting = typeof v === 'function' ? v(sorting) : v;
  };
</script>

<div class="wrap">
  <DataTable
    {columns}
    data={pageData}
    total={filtered.length}
    {globalFilter}
    {onGlobalFilterChange}
    {pagination}
    {onPaginationChange}
    {sorting}
    {onSortingChange}
    searchPlaceholder="Search by name, email, role…"
  />
</div>

<style>
  .wrap { width: 100%; min-width: 0; }
</style>

Groups, resize, reorder and interactive cells

Four options turn the table into a backlog or board in table form. They are independent: use any of them alone.

  • Row groups. groupBy={(row) => row.sprint} renders one <tbody> per key, in first-appearance order, each with a header row. The header is a button with aria-expanded that collapses its rows; groupLabel replaces its content.
  • Column resize. Set meta: { resizable: true } on a column, with size, minSize and maxSize on the definition. The header gets a handle that follows the window splitter pattern: drag it with a mouse, pen or finger, or focus it and press the arrow keys (10px, 40px with Shift), Home (minimum), End (maximum) and Enter (reset). Double click resets too. Once a column is resizable the table uses a fixed layout and scrolls sideways when its columns are wider than the container.
  • Row reorder. onReorder adds a drag grip to every row, built on the drag and drop primitive: mouse, touch (press and hold) and keyboard (Space lifts, arrows move, Enter drops, Escape cancels), announced to screen readers. Groups exchange rows. DataTable never changes data: the handler gets { id, from, to } with { group, index } positions inside a group and you apply them. Give rows stable ids with rowId.
  • Interactive cells. A cell can render any component (renderComponent(Button, …), menus, inline editors). A click on a link, button, input or other control inside a cell does not reach onRowClick; mark any other element with data-no-row-click to opt it out too.

Only the current page is in the DOM, so a table of thousands of rows stays fast: raise pageSize (simple mode) or page on the server (controlled mode).

#433

Groups

Collapsible sprints, a resizable Task column, rows that move between sprints by drag or keyboard, and a Button inside a cell.

5 results

Press Space to lift, the arrow keys to move, Enter to drop and Escape to cancel. Touch: press and hold, then drag.

Reorder Owner Details
Audit contrast tokensAna
Write the export guideBruno
Ship the date picker fixCarla
Wire the plugin installDiego
Review the rail magnificationElena
Rows
1 / 1 · 5

Nothing opened yet

Reference #433 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { createRawSnippet } from 'svelte';
  import { renderComponent } from '@tanstack/svelte-table';
  import { Button } from '@codeatlasdev/ui';
  import { DataTable, DataTableColumnHeader, createCaColumnHelper, type DataTableReorderEvent } from '@codeatlasdev/ui/table';

  interface Task {
    id: string;
    title: string;
    owner: string;
    sprint: string;
  }

  let tasks = $state<Task[]>([
    { id: 't1', title: 'Audit contrast tokens', owner: 'Ana', sprint: 'Backlog' },
    { id: 't2', title: 'Write the export guide', owner: 'Bruno', sprint: 'Backlog' },
    { id: 't3', title: 'Ship the date picker fix', owner: 'Carla', sprint: 'Backlog' },
    { id: 't4', title: 'Wire the plugin install', owner: 'Diego', sprint: 'Sprint 12' },
    { id: 't5', title: 'Review the rail magnification', owner: 'Elena', sprint: 'Sprint 12' },
  ]);
  let last = $state('Nothing opened yet');

  const col = createCaColumnHelper<Task>();
  const columns = col.columns([
    col.accessor('title', {
      header: ({ column }) => renderComponent(DataTableColumnHeader<Task, string>, { column, title: 'Task' }),
      meta: { label: 'Task', resizable: true },
      size: 320,
      minSize: 160,
      maxSize: 560,
    }),
    col.accessor('owner', { header: 'Owner', meta: { label: 'Owner' } }),
    col.display({
      id: 'open',
      header: 'Details',
      cell: ({ row }) =>
        renderComponent(Button, {
          size: 'sm',
          variant: 'outlined',
          children: createRawSnippet(() => ({ render: () => '<span>Open</span>' })),
          'aria-label': `Open ${row.original.title}`,
          onclick: () => (last = `Opened ${row.original.title}`),
        }),
    }),
  ]);

  function onReorder(e: DataTableReorderEvent) {
    const item = tasks.find((t) => t.id === e.id);
    if (!item) return;
    const rest = tasks.filter((t) => t.id !== e.id);
    const target = e.to.group ?? item.sprint;
    const before = rest.filter((t) => t.sprint === target)[e.to.index];
    item.sprint = target;
    rest.splice(before ? rest.indexOf(before) : rest.length, 0, item);
    tasks = rest;
  }
</script>

<div class="wrap">
  <DataTable
    {columns}
    data={tasks}
    rowId={(t) => t.id}
    groupBy={(t) => t.sprint}
    {onReorder}
    onRowClick={(t) => (last = `Selected ${t.title}`)}
  />
  <p class="note" aria-live="polite">{last}</p>
</div>

<style>
  .wrap { width: 100%; min-width: 0; display: grid; gap: var(--ca-space-2); }
  .note { margin: 0; color: var(--ca-text-secondary); font-size: var(--ca-font-size-body2); }
</style>

Props

PropTypeDefaultDescription
columnsColumnDef[]–Column definitions created with `createCaColumnHelper`. Required.
dataTData[]–Row data. In simple mode pass all rows; in controlled mode pass the current page. Required.
totalnumberdata.lengthTotal row count after filtering, used to compute the page count. Ignored in simple mode.
globalFilterstringundefinedSearch value. Passing it switches the table to controlled mode.
onGlobalFilterChange(v: string) => voidundefinedCalled when the user types in the search box.
paginationPaginationStateundefinedControlled `{ pageIndex, pageSize }`.
onPaginationChange(v: Updater<PaginationState>) => voidundefinedCalled when the page or page size changes.
sortingSortingStateundefinedControlled sorting, `[{ id, desc }]`.
onSortingChange(v: Updater<SortingState>) => voidundefinedCalled when the user clicks a column header.
labels{ noResults?, noResultsSub?, clearSearch? }undefinedOverrides the strings of the no-results state.
searchPlaceholderstringundefinedPlaceholder of the search input.
isLoadingbooleanundefinedShows a loading indicator in the toolbar.
isExportingbooleanundefinedShows the export button as busy.
onExport() => voidundefinedEnables the export button in the toolbar.
onRowClick(row: TData) => voidundefinedCalled when a row is clicked. Clicks on a control inside a cell (link, button, input, `data-no-row-click`) are ignored.
rowId(row: TData) => stringundefinedStable id of a row. Defaults to its index; set it when you use `onReorder`.
groupBy(row: TData) => stringundefinedRows with the same key share a collapsible `<tbody>`, in first-appearance order.
groupLabelSnippet<[{ group: string; count: number }]>undefinedCustom content of a group header button.
onReorder(e: DataTableReorderEvent) => voidundefinedShows a drag grip on every row and reports `{ id, from, to }` with `{ group, index }` positions. You apply the move to `data`.
pageSizenumber10Rows per page in simple mode.
hasActiveFiltersbooleanundefinedTreats the table as filtered, which suppresses the empty state.
emptyStateSnippetundefinedCustom content shown when there is no data and no active filter.
childrenSnippetundefinedFilter chips such as `DataTableFacetedFilter`, rendered in the toolbar.
classstring''Extra class names on the wrapper.

Building blocks

DataTable is assembled from the parts below, all exported from @codeatlasdev/ui/table. You rarely need them: use them standalone when you render your own <table> markup around a table instance made with createCaTable, for example a layout DataTable does not offer. Inside DataTable the toolbar, pagination and column toggle are already wired. Use DataTableColumnHeader as a column header to get a sortable header, and pass DataTableFacetedFilter as children of DataTable.

<script lang="ts">
  import { renderComponent } from "@tanstack/svelte-table";
  import { createCaColumnHelper, DataTableColumnHeader } from "@codeatlasdev/ui/table";
  type User = { name: string };

  const col = createCaColumnHelper<User>();
  const columns = col.columns([
    col.accessor("name", {
      header: ({ column }) =>
        renderComponent(DataTableColumnHeader<User, string>, { column, title: "Name" }),
      meta: { label: "Name" },
    }),
  ]);
</script>

createCaColumnHelper, createCaTable, caTableFeatures and the CaColumn, CaColumnDef, CaColumnMeta, CaTable and CaTableFeatures types are exported from the same entry point. meta.label is the name shown in the column visibility menu.

DataTableToolbar props

PropTypeDefaultDescription
tableCaTable<TData>–Table instance from `createCaTable`. Required.
totalnumber–Result count shown in the toolbar. Required.
globalFilterstring–Current search value. Required.
onGlobalFilterChange(v: string) => void–Called when the user types in the search box. Required.
searchPlaceholderstringundefinedPlaceholder and accessible name of the search input.
isLoadingbooleanundefinedShows a spinner.
isExportingbooleanundefinedDisables the export button and shows a spinner in it.
onExport() => voidundefinedRenders the export button. It is disabled while `total` is 0.
childrenSnippetundefinedFilter controls rendered next to the search box.

The toolbar always includes DataTableColumnToggle on its right side.

DataTablePagination props

PropTypeDefaultDescription
tableCaTable<TData>–Table instance from `createCaTable`. Required.
totalnumber–Total row count shown next to the page indicator. Required.

It renders a page size select (10, 20, 50 or 100 rows), a page / pages indicator and previous and next buttons.

DataTableColumnHeader props

PropTypeDefaultDescription
columnCaColumn<TData, TValue>–Column the header belongs to. Required.
titlestring–Header text. Required.
classstring''Extra class names.

A sortable column renders a button that toggles sorting and shows the direction icon, and its <th> carries aria-sort. A column that cannot sort renders the plain title.

DataTableColumnResize props

PropTypeDefaultDescription
columnCaColumn<TData, TValue>–Column to resize. Required.
labelstring–Accessible name of the handle, such as `Resize Task`. Required.
stepnumber10Keyboard step in px; Shift uses four times as much.

DataTable renders it for every column with meta.resizable. Use it in your own header markup when you build the table yourself.

DataTableColumnToggle props

PropTypeDefaultDescription
tableCaTable<TData>–Table instance from `createCaTable`. Required.

A menu of checkboxes, one per column that can be hidden. It follows the menu keyboard pattern: arrow keys move, Space and Enter toggle, Escape closes.

DataTableFacetedFilter props

PropTypeDefaultDescription
titlestring–Label of the filter button. Required.
facetsRecord<string, number>{}Option key to result count. Options are sorted by count, highest first.
labelMapRecord<string, string>{}Maps option keys to display labels.
selectedstring[][]Selected option keys.
onSelectionChange(values: string[]) => void–Called with the new selection. Required.