For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Support

DataGrid

Strictly typed server-driven tables, with page and cursor navigation, native Query options, and remote selection.

Playground

Live preview
Loading results
Page 1
Page 1
In-memory async simulation, not a backend. Loading resolves after 10 seconds. Reset remounts configuration and all transient grid state. Generated code reproduces configuration, not current selection or filters.
Customize DataGrid
Customize
datagrid-demo.tsx
"use client";

import { QueryClient, QueryClientProvider, queryOptions } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { z } from "zod";
import { createDataGridContract } from "@/components/data-grid/schema";
import { createDataGridColumnHelper, useDataGrid } from "@/components/data-grid";
import { DataGrid, DataGridToolbar, DataGridSearch, DataGridApplyFilters, DataGridResetFilters, DataGridColumnVisibility, DataGridSelectionBar, DataGridViewport, DataGridTable, DataGridPagination } from "@/components/data-grid";

const contract = createDataGridContract({
  pagination: "page",
  row: z.object({ id: z.string(), name: z.string() }),
  filters: z.object({ search: z.string().default("") }),
  sortBy: z.literal("name"),
});
const column = createDataGridColumnHelper(contract);
const columns = [column.accessor("name", { header: "Name", sortBy: "name" })];
const records = Array.from({ length: 78 }, (_, index) => ({ id: String(index), name: "User " + (index + 1) }));

function Users() {
  const grid = useDataGrid({
    contract, columns, getRowId: row => row.id,
    filterMode: "immediate", selectionMode: "none",
    queryOptions: input => queryOptions({
      queryKey: ["users-demo", "ready", input],
      retry: false, enabled: true,
      queryFn: async ({ signal }) => {
        // In-memory backend simulation. Replace with an authorized transport.
        await new Promise<void>((resolve, reject) => {
          if (signal.aborted) { reject(new DOMException("Cancelled", "AbortError")); return; }
          const abort = () => { clearTimeout(timer); reject(new DOMException("Cancelled", "AbortError")); };
          const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, 120);
          signal.addEventListener("abort", abort, { once: true });
        });
        const rows = records.filter(row => row.name.toLowerCase().includes(input.filters.search.toLowerCase()));
        rows.sort((a, b) => {
          const direction = input.sorting[0]?.desc ? -1 : 1;
          return input.sorting.length ? direction * a.name.localeCompare(b.name) || Number(a.id) - Number(b.id) : Number(a.id) - Number(b.id);
        });
        const start = input.pagination.pageIndex * input.pagination.pageSize;
        const end = start + input.pagination.pageSize;
        return contract.output.parse({ rows: rows.slice(start, end), rowCount: rows.length });
      },
    }),
  });
  return <DataGrid grid={grid} variant="default" density="comfortable">
    <DataGridToolbar>
      <DataGridSearch grid={grid} field="search" clearable aria-label="Search users" placeholder="Search users" />
      <DataGridResetFilters /><DataGridColumnVisibility />
    </DataGridToolbar>
    <DataGridSelectionBar />
    <DataGridViewport className="max-h-80">
      <DataGridTable grid={grid} aria-label="Users" stickyHeader={false} />
    </DataGridViewport>
    <DataGridPagination showPageNumbers={true} />
  </DataGrid>;
}
export default function UsersDemo() {
  const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } }));
  useEffect(() => () => client.clear(), [client]);
  return <QueryClientProvider client={client}><Users /></QueryClientProvider>;
}

Installation

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

# From the Vandor UI checkout, after initializing shadcn in the consumer:
pnpm registry:build
node scripts/install-component-family.cjs data-grid /absolute/path/to/consumer

The default installation target is components/data-grid/data-grid.tsx. Review the CLI's overwrite prompt before replacing local customizations.

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies5 required packages
  • @tanstack/react-table@^9.2.6
  • @tanstack/react-query@^5.104.1
  • zod@^4.3.6
  • cn
  • lucide-react
$ pnpm add @tanstack/react-table@^9.2.6 @tanstack/react-query@^5.104.1 zod@^4.3.6 cn lucide-react

Usage

Supply an existing QueryClientProvider. Define the domain once with Zod, derive columns from it, and pass native Query options to useDataGrid. No duplicate wire interfaces or repeated generic arguments are needed.

Loading results
Page 1
Page 1
data-grid-demo.tsx
"use client";

import {
  QueryClient,
  QueryClientProvider,
  queryOptions,
} from "@tanstack/react-query";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";

import {
  DataGrid,
  DataGridTable,
  DataGridSearch,
  DataGridToolbar,
  DataGridPagination,
} from "@/components/ui/data-grid";
import { useDataGrid } from "@/components/ui/use-data-grid";

import { usersGrid, userColumns, listDemoUsers } from "./data-grid-demo-data";

