Data Grid
import type { ColumnDef, PaginationState, SortingState } from "@tanstack/solid-table";import { createTable } from "@tanstack/solid-table";import { createSignal } from "solid-js";import { DataGrid, DataGridContainer, type DataGridFeatures, DataGridPagination, DataGridScrollArea, DataGridTable, dataGridFeatures,} from "@/registry/kobalte/blocks/data-grid";
interface User { id: string; name: string; email: string; flag: string; location: string; balance: number;}
const demoData: User[] = [ { name: "Alex Johnson", email: "alex@example.com", flag: "us", location: "United States" }, { name: "Sarah Chen", email: "sarah@example.com", flag: "gb", location: "United Kingdom" }, { name: "Michael Rodriguez", email: "michael@example.com", flag: "ca", location: "Canada" }, { name: "Emma Wilson", email: "emma@example.com", flag: "au", location: "Australia" }, { name: "David Kim", email: "david@example.com", flag: "de", location: "Germany" }, { name: "Aron Thompson", email: "lisa@example.com", flag: "my", location: "Malaysia" }, { name: "James Brown", email: "james@example.com", flag: "es", location: "Spain" }, { name: "Maria Garcia", email: "maria@example.com", flag: "jp", location: "Japan" }, { name: "Nick Johnson", email: "nick@example.com", flag: "fr", location: "France" }, { name: "Liam Thompson", email: "liam@example.com", flag: "it", location: "Italy" },].map((user, index) => ({ ...user, id: String(index + 1), balance: 5143.03 + index * 100 }));
const columns: ColumnDef<DataGridFeatures, User>[] = [ { accessorKey: "name", id: "name", header: "Name", cell: (info) => info.getValue<string>(), size: 150, }, { accessorKey: "email", id: "email", header: "Email", cell: (info) => ( <div class="truncate"> <a href={`mailto:${info.getValue<string>()}`} class="truncate hover:text-primary hover:underline" > {info.getValue<string>()} </a> </div> ), size: 150, }, { accessorKey: "location", id: "location", header: "Location", cell: ({ row }) => ( <div class="flex items-center gap-1.5"> <img src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`} alt={row.original.flag} class="size-4 rounded-full object-cover" /> <div class="text-foreground">{row.original.location}</div> </div> ), size: 175, }, { accessorKey: "balance", id: "balance", header: "Balance ($)", cell: (info) => `$${info.getValue<number>().toFixed(2)}`, size: 100, meta: { headerClassName: "text-right rtl:text-left", cellClassName: "text-right rtl:text-left", }, },];
export default function DataGridDemo() { const [pagination, setPagination] = createSignal<PaginationState>({ pageIndex: 0, pageSize: 5, }); const [sorting, setSorting] = createSignal<SortingState>([{ id: "name", desc: true }]);
const table = createTable({ features: dataGridFeatures, columns, data: demoData, get pageCount() { return Math.ceil(demoData.length / pagination().pageSize); }, getRowId: (row: User) => row.id, state: { get pagination() { return pagination(); }, get sorting() { return sorting(); }, }, onPaginationChange: setPagination, onSortingChange: setSorting, });
return ( <DataGrid table={table} recordCount={demoData.length}> <div class="w-full space-y-2.5"> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> <DataGridPagination /> </div> </DataGrid> );}The Data Grid block ships the shared grid context, the table renderers, pagination, column
controls, drag-and-drop helpers, virtualization, infinite scroll, footer helpers, row pinning,
and tree rows. Footer components (DataGridTableFoot, DataGridTableFootRow,
DataGridTableFootRowCell) plus the DataGridTableRowPin and DataGridTableRowExpand toggles
come from data-grid-table.tsx, and DataGridScrollArea handles scrolling for sticky-header or
wide tables.
You own the TanStack core: the block never creates a table for you, it renders the one you hand it.
Installation
TanStack Table v9
The grid is built on TanStack Table v9 through
@tanstack/solid-table. v9 asks every table to declare which features it uses, so the block
exports a ready-made bundle:
import { createTable } from "@tanstack/solid-table";
import { dataGridFeatures } from "~/components/blocks/data-grid";
const table = createTable({ features: dataGridFeatures, columns, get data() { return data(); }, state: { get sorting() { return sorting(); }, get pagination() { return pagination(); }, }, onSortingChange: setSorting, onPaginationChange: setPagination,});createTable replaces React's useTable. Its options object is read reactively, so anything
that changes over time — data, pageCount, every slice of state — is passed as a getter,
not as a snapshot. Solid's setters match TanStack's OnChangeFn shape, so onSortingChange
takes a createSignal setter directly.
dataGridFeatures registers everything the grid's own rendering needs, which is a wider set than
it looks. columnVisibilityFeature gates row.getVisibleCells() and columnPinningFeature
gates the getStartVisibleCells() / getCenterVisibleCells() / getEndVisibleCells() split
every row goes through, so a grid that never hides and never pins a column still needs both just
to render rows.
Two entries in the bundle are neither features nor row models:
sortFns: { basic: sortFn_basic, text: sortFn_text }. On v9 a stringsortFnresolves against this registry alone, so"basic"and"text"(plus the default"auto") are the only names this bundle types. Any other built-in ("alphanumeric","datetime","textCaseSensitive") has to be registered in your own bundle or passed as a function.columnMeta, which typescolumnDef.metaasDataGridColumnMeta(headerTitle,headerClassName,cellClassName,skeleton,expandedContent,autoSize). A features-levelcolumnMetaslot wins over the globalColumnMetainterface, so adeclare moduleaugmentation is ignored on any table built withdataGridFeatures. Add your own fields toDataGridColumnMetain the installeddata-grid.tsxinstead.
The bundle also ships as a type, DataGridFeatures. v9 puts the feature set first in the
TanStack generics, so that is the type you write wherever a column, a row, or the table appears
in your own annotations:
import type { ColumnDef, Row } from "@tanstack/solid-table";
import type { DataGridFeatures } from "~/components/blocks/data-grid";
const columns: ColumnDef<DataGridFeatures, User>[] = [ // ...];
function ActionsCell(props: { row: Row<DataGridFeatures, User> }) { // ...}Extend the bundle when a grid needs more:
const features = tableFeatures({ ...dataGridFeatures, columnGroupingFeature, groupedRowModel: createGroupedRowModel(),});<DataGrid> is the generic one: its table prop is Table<TFeatures, TData> for any
TFeatures, and the instance is widened internally exactly once. The sub-components that take a
column, row, or table prop (DataGridColumnHeader, DataGridColumnFilter,
DataGridColumnVisibility, DataGridTableRowSelect, DataGridTableRowPin,
DataGridTableRowExpand) are declared against DataGridFeatures, and TFeatures is invariant
in v9, so any bundle wider or leaner than dataGridFeatures needs a cast at those boundaries.
No Subscribe, no memo
React needs TanStack's Subscribe around builder calls like row.getIsSelected() and
column.getIsSorted(), because the React Compiler can memoize a read off a stable row or column
reference and freeze it. Solid has no such gap: reading column.getIsSorted() inside JSX is
the subscription, since v9 backs its state with signals through the Solid adapter. The block
drops Subscribe and memo everywhere upstream used them, and your own cell templates need
neither.
State reads go through table.store.state.* — table.getState() is gone, and there is no plain
table.state on the Solid adapter:
const { pageIndex, pageSize } = table.store.state.pagination;Upgrading from v8
These are the changes that bite hardest when porting an existing table:
- Every generic gained a leading
TFeatures.ColumnDef<TData, TValue>becomesColumnDef<DataGridFeatures, TData, TValue>, andRow,Column,Cell,Header, andTableshift the same way. useReactTableis nowcreateTable, and theget*RowModeloptions are gone. Row models moved into thefeaturesbundle, which is whatdataGridFeatureshands you.- A registered
paginatedRowModelalways slices. It is the one row model that is not inert, so a grid that used to render every row now shows onlypageSizerows (10 by default). When the data is already one page, or the grid renders all rows, setmanualPagination: true. - Column pinning moved from
left/righttostart/end, acrossColumnPinningState,column.pin(), andgetIsPinned(). The grid'sdata-pinned,data-last-col, anddata-outer-pinned-colattributes emitstart/endtoo, so CSS targeting[data-pinned="left"]must be updated. getIsSomeRowsSelected()andgetIsSomePageRowsSelected()changed meaning, from "some but not all" to "at least one". Guard an indeterminate checkbox withgetIsSomePageRowsSelected() && !getIsAllPageRowsSelected().- State shapes got stricter.
ColumnPinningStaterequires bothstartandend,RowPinningStaterequires bothtopandbottom, andRowSelectionStatenarrowed toRecord<string, true>.
Usage
import { createSignal } from "solid-js";import { type ColumnDef, createTable } from "@tanstack/solid-table";
import { DataGrid, DataGridContainer, DataGridPagination, DataGridScrollArea, DataGridTable, DataGridTableFootRow, DataGridTableFootRowCell, type DataGridFeatures, dataGridFeatures,} from "~/components/blocks/data-grid";const columns: ColumnDef<DataGridFeatures, User>[] = [ { accessorKey: "name", id: "name", header: "Name" }, { accessorKey: "email", id: "email", header: "Email" },];
const footer = ( <DataGridTableFootRow> <DataGridTableFootRowCell colSpan={columns.length}> Showing {data().length} rows </DataGridTableFootRowCell> </DataGridTableFootRow>);
const table = createTable({ features: dataGridFeatures, columns, get data() { return data(); },});
return ( <DataGrid table={table} recordCount={data().length} tableLayout={{ rowsPinnable: true }}> <DataGridContainer> <DataGridScrollArea> <DataGridTable footerContent={footer} /> </DataGridScrollArea> </DataGridContainer> <DataGridPagination /> </DataGrid>);Use DataGridTableRowPin inside a column definition to let users pin rows, swap in
DataGridTableVirtual when you need virtualization or infinite scroll, and wrap sticky-header
tables with DataGridScrollArea. When rows can be pinned, reordered, or virtualized, provide a
stable getRowId so row identity survives reordering.
Examples
Cell Border
import type { ColumnDef, PaginationState, SortingState } from "@tanstack/solid-table";import { createTable } from "@tanstack/solid-table";import { createSignal } from "solid-js";import { DataGrid, DataGridContainer, type DataGridFeatures, DataGridPagination, DataGridScrollArea, DataGridTable, dataGridFeatures,} from "@/registry/kobalte/blocks/data-grid";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Card } from "~/components/ui/card";
interface User { id: string; name: string; email: string; avatar: string; company: string; role: string; balance: number;}
const avatars = [ "https://github.com/carere.png", "https://github.com/shadcn.png", "https://github.com/evilrabbit.png", "https://github.com/maxleiter.png",];
const demoData: User[] = [ { name: "Alex Johnson", email: "alex@example.com", company: "Apple", role: "CEO" }, { name: "Sarah Chen", email: "sarah@example.com", company: "OpenAI", role: "CTO" }, { name: "Michael Rodriguez", email: "michael@example.com", company: "Meta", role: "Designer" }, { name: "Emma Wilson", email: "emma@example.com", company: "Tesla", role: "Developer" }, { name: "David Kim", email: "david@example.com", company: "SAP", role: "Lawyer" }, { name: "Aron Thompson", email: "lisa@example.com", company: "Keenthemes", role: "Director" }, { name: "James Brown", email: "james@example.com", company: "BBVA", role: "Product Manager" }, { name: "Maria Garcia", email: "maria@example.com", company: "Sony", role: "Marketing Lead" }, { name: "Nick Johnson", email: "nick@example.com", company: "LVMH", role: "Data Scientist" }, { name: "Liam Thompson", email: "liam@example.com", company: "ENI", role: "Engineer" },].map((user, index) => ({ ...user, id: String(index + 1), avatar: avatars[index % avatars.length], balance: 5143.03 + index * 100,}));
function getInitials(name: string) { return name .split(" ") .map((part) => part[0]) .join("");}
const columns: ColumnDef<DataGridFeatures, User>[] = [ { accessorKey: "name", id: "name", header: "Name", cell: ({ row }) => ( <div class="flex items-center gap-3"> <Avatar class="size-8"> <AvatarImage src={row.original.avatar} alt={row.original.name} /> <AvatarFallback>{getInitials(row.original.name)}</AvatarFallback> </Avatar> <div class="space-y-px"> <div class="font-medium text-foreground">{row.original.name}</div> <div class="text-muted-foreground">{row.original.email}</div> </div> </div> ), size: 250, enableSorting: true, enableHiding: false, }, { accessorKey: "company", id: "company", header: "Company", cell: (info) => <span>{info.getValue<string>()}</span>, size: 100, }, { accessorKey: "role", id: "role", header: "Occupation", cell: (info) => <span>{info.getValue<string>()}</span>, size: 100, }, { accessorKey: "balance", id: "balance", header: "Salary", cell: (info) => <span class="font-semibold">${info.getValue<number>().toFixed(2)}</span>, size: 100, },];
export default function DataGridCellBorder() { const [pagination, setPagination] = createSignal<PaginationState>({ pageIndex: 0, pageSize: 5, }); const [sorting, setSorting] = createSignal<SortingState>([{ id: "name", desc: true }]);
const table = createTable({ features: dataGridFeatures, columns, data: demoData, get pageCount() { return Math.ceil(demoData.length / pagination().pageSize); }, getRowId: (row: User) => row.id, state: { get pagination() { return pagination(); }, get sorting() { return sorting(); }, }, onPaginationChange: setPagination, onSortingChange: setSorting, });
return ( <DataGrid table={table} recordCount={demoData.length} tableLayout={{ cellBorder: true }}> <div class="w-full space-y-2.5"> <Card class="p-0"> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> </Card> <DataGridPagination /> </div> </DataGrid> );}Dense Table
import type { ColumnDef, PaginationState, SortingState } from "@tanstack/solid-table";import { createTable } from "@tanstack/solid-table";import { createSignal } from "solid-js";import { DataGrid, DataGridContainer, type DataGridFeatures, DataGridPagination, DataGridScrollArea, DataGridTable, dataGridFeatures,} from "@/registry/kobalte/blocks/data-grid";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
interface User { id: string; name: string; email: string; avatar: string; flag: string; location: string; balance: number;}
const avatars = [ "https://github.com/carere.png", "https://github.com/shadcn.png", "https://github.com/evilrabbit.png", "https://github.com/maxleiter.png",];
const demoData: User[] = [ { name: "Alex Johnson", email: "alex@example.com", flag: "us", location: "United States" }, { name: "Sarah Chen", email: "sarah@example.com", flag: "gb", location: "United Kingdom" }, { name: "Michael Rodriguez", email: "michael@example.com", flag: "ca", location: "Canada" }, { name: "Emma Wilson", email: "emma@example.com", flag: "au", location: "Australia" }, { name: "David Kim", email: "david@example.com", flag: "de", location: "Germany" }, { name: "Aron Thompson", email: "lisa@example.com", flag: "my", location: "Malaysia" }, { name: "James Brown", email: "james@example.com", flag: "es", location: "Spain" }, { name: "Maria Garcia", email: "maria@example.com", flag: "jp", location: "Japan" }, { name: "Nick Johnson", email: "nick@example.com", flag: "fr", location: "France" }, { name: "Liam Thompson", email: "liam@example.com", flag: "it", location: "Italy" },].map((user, index) => ({ ...user, id: String(index + 1), avatar: avatars[index % avatars.length], balance: 5143.03 + index * 100,}));
function getInitials(name: string) { return name .split(" ") .map((part) => part[0]) .join("");}
const columns: ColumnDef<DataGridFeatures, User>[] = [ { accessorKey: "name", id: "name", header: "Name", cell: ({ row }) => ( <div class="flex items-center gap-2"> <Avatar class="size-6"> <AvatarImage src={row.original.avatar} alt={row.original.name} /> <AvatarFallback>{getInitials(row.original.name)}</AvatarFallback> </Avatar> <a href="#name" class="font-medium text-foreground hover:text-primary"> {row.original.name} </a> </div> ), size: 175, enableSorting: true, enableHiding: false, }, { accessorKey: "email", id: "email", header: "Email", cell: (info) => ( <a href={`mailto:${info.getValue<string>()}`} class="hover:text-primary hover:underline"> {info.getValue<string>()} </a> ), size: 150, }, { accessorKey: "location", id: "location", header: "Location", cell: ({ row }) => ( <div class="flex items-center gap-1.5"> <img src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`} alt={row.original.flag} class="size-4 rounded-full object-cover" /> <div class="font-medium text-foreground">{row.original.location}</div> </div> ), size: 150, meta: { cellClassName: "text-start" }, }, { accessorKey: "balance", id: "balance", header: "Balance ($)", cell: (info) => <span class="font-semibold">${info.getValue<number>().toFixed(2)}</span>, size: 110, meta: { headerClassName: "text-right rtl:text-left", cellClassName: "text-right rtl:text-left", }, },];
export default function DataGridDense() { const [pagination, setPagination] = createSignal<PaginationState>({ pageIndex: 0, pageSize: 5, }); const [sorting, setSorting] = createSignal<SortingState>([{ id: "name", desc: true }]);
const table = createTable({ features: dataGridFeatures, columns, data: demoData, get pageCount() { return Math.ceil(demoData.length / pagination().pageSize); }, getRowId: (row: User) => row.id, state: { get pagination() { return pagination(); }, get sorting() { return sorting(); }, }, onPaginationChange: setPagination, onSortingChange: setSorting, });
return ( <DataGrid table={table} recordCount={demoData.length} tableLayout={{ dense: true }}> <div class="w-full space-y-2.5"> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> <DataGridPagination /> </div> </DataGrid> );}Light Table
import type { ColumnDef, PaginationState, SortingState } from "@tanstack/solid-table";import { createTable } from "@tanstack/solid-table";import { createSignal } from "solid-js";import { cn } from "~/lib/utils";import { DataGrid, DataGridContainer, type DataGridFeatures, DataGridPagination, DataGridScrollArea, DataGridTable, dataGridFeatures,} from "@/registry/kobalte/blocks/data-grid";import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Badge } from "~/components/ui/badge";
type Availability = "online" | "away" | "busy" | "offline";
interface User { id: string; name: string; email: string; avatar: string; availability: Availability; status: "active" | "inactive"; flag: string; location: string;}
const avatars = [ "https://github.com/carere.png", "https://github.com/shadcn.png", "https://github.com/evilrabbit.png", "https://github.com/maxleiter.png",];
const availabilities: Availability[] = ["online", "away", "busy", "offline"];
const availabilityColors: Record<Availability, string> = { online: "bg-green-500", away: "bg-yellow-500", busy: "bg-orange-500", offline: "bg-gray-400",};
const demoData: User[] = [ { name: "Alex Johnson", email: "alex@example.com", flag: "us", location: "United States" }, { name: "Sarah Chen", email: "sarah@example.com", flag: "gb", location: "United Kingdom" }, { name: "Michael Rodriguez", email: "michael@example.com", flag: "ca", location: "Canada" }, { name: "Emma Wilson", email: "emma@example.com", flag: "au", location: "Australia" }, { name: "David Kim", email: "david@example.com", flag: "de", location: "Germany" }, { name: "Aron Thompson", email: "lisa@example.com", flag: "my", location: "Malaysia" }, { name: "James Brown", email: "james@example.com", flag: "es", location: "Spain" }, { name: "Maria Garcia", email: "maria@example.com", flag: "jp", location: "Japan" }, { name: "Nick Johnson", email: "nick@example.com", flag: "fr", location: "France" }, { name: "Liam Thompson", email: "liam@example.com", flag: "it", location: "Italy" },].map((user, index) => ({ ...user, id: String(index + 1), avatar: avatars[index % avatars.length], availability: availabilities[index % availabilities.length], status: index % 2 === 0 ? ("active" as const) : ("inactive" as const),}));
function getInitials(name: string) { return name .split(" ") .map((part) => part[0]) .join("");}
const columns: ColumnDef<DataGridFeatures, User>[] = [ { accessorKey: "name", id: "name", header: "Name", cell: ({ row }) => ( <div class="flex items-center gap-3"> <Avatar class="size-8"> <AvatarImage src={row.original.avatar} alt={row.original.name} /> <AvatarFallback>{getInitials(row.original.name)}</AvatarFallback> <AvatarBadge class={cn( "size-1.5! p-0", availabilityColors[row.original.availability] ?? availabilityColors.offline, )} /> </Avatar> <div class="space-y-px"> <div class="font-medium text-foreground">{row.original.name}</div> <div class="text-muted-foreground">{row.original.email}</div> </div> </div> ), size: 225, enableSorting: true, enableHiding: false, }, { accessorKey: "location", id: "location", header: "Location", cell: ({ row }) => ( <div class="flex items-center gap-1.5"> <img src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`} alt={row.original.flag} class="size-4 rounded-full object-cover" /> <div class="font-medium text-foreground">{row.original.location}</div> </div> ), size: 160, meta: { cellClassName: "text-start" }, }, { accessorKey: "status", id: "status", header: "Status", cell: ({ row }) => row.original.status === "active" ? ( <Badge variant="outline" class="text-green-700 dark:text-green-300"> Approved </Badge> ) : ( <Badge variant="outline" class="text-amber-700 dark:text-amber-300"> Pending </Badge> ), size: 100, },];
export default function DataGridLight() { const [pagination, setPagination] = createSignal<PaginationState>({ pageIndex: 0, pageSize: 5, }); const [sorting, setSorting] = createSignal<SortingState>([{ id: "name", desc: true }]);
const table = createTable({ features: dataGridFeatures, columns, data: demoData, get pageCount() { return Math.ceil(demoData.length / pagination().pageSize); }, getRowId: (row: User) => row.id, state: { get pagination() { return pagination(); }, get sorting() { return sorting(); }, }, onPaginationChange: setPagination, onSortingChange: setSorting, });
return ( <DataGrid table={table} recordCount={demoData.length} tableLayout={{ headerBackground: false, rowBorder: false, rowRounded: true }} > <div class="w-full space-y-2.5"> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> <DataGridPagination /> </div> </DataGrid> );}Striped Table
import type { ColumnDef, PaginationState, SortingState } from "@tanstack/solid-table";import { createTable } from "@tanstack/solid-table";import { createSignal } from "solid-js";import { DataGrid, DataGridContainer, type DataGridFeatures, DataGridPagination, DataGridScrollArea, DataGridTable, dataGridFeatures,} from "@/registry/kobalte/blocks/data-grid";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
interface User { id: string; name: string; email: string; avatar: string; flag: string; location: string; balance: number;}
const avatars = [ "https://github.com/carere.png", "https://github.com/shadcn.png", "https://github.com/evilrabbit.png", "https://github.com/maxleiter.png",];
const demoData: User[] = [ { name: "Alex Johnson", email: "alex@example.com", flag: "us", location: "United States" }, { name: "Sarah Chen", email: "sarah@example.com", flag: "gb", location: "United Kingdom" }, { name: "Michael Rodriguez", email: "michael@example.com", flag: "ca", location: "Canada" }, { name: "Emma Wilson", email: "emma@example.com", flag: "au", location: "Australia" }, { name: "David Kim", email: "david@example.com", flag: "de", location: "Germany" }, { name: "Aron Thompson", email: "lisa@example.com", flag: "my", location: "Malaysia" }, { name: "James Brown", email: "james@example.com", flag: "es", location: "Spain" }, { name: "Maria Garcia", email: "maria@example.com", flag: "jp", location: "Japan" }, { name: "Nick Johnson", email: "nick@example.com", flag: "fr", location: "France" }, { name: "Liam Thompson", email: "liam@example.com", flag: "it", location: "Italy" },].map((user, index) => ({ ...user, id: String(index + 1), avatar: avatars[index % avatars.length], balance: 5143.03 + index * 100,}));
function getInitials(name: string) { return name .split(" ") .map((part) => part[0]) .join("");}
const columns: ColumnDef<DataGridFeatures, User>[] = [ { accessorKey: "name", id: "name", header: "Name", cell: ({ row }) => ( <div class="flex items-center gap-2"> <Avatar class="size-6"> <AvatarImage src={row.original.avatar} alt={row.original.name} /> <AvatarFallback>{getInitials(row.original.name)}</AvatarFallback> </Avatar> <a href="#name" class="font-medium text-foreground hover:text-primary"> {row.original.name} </a> </div> ), size: 160, enableSorting: true, enableHiding: false, }, { accessorKey: "email", id: "email", header: "Email", cell: (info) => ( <a href={`mailto:${info.getValue<string>()}`} class="hover:text-primary hover:underline"> {info.getValue<string>()} </a> ), size: 150, }, { accessorKey: "location", id: "location", header: "Location", cell: ({ row }) => ( <div class="flex items-center gap-1.5"> <img src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`} alt={row.original.flag} class="size-4 rounded-full object-cover" /> <div class="font-medium text-foreground">{row.original.location}</div> </div> ), size: 150, }, { accessorKey: "balance", id: "balance", header: "Balance ($)", cell: (info) => <span class="font-semibold">${info.getValue<number>().toFixed(2)}</span>, size: 110, meta: { headerClassName: "text-right rtl:text-left", cellClassName: "text-right rtl:text-left", }, },];
export default function DataGridStriped() { const [pagination, setPagination] = createSignal<PaginationState>({ pageIndex: 0, pageSize: 5, }); const [sorting, setSorting] = createSignal<SortingState>([{ id: "name", desc: true }]);
const table = createTable({ features: dataGridFeatures, columns, data: demoData, get pageCount() { return Math.ceil(demoData.length / pagination().pageSize); }, getRowId: (row: User) => row.id, state: { get pagination() { return pagination(); }, get sorting() { return sorting(); }, }, onPaginationChange: setPagination, onSortingChange: setSorting, });
return ( <DataGrid table={table} recordCount={demoData.length} tableLayout={{ stripped: true, rowRounded: true }} > <div class="w-full space-y-2.5"> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> <DataGridPagination /> </div> </DataGrid> );}Auto Width
import type { ColumnDef, PaginationState, SortingState } from "@tanstack/solid-table";import { createTable } from "@tanstack/solid-table";import { createSignal } from "solid-js";import { DataGrid, DataGridContainer, type DataGridFeatures, DataGridPagination, DataGridScrollArea, DataGridTable, dataGridFeatures,} from "@/registry/kobalte/blocks/data-grid";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
interface User { id: string; name: string; email: string; avatar: string; flag: string; location: string; joined: string;}
const avatars = [ "https://github.com/carere.png", "https://github.com/shadcn.png", "https://github.com/evilrabbit.png", "https://github.com/maxleiter.png",];
const demoData: User[] = [ { name: "Alex Johnson", email: "alex@example.com", flag: "us", location: "United States" }, { name: "Sarah Chen", email: "sarah@example.com", flag: "gb", location: "United Kingdom" }, { name: "Michael Rodriguez", email: "michael@example.com", flag: "ca", location: "Canada" }, { name: "Emma Wilson", email: "emma@example.com", flag: "au", location: "Australia" }, { name: "David Kim", email: "david@example.com", flag: "de", location: "Germany" }, { name: "Aron Thompson", email: "lisa@example.com", flag: "my", location: "Malaysia" }, { name: "James Brown", email: "james@example.com", flag: "es", location: "Spain" }, { name: "Maria Garcia", email: "maria@example.com", flag: "jp", location: "Japan" }, { name: "Nick Johnson", email: "nick@example.com", flag: "fr", location: "France" }, { name: "Liam Thompson", email: "liam@example.com", flag: "it", location: "Italy" },].map((user, index) => ({ ...user, id: String(index + 1), avatar: avatars[index % avatars.length], joined: "Jan, 2024",}));
function getInitials(name: string) { return name .split(" ") .map((part) => part[0]) .join("");}
const columns: ColumnDef<DataGridFeatures, User>[] = [ { accessorKey: "name", id: "name", header: "Name", cell: ({ row }) => ( <div class="flex items-center gap-2"> <Avatar class="size-6"> <AvatarImage src={row.original.avatar} alt={row.original.name} /> <AvatarFallback>{getInitials(row.original.name)}</AvatarFallback> </Avatar> <a href="#name" class="font-medium text-foreground hover:text-primary"> {row.original.name} </a> </div> ), size: 225, enableSorting: true, enableHiding: false, }, { accessorKey: "email", id: "email", header: "Email", cell: (info) => ( <a href={`mailto:${info.getValue<string>()}`} class="hover:text-primary hover:underline"> {info.getValue<string>()} </a> ), size: 200, }, { accessorKey: "location", id: "location", header: "Location", cell: ({ row }) => ( <div class="flex items-center gap-1.5"> <img src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`} alt={row.original.flag} class="size-4 rounded-full object-cover" /> <div class="font-medium text-foreground">{row.original.location}</div> </div> ), size: 175, }, { accessorKey: "joined", id: "joined", header: "Joined", cell: (info) => info.getValue<string>(), size: 120, meta: { cellClassName: "font-medium" }, },];
export default function DataGridAutoWidth() { const [pagination, setPagination] = createSignal<PaginationState>({ pageIndex: 0, pageSize: 5, }); const [sorting, setSorting] = createSignal<SortingState>([{ id: "name", desc: true }]);
const table = createTable({ features: dataGridFeatures, columns, data: demoData, get pageCount() { return Math.ceil(demoData.length / pagination().pageSize); }, getRowId: (row: User) => row.id, state: { get pagination() { return pagination(); }, get sorting() { return sorting(); }, }, onPaginationChange: setPagination, onSortingChange: setSorting, });
return ( <DataGrid table={table} recordCount={demoData.length} tableLayout={{ width: "auto" }}> <div class="w-full space-y-2.5"> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> <DataGridPagination /> </div> </DataGrid> );}Row Selection
import type { ColumnDef, PaginationState, RowSelectionState, SortingState,} from "@tanstack/solid-table";import { createTable } from "@tanstack/solid-table";import { createSignal } from "solid-js";import { cn } from "~/lib/utils";import { DataGrid, DataGridContainer, type DataGridFeatures, DataGridPagination, DataGridScrollArea, DataGridTable, DataGridTableRowSelect, DataGridTableRowSelectAll, dataGridFeatures,} from "@/registry/kobalte/blocks/data-grid";import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
type Availability = "online" | "away" | "busy" | "offline";
interface User { id: string; name: string; email: string; avatar: string; availability: Availability; flag: string; location: string; joined: string;}
const avatars = [ "https://github.com/carere.png", "https://github.com/shadcn.png", "https://github.com/evilrabbit.png", "https://github.com/maxleiter.png",];
const availabilities: Availability[] = ["online", "away", "busy", "offline"];
const availabilityColors: Record<Availability, string> = { online: "bg-green-500", away: "bg-yellow-500", busy: "bg-orange-500", offline: "bg-gray-400",};
const demoData: User[] = [ { name: "Alex Johnson", email: "alex@example.com", flag: "us", location: "United States" }, { name: "Sarah Chen", email: "sarah@example.com", flag: "gb", location: "United Kingdom" }, { name: "Michael Rodriguez", email: "michael@example.com", flag: "ca", location: "Canada" }, { name: "Emma Wilson", email: "emma@example.com", flag: "au", location: "Australia" }, { name: "David Kim", email: "david@example.com", flag: "de", location: "Germany" }, { name: "Aron Thompson", email: "lisa@example.com", flag: "my", location: "Malaysia" }, { name: "James Brown", email: "james@example.com", flag: "es", location: "Spain" }, { name: "Maria Garcia", email: "maria@example.com", flag: "jp", location: "Japan" }, { name: "Nick Johnson", email: "nick@example.com", flag: "fr", location: "France" }, { name: "Liam Thompson", email: "liam@example.com", flag: "it", location: "Italy" },].map((user, index) => ({ ...user, id: String(index + 1), avatar: avatars[index % avatars.length], availability: availabilities[index % availabilities.length], joined: "Jan, 2024",}));
function getInitials(name: string) { return name .split(" ") .map((part) => part[0]) .join("");}
const columns: ColumnDef<DataGridFeatures, User>[] = [ { accessorKey: "id", id: "select", header: () => <DataGridTableRowSelectAll />, cell: ({ row }) => <DataGridTableRowSelect row={row} />, enableSorting: false, size: 20, }, { accessorKey: "name", id: "name", header: "Name", cell: ({ row }) => ( <div class="flex items-center gap-3"> <Avatar class="size-8"> <AvatarImage src={row.original.avatar} alt={row.original.name} /> <AvatarFallback>{getInitials(row.original.name)}</AvatarFallback> <AvatarBadge class={cn( "size-1.5! p-0", availabilityColors[row.original.availability] ?? availabilityColors.offline, )} /> </Avatar> <div class="space-y-px"> <div class="font-medium text-foreground">{row.original.name}</div> <div class="text-muted-foreground">{row.original.email}</div> </div> </div> ), size: 200, enableSorting: true, enableHiding: false, }, { accessorKey: "location", id: "location", header: "Location", cell: ({ row }) => ( <div class="flex items-center gap-1.5"> <img src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`} alt={row.original.flag} class="size-4 rounded-full object-cover" /> <div class="font-medium text-foreground">{row.original.location}</div> </div> ), size: 180, meta: { cellClassName: "text-start" }, }, { accessorKey: "joined", id: "joined", header: "Joined", cell: (info) => info.getValue<string>(), size: 120, meta: { cellClassName: "font-medium" }, },];
export default function DataGridRowSelection() { const [pagination, setPagination] = createSignal<PaginationState>({ pageIndex: 0, pageSize: 5, }); const [sorting, setSorting] = createSignal<SortingState>([{ id: "name", desc: true }]); const [rowSelection, setRowSelection] = createSignal<RowSelectionState>({});
const selectedCount = () => Object.keys(rowSelection()).length;
const table = createTable({ features: dataGridFeatures, columns, data: demoData, get pageCount() { return Math.ceil(demoData.length / pagination().pageSize); }, getRowId: (row: User) => row.id, state: { get pagination() { return pagination(); }, get sorting() { return sorting(); }, get rowSelection() { return rowSelection(); }, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, onPaginationChange: setPagination, onSortingChange: setSorting, });
return ( <DataGrid table={table} recordCount={demoData.length}> <div class="w-full space-y-2.5"> <div class="text-muted-foreground text-sm" aria-live="polite"> {selectedCount()} of {demoData.length} row(s) selected </div> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> <DataGridPagination /> </div> </DataGrid> );}Tree Rows
import type { ColumnDef, ExpandedState, PaginationState, SortingState,} from "@tanstack/solid-table";import { createTable } from "@tanstack/solid-table";import { createSignal, onMount, Show } from "solid-js";import { DataGrid, DataGridColumnHeader, DataGridContainer, type DataGridFeatures, DataGridPagination, DataGridScrollArea, DataGridTable, DataGridTableRowExpand, dataGridFeatures,} from "@/registry/kobalte/blocks/data-grid";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Badge } from "~/components/ui/badge";import { Card } from "~/components/ui/card";
interface Node { id: string; name: string; type: "department" | "team" | "member"; status: "active" | "inactive"; role?: string; avatar?: string; flag?: string; location?: string; children?: Node[];}
const demoData: Node[] = [ { id: "eng", name: "Engineering", type: "department", status: "active", children: [ { id: "eng-platform", name: "Platform", type: "team", status: "active", children: [ { id: "eng-platform-1", name: "Alex Johnson", type: "member", status: "active", role: "Staff Engineer", avatar: "https://github.com/shadcn.png", flag: "us", location: "United States", }, { id: "eng-platform-2", name: "Sarah Chen", type: "member", status: "active", role: "Senior Engineer", avatar: "https://github.com/evilrabbit.png", flag: "gb", location: "United Kingdom", }, { id: "eng-platform-3", name: "Michael Rodriguez", type: "member", status: "inactive", role: "Frontend Engineer", avatar: "https://github.com/maxleiter.png", flag: "ca", location: "Canada", }, ], }, { id: "eng-mobile", name: "Mobile", type: "team", status: "active", children: [ { id: "eng-mobile-1", name: "Emma Wilson", type: "member", status: "active", role: "iOS Engineer", avatar: "https://github.com/pranathip.png", flag: "au", location: "Australia", }, { id: "eng-mobile-2", name: "David Kim", type: "member", status: "active", role: "Android Engineer", avatar: "https://github.com/shadcn.png", flag: "de", location: "Germany", }, ], }, ], }, { id: "design", name: "Design", type: "department", status: "active", children: [ { id: "design-product", name: "Product Design", type: "team", status: "active", children: [ { id: "design-product-1", name: "Aron Thompson", type: "member", status: "active", role: "Design Lead", avatar: "https://github.com/evilrabbit.png", flag: "my", location: "Malaysia", }, { id: "design-product-2", name: "Maria Garcia", type: "member", status: "active", role: "Product Designer", avatar: "https://github.com/maxleiter.png", flag: "jp", location: "Japan", }, ], }, { id: "design-brand", name: "Brand", type: "team", status: "active", children: [ { id: "design-brand-1", name: "Nick Johnson", type: "member", status: "active", role: "Brand Designer", avatar: "https://github.com/pranathip.png", flag: "fr", location: "France", }, { id: "design-brand-2", name: "Liam Thompson", type: "member", status: "inactive", role: "Motion Designer", avatar: "https://github.com/shadcn.png", flag: "it", location: "Italy", }, ], }, ], }, { id: "marketing", name: "Marketing", type: "department", status: "active", children: [ { id: "marketing-growth", name: "Growth", type: "team", status: "active", children: [ { id: "marketing-growth-1", name: "Olivia Martin", type: "member", status: "active", role: "Growth Lead", avatar: "https://github.com/evilrabbit.png", flag: "us", location: "United States", }, { id: "marketing-growth-2", name: "Ethan Clark", type: "member", status: "active", role: "Performance Marketer", avatar: "https://github.com/maxleiter.png", flag: "ca", location: "Canada", }, ], }, { id: "marketing-content", name: "Content", type: "team", status: "active", children: [ { id: "marketing-content-1", name: "Sofia Rossi", type: "member", status: "active", role: "Content Lead", avatar: "https://github.com/pranathip.png", flag: "it", location: "Italy", }, { id: "marketing-content-2", name: "Lucas Meyer", type: "member", status: "inactive", role: "Copywriter", avatar: "https://github.com/shadcn.png", flag: "de", location: "Germany", }, ], }, ], }, { id: "operations", name: "Operations", type: "department", status: "active", children: [ { id: "operations-finance", name: "Finance", type: "team", status: "active", children: [ { id: "operations-finance-1", name: "Grace Lee", type: "member", status: "active", role: "Finance Lead", avatar: "https://github.com/evilrabbit.png", flag: "kr", location: "South Korea", }, { id: "operations-finance-2", name: "Daniel Novak", type: "member", status: "active", role: "Accountant", avatar: "https://github.com/maxleiter.png", flag: "cz", location: "Czechia", }, ], }, { id: "operations-people", name: "People", type: "team", status: "active", children: [ { id: "operations-people-1", name: "Chloe Dubois", type: "member", status: "active", role: "People Lead", avatar: "https://github.com/pranathip.png", flag: "fr", location: "France", }, { id: "operations-people-2", name: "Ryan Walsh", type: "member", status: "active", role: "Recruiter", avatar: "https://github.com/shadcn.png", flag: "ie", location: "Ireland", }, ], }, ], }, { id: "sales", name: "Sales", type: "department", status: "active", children: [ { id: "sales-accounts", name: "Accounts", type: "team", status: "active", children: [ { id: "sales-accounts-1", name: "Mia Park", type: "member", status: "active", role: "Account Executive", avatar: "https://github.com/evilrabbit.png", flag: "kr", location: "South Korea", }, { id: "sales-accounts-2", name: "Noah Fischer", type: "member", status: "inactive", role: "Account Manager", avatar: "https://github.com/maxleiter.png", flag: "at", location: "Austria", }, ], }, ], },];
// Collapsed rows are unmounted, so their images would load only when a branch// is first expanded and pop in after the fallback renders. Warming them once// at mount keeps expansion flicker-free.function collectImageUrls(nodes: Node[]): string[] { return nodes.flatMap((node) => [ ...(node.avatar ? [node.avatar] : []), ...(node.flag ? [`https://flagcdn.com/${node.flag.toLowerCase()}.svg`] : []), ...(node.children ? collectImageUrls(node.children) : []), ]);}
function getInitials(name: string) { return name .split(" ") .map((part) => part[0]) .join("");}
const columns: ColumnDef<DataGridFeatures, Node>[] = [ { accessorKey: "name", id: "name", header: ({ column }) => <DataGridColumnHeader title="Name" column={column} />, cell: ({ row }) => ( <div class="flex items-center gap-1"> <DataGridTableRowExpand row={row} class="-ms-1.5 -me-1" /> <Show when={row.original.type === "member"} fallback={<span class="font-medium text-foreground">{row.original.name}</span>} > <Avatar class="size-6 shrink-0"> <AvatarImage src={row.original.avatar} alt={row.original.name} /> <AvatarFallback>{getInitials(row.original.name)}</AvatarFallback> </Avatar> <a href="#name" class="font-medium text-foreground hover:text-primary"> {row.original.name} </a> </Show> </div> ), minSize: 260, enableSorting: true, enableHiding: false, meta: { autoSize: true }, }, { accessorKey: "role", id: "role", header: ({ column }) => <DataGridColumnHeader title="Role" column={column} />, cell: ({ row }) => ( <div class="text-muted-foreground"> {row.original.role ?? (row.original.type === "department" ? "Department" : "Team")} </div> ), size: 180, }, { accessorKey: "location", id: "location", header: ({ column }) => <DataGridColumnHeader title="Location" column={column} />, cell: ({ row }) => ( <Show when={row.original.location && row.original.flag} fallback={<span class="text-muted-foreground">-</span>} > <div class="flex items-center gap-1.5"> <img src={`https://flagcdn.com/${row.original.flag?.toLowerCase()}.svg`} alt={row.original.flag} class="size-4 rounded-full object-cover" /> <div class="font-medium text-foreground">{row.original.location}</div> </div> </Show> ), size: 180, }, { accessorKey: "status", id: "status", header: ({ column }) => <DataGridColumnHeader title="Status" column={column} />, cell: ({ row }) => row.original.status === "active" ? ( <Badge variant="outline" class="text-green-700 dark:text-green-300"> Active </Badge> ) : ( <Badge variant="outline" class="text-amber-700 dark:text-amber-300"> Inactive </Badge> ), size: 130, },];
export default function DataGridTreeRows() { const [pagination, setPagination] = createSignal<PaginationState>({ pageIndex: 0, pageSize: 4, }); const [sorting, setSorting] = createSignal<SortingState>([]); const [expanded, setExpanded] = createSignal<ExpandedState>({ eng: true, "eng-platform": true, });
onMount(() => { for (const src of collectImageUrls(demoData)) { const image = new Image(); image.src = src; } });
const table = createTable({ features: dataGridFeatures, columns, data: demoData, get pageCount() { return Math.ceil(demoData.length / pagination().pageSize); }, getRowId: (row: Node) => row.id, getSubRows: (row: Node) => row.children, state: { get pagination() { return pagination(); }, get sorting() { return sorting(); }, get expanded() { return expanded(); }, }, // Keep expanded children on the same page as their parent. paginateExpandedRows: false, onPaginationChange: setPagination, onSortingChange: setSorting, onExpandedChange: setExpanded, });
return ( <DataGrid table={table} recordCount={demoData.length} tableLayout={{ columnsResizable: true, columnsMovable: true, columnsVisibility: true }} > <div class="w-full space-y-2.5"> <Card class="overflow-hidden p-0"> <DataGridContainer> <DataGridScrollArea> <DataGridTable /> </DataGridScrollArea> </DataGridContainer> </Card> <DataGridPagination sizes={[4, 8, 16]} /> </div> </DataGrid> );}Server-Side Rendering
@dnd-kit/solid is browser-only, so DataGridTableDnd and DataGridTableDndRows render the
same table markup without a drag context during SSR and hydration, then engage drag and drop one
tick after mount. The server HTML is visually identical, but grips are not draggable and carry no
drag-related ARIA attributes until hydration completes. Because Solid resolves context through
the owner tree, the drag provider has to be an ancestor of the sortables, so the table subtree is
built once for the static pass and once when the provider mounts. Every other part of the grid —
including DataGridTableVirtual, which measures on the client — renders on the server unchanged.
API Reference
DataGrid
The root component that provides the table context.
tableLayout
columnsResizeMode is resolved by the grid, not by TanStack. Left unset, the grid reads the
table's own columnResizeMode and falls back to "onEnd" when that is undefined, which on v9 is
the normal case: createTable hands back the options object you passed, so feature defaults
never show up on table.options.
DataGridTableClassNames
Custom CSS classes for different parts of the table.
DataGridContainer
The outer wrapper for the grid. It clips overflow, so scrolling comes from DataGridScrollArea.
DataGridScrollArea
Dedicated scroll wrapper for wide grids and sticky headers.
While the sticky-header scroll mode is active (headerSticky with a vertical orientation), the
root carries data-overflow-vertical="true" whenever content overflows vertically. Use it as an
ancestor selector to style scrollable vs short grids, for example a closing bottom border on the
last row only when a fixed-height grid is partially filled:
[[data-slot=data-grid-scroll-area]:not([data-overflow-vertical])_&:last-child>td]:border-b on
tableClassNames.bodyRow.
The wrapper is vanilla Solid rather than the Zaidan Scroll Area component: the grid needs a viewport ref, per-axis scrollbars, and a pinned-column inset that the shared wrapper does not expose.
DataGridTable
Renders the actual HTML table. It handles data rendering, loading states (skeletons/spinners),
empty states, footer rows, and pinned rows when rowsPinnable is enabled on the parent
DataGrid. The unpinned rows come from table.getRowModel(), which on v9 resolves to the
paginated row model, and dataGridFeatures always registers paginatedRowModel, so a grid that
must render every row sets manualPagination: true.
DataGridPagination
Pagination controls. The record info comes from recordCount on DataGrid, and the page buttons
come from table.getPageCount(), so they only render when there is more than one page. In v9
getPageCount() is pageCount ?? Math.ceil(rowCount / pageSize) and rowCount falls back to
the pre-paginated row count, so a grid whose data holds only the current page must pass
rowCount or pageCount to createTable or the buttons never appear.
sizesInfo, sizesLabel, sizesDescription, and more are still accepted by
DataGridPaginationProps but are not rendered. The page size selector is a Kobalte Select, so
its value is a number rather than a stringified one, and both skeleton defaults are built
lazily — only while isLoading is set.
DataGridColumnHeader
Sort, pin, move, and visibility controls for a column header.
The props extend Omit<JSX.HTMLAttributes<HTMLDivElement>, "title">, because the string title
prop would otherwise collide with the DOM title attribute.
DataGridColumnFilter
A faceted multi-select filter for one column, rendered inside a Popover.
icon is a Solid component rendered through Dynamic and receives a class prop.
The count beside each option comes from column.getFacetedUniqueValues(). On v9 that method only
exists when the feature bundle registers columnFacetingFeature; leave it out of a leaner bundle
of your own and the call throws. The numbers themselves come from the facetedUniqueValues row
model, and facetedRowModel is what makes those counts respect the table's other active filters.
dataGridFeatures registers all three, so the counts work out of the box.
Filtering needs one thing more. v9 resolves a string filterFn name against the filterFns map
on the feature bundle, including the default "auto", and dataGridFeatures registers none, so
hand the column a filter function directly:
import { filterFn_arrHas } from "@tanstack/solid-table";
const columns = [ { accessorKey: "status", id: "status", header: "Status", filterFn: filterFn_arrHas, },];This filter writes an array of selected values, and filterFn_arrHas matches a scalar cell value
against that array (reach for filterFn_arrIncludesSome when the cell itself holds an array).
DataGridColumnVisibility
A dropdown menu of checkbox items, one per hideable column.
Solid has no cloneElement, so trigger is a component rather than an element. It receives the
menu trigger props and must spread them onto its root:
<DataGridColumnVisibility table={table} trigger={(triggerProps) => ( <Button {...triggerProps} variant="outline" size="sm"> <Settings2 /> Columns </Button> )}/>DataGridTableDnd
Column drag-and-drop reordering, with optional footer rendering.
@dnd-kit/solid is the next-generation dnd-kit and has no DragEndEvent, so the grid reports the
resolved positions itself:
type DataGridTableDndDragEndEvent = { activeId: string; overId: string | null; activeIndex: number; overIndex: number; canceled: boolean;};The sortable items come from table.store.state.columnOrder, and TanStack starts that slice as an
empty array, so you have to seed it and keep it controlled. Give every column an explicit id:
ColumnDef.id is optional and is only derived from accessorKey on the built column, not on the
definition object you map over.
const [columnOrder, setColumnOrder] = createSignal<string[]>( columns.map((column) => column.id as string),);
const handleDragEnd = (event: DataGridTableDndDragEndEvent) => { if (event.overIndex === -1 || event.activeIndex === event.overIndex) return; setColumnOrder((order) => arrayMove(order, event.activeIndex, event.overIndex));};
const table = createTable({ features: dataGridFeatures, columns, get data() { return data(); }, state: { get columnOrder() { return columnOrder(); }, }, onColumnOrderChange: setColumnOrder,});Leave columnOrder empty and the headers never resolve a position, so nothing shifts during the
gesture and the reorder commits nothing.
Only the header cell is a sortable. Upstream registers every body cell under the column's id so
dnd-kit stamps one translate on the whole column; @dnd-kit/solid keys its registry on unique
ids, so the body cells mirror the dragged column's state (dimmed, grabbing cursor, raised) rather
than translating with it. The committed order is identical either way.
DataGridTableDndRows
Row drag-and-drop reordering, with optional footer rendering.
Reordering is yours to commit: the grid carries a clone of the row you picked up and marks the
seam it would land on, then hands you handleDragEnd to write the new order back. Three things
have to line up, and a reorder that silently does nothing is almost always one of them:
getRowIdmust be stable and matchdataIds. dnd-kit identifies rows byrow.id, sodataIdshas to be the same ids in the same order as the rendered rows. A row missing fromdataIdsresolves to index-1and will not sort.- Reorder by replacing
data, not by mutating it. TanStack reprocesses rows when thedatareference changes, so an in-placespliceleaves the grid showing the old order. - Do not read a stale index. Resolve positions from the current data inside the setter.
const [rows, setRows] = createSignal(initialRows);const dataIds = () => rows().map((row) => row.id);
const handleDragEnd = (event: DataGridTableDndRowsDragEndEvent) => { if (event.overIndex === -1 || event.activeIndex === event.overIndex) return; setRows((current) => arrayMove(current, event.activeIndex, event.overIndex));};
const table = createTable({ features: dataGridFeatures, // dataGridFeatures registers a paginated row model, and that model always // slices to pageSize (10 by default). manualPagination says the data is // already the page, so every row renders and stays reorderable. manualPagination: true, columns, get data() { return rows(); }, getRowId: (row) => row.id,});The drag handle comes from DataGridTableDndRowHandle in a column of your own. It reads the
row's sortable context, so it only works inside the rows this component renders; placed anywhere
else it renders as a disabled grip. It takes class, plus a disabled flag and a
disabledLabel (default "Reordering unavailable") for the cases where reordering is genuinely
off — a sort being the usual one: the grip keeps its place in the gutter and reads as unavailable
instead of vanishing and collapsing the column.
While a row is in flight the grid does four things, and all of them are built in:
- The carried row is a clone, measured from the row it was lifted from — its column widths and its height. It is not a fixed size, so a grid with wrapping cells does not appear to grow under the pointer when you pick a row up.
- The rows hold still. Sliding them apart to open a gap reads well in a list of identical rows and badly in a table. Nothing moves until the drop commits.
- The row you picked up stays where it was, dimmed and outlined. It is the slot you are moving out of, so it is still there to return to if you change your mind mid-drag.
- The drop target is marked with a 2px bar down its leading edge. Since nothing moves, the bar
is the only thing that says where the row lands, and
data-edgesays which side of that row it comes to rest on.
The indicator renders as a plain element inside the last cell, never a td of its own, so it adds
no column and cannot disturb table-layout: fixed. Target it with
[data-slot="data-grid-table-row-drop-indicator"].
Every sortable row carries tree metadata, so any drag event can resolve a target without re-deriving the table shape:
type DataGridTableDndRowData = { type: "data-grid-row"; depth: number; index: number; parentId: string | null;};Together these cover cross-parent (re-parenting) drops: track the intended parent and depth in
onDragMove or onDragOver, paint the target with renderRowDecoration, and commit the move in
handleDragEnd. The decoration node is positioned over the row, so it adds no column and does not
disturb striping; give it absolute placement, for example an
inset-x-0 bottom-0 h-0.5 bg-primary line offset by the target depth.
@dnd-kit/solid has no SortingStrategy concept, so there is no sortingStrategy prop: the hold-in-place
behavior is inherent to how the grid renders. It also re-exports no modifiers, so the default axis
restriction and the container clamp are not applied for you; pass your own through modifiers if
you need them.
DataGridTableVirtual
A virtualized table renderer using @tanstack/solid-virtual for row virtualization, infinite
scroll, optional footer rows, and pinned rows when rowsPinnable is enabled. The wrapper manages
row count and the scroll element for you, while virtualizerOptions lets you customize the
underlying TanStack Virtual instance. Set scrollToRowIndex to reveal a controlled center row;
"auto" alignment keeps already-visible rows in place and accounts for sticky headers.
observeElementRect, observeElementOffset, and scrollToFn are optional on
DataGridTableVirtualizerOptions, matching the Solid adapter's own signature, so passing
virtualizerOptions never forces you to supply internals you did not mean to touch.
Set manualPagination: true. A virtualized grid renders every row, and dataGridFeatures
registers paginatedRowModel, which is the one row model in the bundle that is not inert:
table.getRowModel() is sliced to pageSize (default 10) unless the table opts out. Without it
an otherwise correct virtual grid renders ten rows and stops.
DataGridTableRowPin
A pin/unpin toggle button for use in column definitions.
The button pins to the top region with row.pin("top") and clears it with row.pin(false). Turn
pinning on with enableRowPinning on the table and tableLayout={{ rowsPinnable: true }} on
DataGrid. In v9 both RowPinningState keys are required, so controlled state has to seed each
region: createSignal<RowPinningState>({ top: [], bottom: [] }).
DataGridTableRowSelect / DataGridTableRowSelectAll
Selection checkboxes for a row and for the current page. DataGridTableRowSelect takes a row
prop; DataGridTableRowSelectAll reads the table from the grid context and takes none. Enable
selection with enableRowSelection on the table.
DataGridTableRowExpand
A depth-indented expand/collapse toggle for tree data, for use in the tree column's cell. Renders
a chevron button for expandable rows (with aria-expanded reflecting state) and a compact spacer
for leaves so leaf content sits close to the parent label.
Pass children to swap the default chevron for your own state-aware icon; the button always
carries aria-expanded, so pure-CSS state styling keeps working. The wrapper exposes
data-slot="data-grid-table-row-expand" and the computed --data-grid-tree-padding CSS variable
as styling hooks. For fully custom cells, the exported getDataGridTreeIndentStyle(row, indent)
helper returns the same indent style, and row.getCanExpand() / row.getIsExpanded() /
row.getToggleExpandedHandler() cover bring-your-own toggles.
DataGridTableFoot
Wrapper component for the table footer (<tfoot>).
DataGridTableFootRow
A row inside the table footer.
DataGridTableFootRowCell
A cell inside a footer row.
DOM Attributes
Data rows carry these attributes so you can target them from queries, tests, and styles. They are
applied by the shared row renderer, so they appear on standard, virtualized, pinned, and
draggable rows alike, with data-index the one exception (only DataGridTableVirtual sets it,
and only on its unpinned body rows). Spacer, skeleton, empty, virtual status, and expanded-detail
rows are rendered separately and carry none of them.
Header and body cells carry the pinning attributes below. They are the contract the grid's own
sticky-column styling is built on, so they are also the hook to use for your own. These values
were left / right before TanStack Table v9, so any CSS selecting [data-pinned="left"] needs
updating to [data-pinned="start"].
While a column is being resized the grid also renders a full-height vertical line at the pointer,
with a cap in the header. It is only shown in onEnd mode — the default — because that is the
mode where the column width does not move until you release, so the line is the only feedback the
drag produces. Target it with [data-slot="data-grid-table-resize-indicator"]. It is positioned
imperatively rather than through signals: a resize drag fires at pointer rate, and routing that
through the reactive graph would touch every row on every frame.
Pinned cells stick with the CSS logical properties inset-inline-start and inset-inline-end
rather than left / right, so they land on the correct edge in RTL without extra work.