- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"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/consumerThe 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:
- @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-reactUsage
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.
"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.
"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.
"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.
"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
{
"ids": [],
"mode": "explicit"
}All matching means matching at execution time. The backend still validates authorization and exclusions.
"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
{
"filters": {
"dates": null,
"search": "",
"status": "all"
},
"pagination": {
"pageIndex": 0,
"pageSize": 25
},
"sorting": []
}"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
| Score | Actions | |
|---|---|---|
"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-busyon 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, andselectAllMatchingare 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, rawfilterDraft, field/form errors, selection/preferences/presentation and typed actions:setFilter,setFilters,resetFilter,applyFilters,resetFilters,setSorting,setPageSize,nextPage,previousPage,toggleRow,togglePage,selectAllMatching,clearSelection.setPageIndexexists only on page grids.getFilterBindingreturns 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/consumerThe 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
| Prop | Type / Description | Default |
|---|---|---|
| columns | readonly DataGridColumnDefinition[]Both adapters: typed helper definitions, independent column ID/accessor/backend sort key. | Required |
| debounce | numberDataGridSearch: immediate-mode delay in milliseconds; clear applies immediately. Apply mode ignores delay. | 300 |
| density | "comfortable" | "compact"DataGrid: cell padding. | "comfortable" |
| field | Inferred raw filter keyDataGridSearch / 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) => stringBoth adapters: stable unique domain IDs, never page indexes. | Required |
| grid | Inferred controllerDataGrid and typed Table/Search/Filter overrides: controller from useDataGrid or useDataGridSource. | Required |
| initialState | Partial<DataGridRawInput>Both adapters: captured once as the uncontrolled initialization/reset baseline. | Schema defaults |
| labels | Partial<DataGridLabels>DataGrid: visible and accessible labels; count/sort messages are formatter functions. | English defaults |
| onStateChange | Per-slice callbacksBoth adapters: emits the proposed value; controlled changes apply only after the parent supplies it. | Not set |
| pageSizes | readonly 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 |
| queryOptions | Native query-options factoryuseDataGrid: input is normalized; native selected output matches the contract. Query owns all remote data. | Required |
| renderCell | (context) => ReactNodeDataGridTable: domain row, native cell, unknown heterogeneous value and spreadable td props. Typed values belong in column cell callbacks. | DataGridCell |
| renderColumn | (context) => ReactNodeDataGridTable: native header, grid and spreadable th props including sorting controls. | DataGridColumn |
| renderEmpty | ({ grid, filtered, reset }) => ReactNodeDataGridTable: feedback content inside a valid spanning cell. | Empty composition |
| renderError | ({ grid, error, retry, background }) => ReactNodeDataGridTable: raw errors reach custom handlers only, never default feedback text. | ErrorState composition |
| renderRow | (context) => ReactNodeDataGridTable: 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" |
| showPageNumbers | booleanDataGridPagination: page links with ellipses instead of First/Last. Set false for four icon controls. Cursor mode always uses Previous/Next only. | true |
| source | DataGridSource<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 |
| stickyHeader | booleanDataGridTable: 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 };