export function DataGridExampleProvider({ children }: { children: ReactNode }) {
  // This short-lived example scope is cleared on unmount, so it needs no GC timer.
  const [client] = useState(
    () =>
      new QueryClient({
        defaultOptions: { queries: { gcTime: Infinity, retry: false } },
      })
  );
  useEffect(() => () => client.clear(), [client]);
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
function Users() {
  const grid = useDataGrid({
    columns: userColumns,
    contract: usersGrid,
    getRowId: (user) => user.id,
    queryOptions: (input) =>
      queryOptions({
        queryKey: ["demo-users", input],
        queryFn: ({ signal }) => listDemoUsers(input, signal),
      }),
  });
  return (
    <DataGrid grid={grid} density="compact" className="max-w-full">
      <DataGridToolbar>
        <DataGridSearch
          grid={grid}
          field="search"
          clearable
          aria-label="Search demo users"
          placeholder="Search users"
        />
      </DataGridToolbar>
      <DataGridTable grid={grid} aria-label="Demo users" />
      <DataGridPagination />
    </DataGrid>
  );
}
export function DataGridDemo() {
  return (
    <DataGridExampleProvider>
      <Users />
    </DataGridExampleProvider>
  );
}

Examples use an asynchronous in-memory backend simulation, not a database. It processes filters, deterministic sorting, and only the requested page. Replace the service with an authorized transport. Query owns remote data, cancellation, retries, and cache freshness; DataGrid never copies rows into another cache or replaces your select, keys, enabled, or placeholder policy.

Consumer-owned data without a Query provider

Local source only: complete or fail each request manually. Refresh keeps existing rows. Your application owns transport, cache and response ordering.

Source state: loading
Loading results
Page 1
Page 1
data-grid-source-demo.tsx
"use client";

import { useState } from "react";
import { z } from "zod";

import { Button } from "@/components/ui/button";
import {
  DataGrid,
  DataGridPagination,
  DataGridSearch,
  DataGridSelectionBar,
  DataGridTable,
  DataGridToolbar,
} from "@/components/ui/data-grid";
import { createDataGridColumnHelper } from "@/components/ui/data-grid-columns";
import { createDataGridContract } from "@/components/ui/data-grid-schema";
import { useDataGridSource } from "@/components/ui/use-data-grid-source";

const contract = createDataGridContract({
  filters: z.object({ search: z.string().default("") }),
  pagination: "page",
  row: z.object({ id: z.string(), name: z.string() }),
  sortBy: z.literal("name"),
});
const column = createDataGridColumnHelper(contract);
const columns = [column.accessor("name", { header: "Name", sortBy: "name" })];
const records = Array.from({ length: 60 }, (_, index) => ({
  id: String(index),
  name: `Person ${String(index + 1).padStart(2, "0")}`,
}));

/** Local deterministic source, not a transport/cache or stale-response example. */
export const DataGridSourceDemo = () => {
  const [phase, setPhase] = useState<
    "loading" | "ready" | "refreshing" | "error" | "backgroundError"
  >("loading");
  const [request, setRequest] = useState(() =>
    contract.input.parse({ filters: {} })
  );
  const matching = records.filter((row) =>
    row.name.toLowerCase().includes(request.filters.search.toLowerCase())
  );
  const [sort] = request.sorting;
  if (sort) {
    matching.sort(
      (a, b) => (sort.desc ? -1 : 1) * a.name.localeCompare(b.name)
    );
  }
  const start = request.pagination.pageIndex * request.pagination.pageSize;
  const hasData = phase !== "loading" && phase !== "error";
  const pending = phase === "loading" || phase === "refreshing";
  const grid = useDataGridSource({
    columns,
    contract,
    getRowId: (row) => row.id,
    onStateChange: { request: setRequest },
    selectionMode: "allMatching",
    source: {
      data: hasData
        ? {
            rowCount: matching.length,
            rows: matching.slice(start, start + request.pagination.pageSize),
          }
        : undefined,
      error:
        phase === "error" || phase === "backgroundError"
          ? new Error("Simulated source failure")
          : undefined,
      loading: phase === "loading",
      refetch: () => setPhase(hasData ? "refreshing" : "loading"),
      refreshing: phase === "refreshing",
    },
    state: { request },
  });
  return (
    <div className="grid w-full gap-3">
      <p className="text-sm text-muted-foreground">
        Local source only: complete or fail each request manually. Refresh keeps
        existing rows. Your application owns transport, cache and response
        ordering.
      </p>
      <div className="flex flex-wrap gap-2">
        <Button size="sm" disabled={!pending} onClick={() => setPhase("ready")}>
          Complete request
        </Button>
        <Button
          size="sm"
          variant="outline"
          disabled={pending}
          onClick={() => grid.query.refetch()}
        >
          Refresh source
        </Button>
        <Button
          size="sm"
          variant="outline"
          disabled={!pending}
          onClick={() => setPhase(hasData ? "backgroundError" : "error")}
        >
          Fail request
        </Button>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => {
            grid.resetFilters();
            grid.clearSelection();
            setRequest(contract.input.parse({ filters: {} }));
            setPhase("loading");
          }}
        >
          Restart demo
        </Button>
      </div>
      <output className="text-xs text-muted-foreground" aria-live="polite">
        Source state: {phase}
      </output>
      <DataGrid grid={grid} density="compact">
        <DataGridToolbar>
          <DataGridSearch
            grid={grid}
            field="search"
            clearable
            aria-label="Search source people"
          />
        </DataGridToolbar>
        <DataGridSelectionBar />
        <DataGridTable grid={grid} aria-label="Source-owned people" />
        <DataGridPagination pageSizes={[25]} />
      </DataGrid>
    </div>
  );
};

Import useDataGridSource from the installed @/components/data-grid entry. It shares the request, filter, table, pagination and selection controller with useDataGrid. Pass the same contract, columns, getRowId, and optional controller state/callbacks, plus source: { data, error?, loading?, refreshing?, refetch }. data is the contract output or undefined; refetch is required. No fetch, cache, or QueryClient provider is created by this adapter. Existing package installation dependencies remain unchanged.

The example owns a controlled request and slices deterministic local records. Use Complete request or Fail request to finish the initial load, then Refresh source to retain rows during refresh; Try again calls the source's refetch callback. Restart demo restores the initial request and selection. These are explicit local transitions, not network requests or a transport implementation.

For a remote source, the consumer owns authorization, caching, cancellation, and correspondence between data and the current request, including stale-response arbitration. Do not pass another request's cached rows as current data. With no data, loading means initial loading, an error means error, and no activity means inactive. With data, refreshing/loading retains rows with refreshing feedback, error retains rows with background-error feedback, and zero rows means empty. Cursor history commits successful current output only. grid.query on this path exposes only data, error, and refetch, not a fabricated native Query result.

useDataGrid continues to expose the real native Query result and preserve select inference. Both adapters keep selection across pagination/sorting and clear it when effective filters change. The pageSizes selector is hidden for zero or one choice, without hiding range announcements or navigation.

Consumer setup

Requires React 19-compatible Vandor primitives, Tailwind v4, Vandor/shadcn theme tokens (background, foreground, muted, accent, border, ring, destructive), and the existing primitives' animation setup. Verified integration uses Table 9.2.6, Query 5.104.1, and Zod 4.3.6. Backend-only schema installation has no React runtime imports:

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/data-grid-schema.json

Use CLI installation for the complete UI. Copying the Source block alone is insufficient: DataGrid also needs data-grid-columns, data-grid-context, data-grid-controls, data-grid-controller, data-grid-feedback, data-grid-labels, data-grid-selection, data-grid-state, use-data-grid, use-data-grid-source, data-grid-schema, and the shared error-state-base module, plus Button, Checkbox, Select, Dropdown, InputSearch, Empty and their dependencies. Review overwrite prompts for existing customized primitives. Supporting files share identical content with their owning registry items.

Backend reuse

createDataGridContract exposes normal .input, .output, .row, .filters, .pagination, and .sorting Zod schemas. Reuse them in REST/server functions or tRPC:

const listUsers = procedure
  .input(usersGrid.input)
  .output(usersGrid.output)
  .query(({ input, ctx }) => ctx.users.listAuthorized(input));
const grid = useDataGrid({
  contract: usersGrid,
  columns,
  getRowId: (row) => row.id,
  queryOptions: (input) => trpc.users.list.queryOptions(input),
});

These are integration fragments; create the router/provider in your application. The native query key must include all result-affecting request fields and authorization/tenant scope. DataGrid does not rewrite tRPC keys or prove key completeness. Explicitly validate transport outputs at the transport boundary, not through a hidden grid select.

Contracts default to 25 rows, maximum 100, empty sorting, and null initial cursors. Builders accept pageSize/maxPageSize. Page inputs use zero-based pageIndex; output { rows, rowCount } counts all filtered results. Cursor outputs use { rows, nextCursor }, with opaque strings or null. Sort keys are independently allowlisted; duplicate keys and unsafe integer bounds are rejected.

Raw drafts and transforms

Draft bindings infer z.input, while request and query factories use normalized z.output. Changes parse raw filters once; pagination never parses applied filters again. The initial raw baseline is retained. Restoring externally controlled normalized filters uses Zod encode: transformed filters requiring this inverse must use reversible Zod v4 codecs. A one-way transform needing restoration throws a clear diagnostic rather than applying the transform twice. Row/transport transforms are unaffected.

Cursor pagination

Page pagination shows Previous/Next icons and numbered links by default, for example ‹ 1 2 3 … 8 ›. First and last numbers remain reachable; the active number uses aria-current="page". Set <DataGridPagination showPageNumbers={false} /> for icon-only First/Previous/Next/Last controls with a page indicator instead. Icons retain localized accessible names. While totals are unavailable or placeholder rows belong to another request, numbered links are not inferred from stale metadata.

Loading results
Email
Page 1
Page 1
data-grid-cursor-demo.tsx
"use client";

import { queryOptions } from "@tanstack/react-query";

import {
  DataGrid,
  DataGridPagination,
  DataGridSearch,
  DataGridTable,
  DataGridToolbar,
} from "@/components/ui/data-grid";
import { createDataGridColumnHelper } from "@/components/ui/data-grid-columns";
import { useDataGrid } from "@/components/ui/use-data-grid";

import { DataGridExampleProvider } from "./data-grid-demo";
import { cursorUsersGrid, listCursorDemoUsers } from "./data-grid-demo-data";

const column = createDataGridColumnHelper(cursorUsersGrid);
const columns = [
  column.accessor("name", { header: "Name", sortBy: "name" }),
  column.accessor("email", { header: "Email" }),
];
function Users() {
  const grid = useDataGrid({
    columns,
    contract: cursorUsersGrid,
    getRowId: (row) => row.id,
    queryOptions: (input) =>
      queryOptions({
        queryKey: ["cursor-demo", input],
        queryFn: ({ signal }) => listCursorDemoUsers(input, signal),
      }),
  });
  return (
    <DataGrid grid={grid}>
      <DataGridToolbar>
        <DataGridSearch
          grid={grid}
          field="search"
          aria-label="Search cursor results"
        />
      </DataGridToolbar>
      <DataGridTable grid={grid} aria-label="Cursor users" />
      <DataGridPagination />
    </DataGrid>
  );
}
export function DataGridCursorDemo() {
  return (
    <DataGridExampleProvider>
      <Users />
    </DataGridExampleProvider>
  );
}

Next uses current successful response metadata. Previous uses successfully visited cursors, not cached rows. Failed requests remain retryable and do not append history. Filtering, sorting, and page-size changes reset history. There is no arbitrary jump, total, or last-page action. A remount/deep link starts fresh local history.

Filters and Apply

Calendar Apply changes this draft. Apply filters sends the complete group. Dates are local calendar dates, not UTC timestamps.

Loading results
Page 1
Page 1
data-grid-filters-demo.tsx
"use client";

import { queryOptions } from "@tanstack/react-query";
import { format, parseISO } from "date-fns";

import {
  DataGrid,
  DataGridApplyFilters,
  DataGridFilter,
  DataGridPagination,
  DataGridResetFilters,
  DataGridSearch,
  DataGridTable,
  DataGridToolbar,
} from "@/components/ui/data-grid";
import { DateRangePicker } from "@/components/ui/date-range-picker";
import { Select } from "@/components/ui/select";
import { useDataGrid } from "@/components/ui/use-data-grid";

import { DataGridExampleProvider } from "./data-grid-demo";
import { usersGrid, userColumns, listDemoUsers } from "./data-grid-demo-data";

function Users() {
  const grid = useDataGrid({
    columns: userColumns,
    contract: usersGrid,
    filterMode: "apply",
    getRowId: (row) => row.id,
    queryOptions: (input) =>
      queryOptions({
        queryKey: ["filter-demo", input],
        queryFn: ({ signal }) => listDemoUsers(input, signal),
      }),
  });
  return (
    <DataGrid grid={grid}>
      <DataGridToolbar>
        <DataGridSearch
          grid={grid}
          field="search"
          aria-label="Search users"
          clearable
        />
        <DataGridFilter grid={grid} field="status">
          {({ value, onChange, error }) => (
            <Select
              aria-label="User status"
              aria-invalid={Boolean(error)}
              value={value}
              data={[
                { label: "All users", value: "all" },
                { label: "Active", value: "active" },
                { label: "Inactive", value: "inactive" },
              ]}
              onValueChange={(value) => {
                if (value) {
                  onChange(value);
                }
              }}
            />
          )}
        </DataGridFilter>
        <DataGridFilter grid={grid} field="dates">
          {({ value, onChange }) => (
            <DateRangePicker
              label="Joined dates"
              clearable
              value={
                value
                  ? { from: parseISO(value.from), to: parseISO(value.to) }
                  : undefined
              }
              onValueChange={(range) =>
                onChange(
                  range?.from && range.to
                    ? {
                        from: format(range.from, "yyyy-MM-dd"),
                        to: format(range.to, "yyyy-MM-dd"),
                      }
                    : null
                )
              }
            />
          )}
        </DataGridFilter>
        <DataGridApplyFilters />
        <DataGridResetFilters />
      </DataGridToolbar>
      <p className="text-xs text-muted-foreground">
        Calendar Apply changes this draft. Apply filters sends the complete
        group. Dates are local calendar dates, not UTC timestamps.
      </p>
      <DataGridTable grid={grid} aria-label="Filtered users" />
      <DataGridPagination />
    </DataGrid>
  );
}
export function DataGridFiltersDemo() {
  return (
    <DataGridExampleProvider>
      <Users />
    </DataGridExampleProvider>
  );
}

Immediate mode validates each changed field, with Search debounced 300 ms by default. Invalid values remain visible for correction without issuing a request. Independent debounces merge into the current applied filters; clear/reset cancels pending timers. Apply mode edits a whole draft and commits immediately when Apply is clicked, without waiting for debounce. Reset restores the captured initial baseline and resets pagination. Field reset restores only that field.

DataGridFilter and DataGridSearch take an explicit typed grid prop because JSX parent context cannot infer domain field/value types. The render binding supplies value, onChange, reset, and error; it accepts arbitrary controls. The date example maps DateRangePicker's committed dates to local yyyy-MM-dd calendar strings. Calendar Cancel does not change the grid draft. Calendar Apply changes the draft; grid Apply sends the group. Pick date-only versus timestamp semantics explicitly; there is no implicit UTC conversion.

Remote selection

Loading results
Page 1
Page 1
{
  "ids": [],
  "mode": "explicit"
}

All matching means matching at execution time. The backend still validates authorization and exclusions.

data-grid-selection-demo.tsx
"use client";

import { queryOptions } from "@tanstack/react-query";

import {
  DataGrid,
  DataGridPagination,
  DataGridSearch,
  DataGridSelectionBar,
  DataGridTable,
  DataGridToolbar,
} from "@/components/ui/data-grid";
import { useDataGrid } from "@/components/ui/use-data-grid";

import { DataGridExampleProvider } from "./data-grid-demo";
import { usersGrid, userColumns, listDemoUsers } from "./data-grid-demo-data";

function Users() {
  const grid = useDataGrid({
    columns: userColumns,
    contract: usersGrid,
    getRowId: (row) => row.id,
    queryOptions: (input) =>
      queryOptions({
        queryKey: ["selection-demo", input],
        queryFn: ({ signal }) => listDemoUsers(input, signal),
      }),
    selectionMode: "allMatching",
  });
  return (
    <DataGrid grid={grid}>
      <DataGridToolbar>
        <DataGridSearch
          grid={grid}
          field="search"
          aria-label="Search selected users"
        />
      </DataGridToolbar>
      <DataGridSelectionBar />
      <DataGridTable grid={grid} aria-label="Selectable users" />
      <DataGridPagination />
      <pre
        className="max-h-40 overflow-auto text-xs"
        aria-label="Selection descriptor"
      >
        {JSON.stringify(grid.selection, null, 2)}
      </pre>
      <p className="text-xs text-muted-foreground">
        All matching means matching at execution time. The backend still
        validates authorization and exclusions.
      </p>
    </DataGrid>
  );
}
export function DataGridSelectionDemo() {
  return (
    <DataGridExampleProvider>
      <Users />
    </DataGridExampleProvider>
  );
}

Selection defaults to none. Explicit IDs survive paging and sorting. The header checkbox toggles only the visible eligible page and supports indeterminate state. Opt-in all-matching selection emits { mode: "allMatching", filters, excludedIds }, not a million-ID list. Deselected rows/pages become exclusions. Effective applied-filter changes clear selection; draft edits do not. Placeholder rows cannot be selected, and Clear remains available.

Validate descriptors with createDataGridSelectionSchema({ filters, id }). UI IDs are stable unique strings; selection schemas deduplicate IDs/exclusions. All-matching means matching at action execution time, not a frozen dataset. Backend authorization, tenant boundaries, eligibility, destructive confirmation, and exact-time snapshot tokens remain application responsibilities. Totals are informational; changed/deleted exclusions can make an exact selected count unknowable. Do not submit only Table's loaded selected rows as the full remote selection.

Controlled state

Loading results
Page 1
Page 1
{
  "filters": {
    "dates": null,
    "search": "",
    "status": "all"
  },
  "pagination": {
    "pageIndex": 0,
    "pageSize": 25
  },
  "sorting": []
}
data-grid-controlled-demo.tsx
"use client";

import { queryOptions } from "@tanstack/react-query";
import { useState } from "react";

import {
  DataGrid,
  DataGridColumnVisibility,
  DataGridPagination,
  DataGridSearch,
  DataGridTable,
  DataGridToolbar,
} from "@/components/ui/data-grid";
import { useDataGrid } from "@/components/ui/use-data-grid";

import { DataGridExampleProvider } from "./data-grid-demo";
import { usersGrid, userColumns, listDemoUsers } from "./data-grid-demo-data";

function Users() {
  const [request, setRequest] = useState(() =>
    usersGrid.input.parse({ filters: {} })
  );
  const [columnVisibility, setColumnVisibility] = useState<
    Record<string, boolean>
  >({});
  const [columnOrder, setColumnOrder] = useState(["name", "visits", "joined"]);
  const grid = useDataGrid({
    columns: userColumns,
    contract: usersGrid,
    getRowId: (row) => row.id,
    onStateChange: {
      columnOrder: setColumnOrder,
      columnVisibility: setColumnVisibility,
      request: setRequest,
    },
    queryOptions: (input) =>
      queryOptions({
        queryKey: ["controlled-demo", input],
        queryFn: ({ signal }) => listDemoUsers(input, signal),
      }),
    state: { columnOrder, columnVisibility, request },
  });
  return (
    <DataGrid grid={grid}>
      <DataGridToolbar>
        <DataGridSearch
          grid={grid}
          field="search"
          aria-label="Search controlled users"
        />
        <DataGridColumnVisibility />
      </DataGridToolbar>
      <DataGridTable grid={grid} aria-label="Controlled users" />
      <DataGridPagination />
      <pre
        className="max-h-36 overflow-auto text-xs"
        aria-label="Applied request"
      >
        {JSON.stringify(request, null, 2)}
      </pre>
    </DataGrid>
  );
}
export function DataGridControlledDemo() {
  return (
    <DataGridExampleProvider>
      <Users />
    </DataGridExampleProvider>
  );
}

Control slices independently through state and matching onStateChange callbacks: request, columnVisibility, columnOrder, selection. Request contains pagination, applied filters, and sorting as one atomic value. A supplied slice is authoritative; callbacks propose values without competing internal writes. Parent updates actually apply controlled requests. External filter restoration rebases raw drafts and cancels pending edits. initialState is read once and is the reset baseline, not a controlled prop.

URL/router adapters remain application-owned. Remount with a scope key when tenant/resource identity changes to clear incompatible draft, selection, and cursor history. Never carry placeholder data across an authorization scope.

Rendering and columns

Loading results
ScoreActions
Page 1
Page 1
data-grid-rendering-demo.tsx
"use client";

import { queryOptions } from "@tanstack/react-query";

import {
  DataGrid,
  DataGridCell,
  DataGridColumnVisibility,
  DataGridPagination,
  DataGridRow,
  DataGridTable,
  DataGridToolbar,
  DataGridViewport,
} from "@/components/ui/data-grid";
import { useDataGrid } from "@/components/ui/use-data-grid";

import { DataGridExampleProvider } from "./data-grid-demo";
import { usersGrid, userColumn, listDemoUsers } from "./data-grid-demo-data";

const columns = [
  userColumn.accessor("name", { header: "Name", sortBy: "name" }),
  userColumn.accessor((row) => row.visits * 2, {
    cell: ({ value }) => value.toFixed(0),
    header: "Score",
    id: "score",
  }),
  userColumn.display({
    cell: ({ row }) => (
      <a href={`mailto:${row.email}`} className="underline underline-offset-4">
        Email {row.name}
      </a>
    ),
    enableHiding: false,
    header: "Actions",
    id: "actions",
  }),
];
function Users() {
  const grid = useDataGrid({
    columns,
    contract: usersGrid,
    getRowId: (row) => row.id,
    queryOptions: (input) =>
      queryOptions({
        queryKey: ["rendering-demo", input],
        queryFn: ({ signal }) => listDemoUsers(input, signal),
      }),
  });
  return (
    <DataGrid grid={grid} variant="striped" density="compact">
      <DataGridToolbar>
        <DataGridColumnVisibility />
      </DataGridToolbar>
      <DataGridViewport className="max-h-80">
        <DataGridTable
          grid={grid}
          stickyHeader
          aria-label="Custom rendered users"
          renderRow={(context) => (
            <DataGridRow {...context.props} data-user={context.row.id} />
          )}
          renderCell={(context) => (
            <DataGridCell
              {...context.props}
              className={
                context.native.column.id === "score"
                  ? "tabular-nums"
                  : undefined
              }
            />
          )}
        />
      </DataGridViewport>
      <DataGridPagination />
    </DataGrid>
  );
}
export function DataGridRenderingDemo() {
  return (
    <DataGridExampleProvider>
      <Users />
    </DataGridExampleProvider>
  );
}

Column definitions are the single configuration source. Key accessors infer the field value; computed accessors need a stable ID and infer their result; display columns need an ID but no accessor. cell({ value, row, native }) receives domain data and the real Table context. Backend sortBy, column ID, label, and accessor are independent. Non-text headers require a text label. Multiple columns sharing a backend sort key do not produce duplicate request sort entries.

Array order initializes display order. Restored columnOrder ignores obsolete IDs and appends missing known IDs; non-hideable columns stay visible. Default cells show strings/numbers directly and a placeholder for null/undefined/complex values; explicitly format currency, dates, and objects.

renderColumn, renderRow, and renderCell receive grid, native context, and spreadable primitive props; row/cell overrides also receive the inferred domain row. Heterogeneous Table-level cell values are unknown; use typed column cell callbacks for value-specific rendering. Spread context.props to retain default children, selection/sorting controls, and semantics. Replacing children or an entire primitive makes native/ref/event responsibility yours. Use accessible links/buttons for navigation, not click-only rows.

Root owns variant/density/labels. Column, Row, and Cell are native th/tr/td primitives, not JSX registration effects. Table supplies overflow by default; an explicit Viewport becomes the sole scroll container. Sticky headers stay within that viewport. Toolbar, pagination, and selection bar are optional presentation.

Behavior & Accessibility

  • Server processing only: returned backend order is retained, without client-side filtering/sorting/pagination factories. A filter/sort/page-size change resets pagination in one request. Three-state sorting cycles ascending, descending, unsorted; Shift adds multi-sort priority.
  • Current successful out-of-range pages correct once to the last valid index. Zero results display page one and 0–0, never negative ranges. Placeholder metadata never corrects or navigates another request.
  • Initial active requests show skeleton rows. Successful empty results use Empty, with reset for non-baseline filters. Initial/request-key errors use centered ErrorState with retry; same-key background errors retain rows with inline feedback. Raw backend messages/stacks are not rendered by default.
  • Loading, refreshing, updating (placeholder), and paused-with-rows announcements are screen-reader-only; they add no visible text row or status badge above the table. Loading/refreshing/updating keep aria-busy on the table. Disabled queries show neutral inactive feedback; paused requests without rows show waiting feedback instead of an endless active skeleton. Query cancellation/race policy remains native.
  • Native table semantics, keyboard-accessible sort buttons, aria-sort, labeled checkboxes, visible focus and live async/selection feedback are preserved. Add an accessible table name or caption. Custom filters must associate visible labels/help/errors with their controls.
  • Localize all visible/accessible strings through root labels; sort, selectRow, page, range, selected, and selectAllMatching are formatter functions. Avoid parsing English strings for localization. Toolbars wrap on narrow screens; wide tables scroll within their viewport.
  • Controller exposes native query/table, request, raw filterDraft, field/form errors, selection/preferences/presentation and typed actions: setFilter, setFilters, resetFilter, applyFilters, resetFilters, setSorting, setPageSize, nextPage, previousPage, toggleRow, togglePage, selectAllMatching, clearSelection. setPageIndex exists only on page grids. getFilterBinding returns the typed raw field binding.

Optional portable stories

Install DataGrid first, then add stories separately:

# From the Vandor UI checkout, with shadcn initialized in the consumer:
pnpm registry:build
node scripts/install-component-family.cjs data-grid-stories /absolute/path/to/consumer

The wrapper resolves the consumer's configured components alias; stock shadcn 4.5.0 does not support every nested alias. Stories do not reinstall core or modify .storybook. Use an existing React Storybook with matching package versions, @storybook/react types, and a story glob covering your configured components directory (**/*.stories.tsx). Types target the installed 10.6.1 version; use matching versions in your setup. Inherit your global Tailwind/theme CSS and decorators. Query-backed stories include their own QueryClientProvider and simulated service; ConsumerSource needs no provider and supports Retry recovery. There are no website imports or extra addon requirements. Storybook UI is not verified: this project has no Storybook server.

Props

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
columnsreadonly DataGridColumnDefinition[]

Both adapters: typed helper definitions, independent column ID/accessor/backend sort key.

Required
debouncenumber

DataGridSearch: immediate-mode delay in milliseconds; clear applies immediately. Apply mode ignores delay.

300
density"comfortable" | "compact"

DataGrid: cell padding.

"comfortable"
fieldInferred raw filter key

DataGridSearch / DataGridFilter: explicitly supply grid for field/value inference. Search accepts string-compatible fields only.

Required
filterMode"immediate" | "apply"

Both adapters: validate immediately or commit the whole draft.

"immediate"
getRowId(row) => string

Both adapters: stable unique domain IDs, never page indexes.

Required
gridInferred controller

DataGrid and typed Table/Search/Filter overrides: controller from useDataGrid or useDataGridSource.

Required
initialStatePartial<DataGridRawInput>

Both adapters: captured once as the uncontrolled initialization/reset baseline.

Schema defaults
labelsPartial<DataGridLabels>

DataGrid: visible and accessible labels; count/sort messages are formatter functions.

English defaults
onStateChangePer-slice callbacks

Both adapters: emits the proposed value; controlled changes apply only after the parent supplies it.

Not set
pageSizesreadonly number[]

DataGridPagination: sizes must fit contract limits; zero/one choices hide only the size selector. Cursor mode does not show total/first/last controls.

[25, 50, 100]
pagination"page" | "cursor"

Contract: numbered pages or visited cursor navigation.

Required contract choice
queryOptionsNative query-options factory

useDataGrid: input is normalized; native selected output matches the contract. Query owns all remote data.

Required
renderCell(context) => ReactNode

DataGridTable: domain row, native cell, unknown heterogeneous value and spreadable td props. Typed values belong in column cell callbacks.

DataGridCell
renderColumn(context) => ReactNode

DataGridTable: native header, grid and spreadable th props including sorting controls.

DataGridColumn
renderEmpty({ grid, filtered, reset }) => ReactNode

DataGridTable: feedback content inside a valid spanning cell.

Empty composition
renderError({ grid, error, retry, background }) => ReactNode

DataGridTable: raw errors reach custom handlers only, never default feedback text.

ErrorState composition
renderRow(context) => ReactNode

DataGridTable: inferred domain row, native row, grid and spreadable default tr props.

DataGridRow
scenario"ready" | "loading" | "empty" | "error" | "inactive"

Demo-only async simulation, not a DataGrid prop.

"ready"
selectionMode"none" | "explicit" | "allMatching"

Both adapters: remote ID selection policy.

"none"
showPageNumbersboolean

DataGridPagination: page links with ellipses instead of First/Last. Set false for four icon controls. Cursor mode always uses Previous/Next only.

true
sourceDataGridSource<C>

useDataGridSource: contract output or undefined, optional error/loading/refreshing, and refetch callback. Consumer owns transport/cache and current-request correspondence. No Query provider is required; query exposes only data/error/refetch.

Required for useDataGridSource only
state{ request?, columnVisibility?, columnOrder?, selection? }

Both adapters: supplied slices are authoritative. Request contains filters/sorting/pagination atomically.

Uncontrolled
stickyHeaderboolean

DataGridTable: sticky header within the sole viewport.

false
variant"default" | "striped" | "bordered" | "plain"

DataGrid: visual treatment only.

"default"

Root/Viewport/Table/Column/Row/Cell forward their corresponding native element attributes, refs and handlers. Search forwards InputSearch props except controlled value/defaultValue. Filter is a typed render binding, not a DOM element. ColumnVisibility has no native-prop API; Pagination/SelectionBar/Toolbar forward div props. Apply/Reset forward Button props.

Source

import { cn } from "cn";
import type * as React from "react";
 
type ErrorStateProps = React.ComponentProps<"div"> & {
  border?: "none" | "solid" | "dashed";
  variant?: "centered" | "inline";
};
 
const ErrorState = ({
  className,
  border = "none",
  variant = "centered",
  ...props
}: ErrorStateProps) => (
  <div
    data-slot="error-state"
    data-variant={variant}
    data-border={border}
    className={cn(
      "group/error-state flex w-full min-w-0 gap-4 rounded-xl text-foreground",
      border === "solid" && "border border-solid",
      border === "dashed" && "border border-dashed",
      variant === "inline"
        ? "items-start p-4 text-start"
        : "flex-col items-center justify-center p-6 text-center",
      className
    )}
    {...props}
  />
);
 
const ErrorStateMedia = ({
  className,
  ...props
}: React.ComponentProps<"div">) => (
  <div
    data-slot="error-state-media"
    className={cn(
      "flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive [&_svg]:size-5 [&_svg]:shrink-0",
      className
    )}
    {...props}
  />
);
 
const ErrorStateContent = ({
  className,
  ...props
}: React.ComponentProps<"div">) => (
  <div
    data-slot="error-state-content"
    className={cn(
      "flex w-full min-w-0 max-w-sm flex-col gap-4 group-data-[variant=inline]/error-state:max-w-none",
      className
    )}
    {...props}
  />
);
 
const ErrorStateHeader = ({
  className,
  ...props
}: React.ComponentProps<"div">) => (
  <div
    data-slot="error-state-header"
    className={cn("flex min-w-0 flex-col gap-2", className)}
    {...props}
  />
);
 
const ErrorStateTitle = ({
  className,
  ...props
}: React.ComponentProps<"div">) => (
  <div
    data-slot="error-state-title"
    className={cn(
      "text-sm font-medium tracking-tight wrap-anywhere text-balance",
      className
    )}
    {...props}
  />
);
 
const ErrorStateDescription = ({
  className,
  ...props
}: React.ComponentProps<"div">) => (
  <div
    data-slot="error-state-description"
    className={cn(
      "text-sm/relaxed text-muted-foreground wrap-anywhere",
      className
    )}
    {...props}
  />
);
 
const ErrorStateActions = ({
  className,
  ...props
}: React.ComponentProps<"div">) => (
  <div
    data-slot="error-state-actions"
    className={cn(
      "flex min-w-0 flex-wrap items-center justify-center gap-2 group-data-[variant=inline]/error-state:justify-start",
      className
    )}
    {...props}
  />
);
 
export {
  ErrorState,
  ErrorStateActions,
  ErrorStateContent,
  ErrorStateDescription,
  ErrorStateHeader,
  ErrorStateMedia,
  ErrorStateTitle,
};
export type { ErrorStateProps };