A feature-rich React data grid built on TanStack Table. Ships with sensible default styles, themes entirely through CSS variables, and provides classNames slots so each element can be overridden without fighting specificity.
It is not headless. It renders a real UI — table, list, card, and chat variants — and owns that structure so you don't have to. The tradeoff is that you style it rather than build it from scratch. CSS variables handle 90% of theming; classNames slots cover the rest.
- You want sorting, filtering, pagination, virtualization, and real-time updates without assembling them yourself from primitives.
- You have an existing design system and need the grid to adopt its colors and spacing — not fight them.
- You need more than a table — list, card grid, or chat timeline with the same filtering and data pipeline.
If you want full rendering control with zero default markup, use TanStack Table directly. GridKit is the layer above it.
| Component | Output | Shared features |
|---|---|---|
DataGrid |
<table> with header, body, footer |
All |
DataGridInfinity |
Same as DataGrid with infinite scroll |
All |
DataGridDrag |
Same as DataGrid with row drag-reorder |
All |
DataGridCard |
Responsive card grid | Filtering, sorting, infinite scroll |
DataGridList |
Custom item renderer in a list | Filtering, sorting, search, infinite scroll |
DataGridChat |
Message timeline (top-load, stick-to-bottom) | Filtering, sorting, search |
All variants share the same column definition, DataStore, and filter/sort/search pipeline.
- Virtualization — only visible rows are rendered via
@tanstack/react-virtual - Sorting — client-side and server-side (manual)
- Column Filters — filter row or icon-mode with
text,select,multi-select,numbertypes - Global Search — debounced toolbar search across configurable columns
- Pagination — flexible placement:
footer, toolbar, or fully external viaonTableReady - Infinite Scroll —
DataGridInfinitywith IntersectionObserver-based next-page loading - Row Drag Reorder —
DataGridDragfor sortable rows via dnd-kit - Column Resizing — drag-to-resize with
onChangeoronEndpolicy - Column Pinning — pin columns left or right
- Column Visibility — show/hide columns via toolbar dropdown
- Row Selection — checkbox selection with select-all support
- Row Actions — per-row action menu defined at column level
- Row Expansion — tree rows with collapsible sub-rows
- DataStore — map-based external store for high-frequency real-time updates
- Server-Side Support — sorting, filtering, and pagination all controllable externally
- CSS Theming — override
--dg-*variables to match any design system classNamesSlots — apply custom classes to any structural element (container, header, footer, row, cell, empty state, load-more)- Icon Overrides — replace any built-in icon via the
iconsprop - Escape Hatch —
tableOptionspasses advanced TanStack Table options safely
npm install @loykin/gridkitnpm install react react-dom @tanstack/react-table @tanstack/react-virtualImport the stylesheet once in your app entry point:
import '@loykin/gridkit/styles'GridKit has two customization surfaces:
1. CSS variables — colors, spacing, fonts, backgrounds. If it's a design token, it goes here.
:root {
--dg-header-background: #0f172a;
--dg-header-foreground: #f8fafc;
--dg-border: #e2e8f0;
--dg-radius: 0.75rem;
}2. classNames prop — structural class injection. Use this for layout utilities, shadows, and hover effects that CSS variables cannot express — not for colors or spacing that already have a --dg-* token.
// Good — structure/layout that has no --dg-* equivalent
<DataGrid
classNames={{
container: 'shadow-md',
row: 'hover:bg-blue-50',
footer: 'border-t',
}}
...
/>
// Avoid — use CSS variables instead
<DataGrid
classNames={{
header: 'bg-slate-900 text-white', // → --dg-header-background / --dg-header-foreground
cell: 'px-4', // → --dg-cell-padding (if exposed)
}}
...
/>With shadcn/ui — works out of the box. The --dg-* variables automatically fall back to your existing shadcn CSS variables.
Standalone — hardcoded defaults are applied automatically. No configuration needed.
Custom theme — override only what you need:
:root {
--dg-background: #ffffff;
--dg-foreground: #0a0a0a;
--dg-border: #e5e7eb;
--dg-primary: #3b82f6;
--dg-muted: #f5f5f5;
--dg-muted-foreground: #6b7280;
--dg-header-background: var(--dg-muted);
--dg-header-foreground: var(--dg-muted-foreground);
--dg-header-border: var(--dg-border);
--dg-header-control-background: var(--dg-header-background);
--dg-header-control-foreground: var(--dg-header-foreground);
--dg-header-control-border: var(--dg-header-border);
--dg-header-popover-background: var(--dg-header-background);
--dg-header-popover-foreground: var(--dg-header-foreground);
--dg-header-popover-border: var(--dg-header-border);
--dg-control-background: var(--dg-background);
--dg-control-foreground: var(--dg-foreground);
--dg-control-border: var(--dg-border);
--dg-footer-background: var(--dg-background);
--dg-footer-foreground: var(--dg-muted-foreground);
--dg-footer-border: var(--dg-border);
--dg-radius: 0.5rem;
}
.dark {
--dg-background: #0a0a0a;
--dg-foreground: #fafafa;
--dg-border: rgba(255, 255, 255, 0.1);
--dg-primary: #6366f1;
--dg-muted: #1a1a1a;
--dg-muted-foreground: #a1a1aa;
--dg-header-background: var(--dg-muted);
--dg-header-foreground: var(--dg-muted-foreground);
--dg-header-border: var(--dg-border);
--dg-header-control-background: var(--dg-header-background);
--dg-header-control-foreground: var(--dg-header-foreground);
--dg-header-control-border: var(--dg-header-border);
--dg-header-popover-background: var(--dg-header-background);
--dg-header-popover-foreground: var(--dg-header-foreground);
--dg-header-popover-border: var(--dg-header-border);
--dg-control-background: var(--dg-background);
--dg-control-foreground: var(--dg-foreground);
--dg-control-border: var(--dg-border);
--dg-footer-background: var(--dg-background);
--dg-footer-foreground: var(--dg-muted-foreground);
--dg-footer-border: var(--dg-border);
}| Variable | Description |
|---|---|
--dg-background |
Table / cell background |
--dg-foreground |
Default text color |
--dg-popover |
Dropdown / popover background |
--dg-popover-foreground |
Dropdown text color |
--dg-primary |
Primary accent (active page button, checkboxes) |
--dg-primary-foreground |
Text on primary backgrounds |
--dg-secondary |
Secondary background |
--dg-secondary-foreground |
Secondary text |
--dg-muted |
Muted surface background |
--dg-muted-foreground |
Muted text (placeholders, hints) |
--dg-header-background |
Table header and filter row background. Defaults to --dg-muted |
--dg-header-foreground |
Table header text color. Defaults to --dg-muted-foreground |
--dg-header-border |
Header row, header cell, and filter row border color. Defaults to --dg-border |
--dg-header-control-background |
Header filter input/select background. Defaults to --dg-header-background |
--dg-header-control-foreground |
Header filter input/select text color. Defaults to --dg-header-foreground |
--dg-header-control-border |
Header filter input/select/checkbox border color. Defaults to --dg-header-border |
--dg-header-control-placeholder |
Header filter input placeholder color. Defaults to a translucent --dg-header-control-foreground |
--dg-header-popover-background |
Header-origin popover background. Defaults to --dg-header-background |
--dg-header-popover-foreground |
Header-origin popover text color. Defaults to --dg-header-foreground |
--dg-header-popover-border |
Header-origin popover border color. Defaults to --dg-header-border |
--dg-accent |
Hover / accent background |
--dg-accent-foreground |
Accent text |
--dg-destructive |
Destructive action color |
--dg-border |
Border color |
--dg-input |
Input border fallback. Defaults to framework --input when available |
--dg-control-background |
Input, select, and checkbox control background. Defaults to --dg-background |
--dg-control-foreground |
Input and select text color. Defaults to --dg-foreground |
--dg-control-border |
Input, select, and checkbox border color. Defaults to --dg-input |
--dg-control-placeholder |
Input placeholder color. Defaults to --dg-muted-foreground |
--dg-popover-border |
Generic popover and action menu border color. Defaults to a translucent --dg-foreground |
--dg-popover-option-hover-background |
Generic popover option hover background. Defaults to --dg-muted |
--dg-popover-section-foreground |
Generic popover section label color. Defaults to --dg-muted-foreground |
--dg-footer-background |
Footer surface background. Defaults to --dg-background |
--dg-footer-foreground |
Footer text color. Defaults to --dg-muted-foreground |
--dg-footer-border |
Footer border color token for custom footer styles. Defaults to --dg-border; the built-in footer wrapper does not draw a border by default |
--dg-ring |
Focus ring color |
--dg-radius |
Border radius base value |
Each view variant accepts a classNames prop with slots specific to its structure.
DataGridClassNames — DataGrid, DataGridInfinity, DataGridDrag
interface DataGridClassNames {
container?: string // scroll container
header?: string // header panel
footer?: string // footer wrapper
headerCell?: string // individual header cell
row?: string // body row
cell?: string // body cell
empty?: string // empty state wrapper
loadMore?: string // infinite scroll sentinel wrapper
}DataGridCardClassNames — DataGridCard
interface DataGridCardClassNames {
container?: string // card container
row?: string // individual card wrapper
empty?: string // empty state wrapper
loadMore?: string // infinite scroll sentinel wrapper
footer?: string // footer wrapper
}DataGridListClassNames — DataGridList
interface DataGridListClassNames {
container?: string // list container
item?: string // individual list item wrapper
empty?: string // empty state wrapper
loadMore?: string // infinite scroll sentinel wrapper
footer?: string // footer wrapper
}DataGridChatClassNames — DataGridChat
interface DataGridChatClassNames {
container?: string // chat container
messageWrapper?: string // individual message wrapper
daySeparator?: string // day separator injected between messages
unreadMarker?: string // unread marker injected before a message
typingIndicator?: string // typing indicator at the bottom
loadPrevious?: string // load-previous sentinel wrapper
empty?: string // empty state wrapper
footer?: string // footer wrapper
}import { DataGrid } from '@loykin/gridkit'
import '@loykin/gridkit/styles'
const columns = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
]
export function MyTable() {
return (
<DataGrid
data={rows}
columns={columns}
tableHeight={400}
/>
)
}Keep data and columns references stable when the values are derived during render. TanStack Table recalculates row models when these references change, and sorting/filtering operate over the full row set even when the DOM is virtualized.
const columns = useMemo<DataGridColumnDef<User>[]>(
() => [
{ accessorKey: 'name' },
{ accessorKey: 'status', meta: { filterType: 'select' } },
],
[],
)
const data = useMemo(() => rowsFromQuery ?? [], [rowsFromQuery])For large table views, set a fixed tableHeight so virtualization can keep DOM work bounded to the visible rows plus overscan. DataGridList also supports opt-in virtualization with enableVirtualization and a fixed containerHeight. DataGridChat is currently non-virtualized because prepend anchoring and bottom stickiness need stricter scroll handling.
Use fillContainer when a table should fit inside an existing app panel without forcing short data to stretch. Use fillParent when the table should always fill a parent-owned height.
DataGridCardis not virtualized. Use it for small/medium card collections, or add app-level paging/infinite loading for large data sets.- Inline editing is basic cell editing: double-click enters
meta.editCell, and the editor must callonCommitoronCancel. Validation, row edit mode, async save states, and undo/redo are not built in. - Accessibility is partial. Table roles,
aria-sort, and popover semantics are present, but full keyboard grid navigation and screen-reader workflow testing are not complete. - Performance guidance is threshold-based rather than benchmark-based. Table virtualization turns on for fixed-height tables at 100+ rows; real app performance still depends on cell render cost, filter/sort cost, and data stability.
Unit/integration tests cover sorting, header groups, date/datetime filters, chat scroll behavior, list virtualization, reverse infinite scroll, stick-to-bottom, and state persistence.
Browser E2E coverage is intentionally focused on regressions that jsdom cannot catch:
pnpm test:e2eThe E2E suite starts the playground and verifies column resize vs reorder separation, header group alignment, fill-container height behavior, datetime filter popover clipping, state persistence after reload, column visibility, runtime pinning, row actions, row selection, inline editing, tree expansion, and master-detail expansion.
Pagination is opt-in. The pagination prop activates TanStack Table's pagination logic; the UI is injected separately so you can place it anywhere.
| Component | Description | Best placement |
|---|---|---|
DataGridPaginationBar |
Full bar: rows-per-page dropdown + page info + nav buttons | footer |
DataGridPaginationCompact |
Minimal: < X / Y > nav only |
headerRight (toolbar) |
DataGridPaginationPages |
Numbered pages: << < 1 2 [3] … 20 > >> |
footer |
footer — below the grid
import { DataGrid, DataGridPaginationBar } from '@loykin/gridkit'
<DataGrid
data={rows}
columns={columns}
pagination={{ pageSize: 20 }}
footer={(table) => (
<DataGridPaginationBar table={table} className="grid-footer-pagination" pageSizes={[10, 20, 50]} />
)}
/>Footer pagination controls do not add spacing by default. Add the vertical gap at
the placement site with className so toolbar, footer, and external placements
can each own their layout.
.grid-footer-pagination {
padding-top: 8px;
}toolbar — inside the filter row
import { DataGrid, DataGridPaginationCompact } from '@loykin/gridkit'
<DataGrid
data={rows}
columns={columns}
pagination={{ pageSize: 20 }}
headerRight={(table) => <DataGridPaginationCompact table={table} />}
/>numbered pages
import { DataGrid, DataGridPaginationPages } from '@loykin/gridkit'
<DataGrid
data={rows}
columns={columns}
pagination={{ pageSize: 10 }}
footer={(table) => <DataGridPaginationPages table={table} className="grid-footer-pagination" siblingCount={2} />}
/>external — outside the DataGrid
import { DataGrid, DataGridPaginationBar } from '@loykin/gridkit'
const [table, setTable] = useState(null)
// Render anywhere — above, below, in a sidebar, etc.
{table && <DataGridPaginationBar table={table} />}
<DataGrid
data={rows}
columns={columns}
pagination={{ pageSize: 20 }}
onTableReady={(t) => setTable(t)}
/>Use initialPageIndex when GridKit owns the current page after mount. Use
pageIndex when your app owns the current page, such as URL-synced pagination
or resetting to page 0 after a parent resource changes.
const [pageIndex, setPageIndex] = useState(0)
<DataGrid
data={pageRows} // current page data only
columns={columns}
pagination={{
pageIndex,
pageSize: 20,
pageCount: Math.ceil(totalCount / 20), // tells TanStack total pages
onPageChange: (pageIndex, pageSize) => { // fetch on every page change
setPageIndex(pageIndex)
fetchPage(pageIndex, pageSize)
},
}}
footer={(table) => (
<DataGridPaginationBar table={table} className="grid-footer-pagination" totalCount={totalCount} />
)}
/>| Field | Type | Default | Description |
|---|---|---|---|
pageSize |
number |
20 |
Initial page size |
pageIndex |
number |
— | Controlled current page index (0-based) |
initialPageIndex |
number |
0 |
Initial page index (0-based) |
pageCount |
number |
— | Total page count for server-side (manual) pagination |
onPageChange |
(pageIndex, pageSize) => void |
— | Called on every page or size change |
Use fillContainer when the grid lives inside a fixed-height tab, drawer, split pane, or dashboard panel.
import { DataGrid, DataGridPaginationBar } from '@loykin/gridkit'
export function UsersPanel() {
return (
<div style={{ height: 520, minHeight: 0 }}>
<DataGrid
fillContainer
data={rows}
columns={columns}
pagination={{ pageSize: 50 }}
footer={(table) => <DataGridPaginationBar table={table} className="grid-footer-pagination" />}
/>
</div>
)
}Behavior:
- Short data uses natural table height, so the footer sits directly below the table.
- Overflowing data scrolls only inside the body area.
- The footer remains visible at the bottom of the parent panel.
- GridKit measures toolbar, header, footer, gaps, and parent resize internally; callers should not query
.dg-*internals to calculatemaxTableHeight.
Parent requirements:
- A fixed height,
height: 100%chain, or flex layout that gives the parent a real height. - In flex layouts, make sure the parent chain can shrink with
min-height: 0. - Do not add
overflow: autoto.dg-table-wrapperor.dg-container; the scroll owner is the internal body element.
If you want a hard fixed table body regardless of content length, use tableHeight. If you want content to grow until a known cap, use maxTableHeight. If the cap depends on the surrounding app panel, use fillContainer.
Use fillParent when the parent layout already owns height and the grid should occupy that whole region.
import { DataGrid, DataGridPaginationBar } from '@loykin/gridkit'
export function MetricsTab() {
return (
<div className="h-full min-h-0 overflow-hidden">
<DataGrid
fillParent
data={rows}
columns={columns}
pagination={{ pageSize: 100 }}
footer={(table) => <DataGridPaginationBar table={table} className="grid-footer-pagination" />}
/>
</div>
)
}Behavior:
- The shell, table wrapper, container, and body wrapper participate in a full-height flex chain.
- Short data still fills the parent region, so the footer remains at the parent bottom.
- Overflowing data scrolls only inside the body area.
- Large row sets still use table virtualization even without
tableHeight.
fillContainer and fillParent solve different layout problems:
| Prop | Use when | Short data | Overflowing data |
|---|---|---|---|
fillContainer |
Parent height is a cap, but content should stay natural when short | Footer sits directly below the table | Body scrolls, footer remains visible |
fillParent |
Parent height is the layout contract and the grid should fill it | Footer stays at parent bottom | Body scrolls, footer stays at parent bottom |
Parent requirements:
- The direct parent must have a real height, or be inside a valid
height: 100%/ flex chain. - In flex layouts, the parent chain should allow shrinking with
min-height: 0. - Do not use
fillParentas an alias fortableHeight="100%"; use the prop so GridKit can set the internal flex and virtualization behavior correctly.
If tableHeight and fillParent are both provided, tableHeight remains the explicit body height. Prefer using only one layout mode.
import { DataGridInfinity } from '@loykin/gridkit'
export function MyInfiniteTable() {
const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery(...)
return (
<DataGridInfinity
data={data}
columns={columns}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
tableHeight={500}
/>
)
}Renders rows with your own item component instead of table markup. Columns still define the row schema for sorting, filtering, and global search; they do not have to be visible.
import { DataGridList, GlobalSearch, SelectFilter } from '@loykin/gridkit'
const columns = [
{ accessorKey: 'name' },
{ accessorKey: 'department', meta: { filterType: 'select' } },
{ accessorKey: 'status', meta: { filterType: 'select' } },
]
export function EmployeeList() {
return (
<DataGridList
data={employees}
columns={columns}
containerHeight={560}
itemGap={8}
itemPadding={12}
headerLeft={(table) => (
<SelectFilter table={table} columnId="department" label="Department" />
)}
headerRight={(table) => <GlobalSearch table={table} placeholder="Search…" />}
renderItem={(row) => (
<div className="rounded border p-3">
<strong>{row.original.name}</strong>
<span>{row.original.status}</span>
</div>
)}
/>
)
}<DataGridList
data={data}
columns={columns}
renderItem={(row) => <InboxRow item={row.original} />}
containerHeight={600}
enableVirtualization
estimateRowHeight={56}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>List views use the shared row/data/filtering props, but omit table-only options such as column resizing, pinning, headers, and table width modes.
| Prop | Type | Default | Description |
|---|---|---|---|
renderItem |
(row: Row<T>) => ReactNode |
— | Required. Render function for each list item |
itemKey |
(row: Row<T>) => string |
row.id |
Override the React key for each item |
itemGap |
number |
0 |
Gap in px between list items |
itemPadding |
number |
0 |
Padding in px around the list body |
containerHeight |
string | number | 'auto' |
'auto' |
Preferred list container height |
tableHeight |
string | number | 'auto' |
'auto' |
Compatibility alias for containerHeight |
enableVirtualization |
boolean |
false |
Render only the visible item window. Requires a fixed containerHeight or tableHeight |
estimateRowHeight |
number |
48 |
Estimated item height in px for virtualization |
overscan |
number |
10 |
Items rendered outside the visible window when virtualized |
headerLeft |
ReactNode | (table: Table<T>) => ReactNode |
— | Toolbar content on the left. Function form receives the table instance |
headerRight |
ReactNode | (table: Table<T>) => ReactNode |
— | Toolbar content on the right. Function form receives the table instance |
footer |
ReactNode |
— | Static content below the list |
hasNextPage |
boolean |
— | Whether more pages exist |
isFetchingNextPage |
boolean |
— | Show loading indicator at the bottom |
fetchNextPage |
() => void |
— | Called when the sentinel enters the viewport |
rootMargin |
string |
'100px' |
IntersectionObserver rootMargin for early trigger |
classNames |
DataGridListClassNames |
— | Slot-based class injection for list elements |
| Variable | Default | Description |
|---|---|---|
--dg-list-gap |
0px |
Gap between list items |
--dg-list-padding |
0px |
Padding around the list body |
Renders row data as a message timeline. It supports loading older rows from the top, preserving scroll offset after prepends, and automatically staying at the bottom when the user is already near the latest message.
import { DataGridChat } from '@loykin/gridkit'
const columns = [
{ accessorKey: 'author' },
{ accessorKey: 'body' },
{ accessorKey: 'createdAt' },
]
export function MessageTimeline() {
return (
<DataGridChat
data={messages}
columns={columns}
getRowId={(message) => message.id}
containerHeight={640}
hasPreviousPage={hasPreviousPage}
isFetchingPreviousPage={isFetchingPreviousPage}
fetchPreviousPage={fetchPreviousPage}
renderMessage={(row) => <MessageBubble message={row.original} />}
renderTypingIndicator={() => <TypingIndicator />}
/>
)
}Chat views use the shared row/data/filtering props, but omit table-only options such as column resizing, pinning, headers, table width modes, and checkbox selection.
| Prop | Type | Default | Description |
|---|---|---|---|
renderMessage |
(row: Row<T>) => ReactNode |
— | Required. Render function for each message |
renderDaySeparator |
(row, previousRow) => ReactNode |
— | Optional non-row separator before a message |
renderUnreadMarker |
(row) => ReactNode |
— | Optional non-row marker before a message |
renderTypingIndicator |
() => ReactNode |
— | Optional content after the latest message |
hasPreviousPage |
boolean |
— | Whether older rows exist |
isFetchingPreviousPage |
boolean |
— | Show loading indicator at the top |
fetchPreviousPage |
() => void |
— | Called when the top sentinel enters the viewport |
rootMargin |
string |
'100px' |
IntersectionObserver rootMargin for early trigger |
stickToBottom |
boolean |
true |
Auto-scroll when the user is already near the bottom |
bottomThreshold |
number |
48 |
Distance in px considered “at bottom” |
onAtBottomChange |
(atBottom: boolean) => void |
— | Called when bottom state changes |
containerHeight |
string | number | 'auto' |
'auto' |
Preferred chat container height |
tableHeight |
string | number | 'auto' |
'auto' |
Compatibility alias for containerHeight |
footer |
ReactNode |
— | Static content below the chat container |
classNames |
DataGridChatClassNames |
— | Slot-based class injection for chat elements |
Renders rows as a responsive card grid instead of a table. All filtering, sorting, global search, and infinite scroll work identically to DataGridInfinity — only the visual output changes.
import { DataGridCard, GlobalSearch } from '@loykin/gridkit'
const columns = [
{ accessorKey: 'name' },
{ accessorKey: 'category', meta: { filterType: 'select' } },
{ accessorKey: 'price' },
]
export function ProductGrid() {
return (
<DataGridCard
data={products}
columns={columns}
minCardWidth={240}
minColumns={2}
enableSorting
headerRight={(table) => <GlobalSearch table={table} placeholder="Search…" />}
renderCard={(row) => (
<div className="rounded-lg border p-4">
<h3 className="font-semibold">{row.original.name}</h3>
<p className="text-sm text-muted-foreground">{row.original.category}</p>
<p className="mt-2 font-medium">${row.original.price}</p>
</div>
)}
/>
)
}<DataGridCard
data={data}
columns={columns}
renderCard={(row) => <ProductCard product={row.original} />}
minCardWidth={240}
minColumns={2}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>| Props | CSS generated | Behaviour |
|---|---|---|
minCardWidth={240} |
repeat(auto-fill, minmax(240px, 1fr)) |
Responsive — 1 col on mobile, 4+ on desktop |
minCardWidth={240} minColumns={2} |
repeat(auto-fill, minmax(min(240px, 50%), 1fr)) |
Responsive, but never fewer than 2 columns |
cardColumns={4} |
repeat(4, 1fr) |
Always exactly 4 columns |
All shared props apply. Additional props:
| Prop | Type | Default | Description |
|---|---|---|---|
renderCard |
(row: Row<T>) => ReactNode |
— | Required. Render function for each card |
minCardWidth |
number |
240 |
Minimum card width in px — column count adjusts automatically |
minColumns |
number |
1 |
The grid never collapses below this number of columns |
cardColumns |
number |
— | Fixed column count — overrides minCardWidth and minColumns |
hasNextPage |
boolean |
— | Whether more pages exist |
isFetchingNextPage |
boolean |
— | Show loading indicator at the bottom |
fetchNextPage |
() => void |
— | Called when the sentinel enters the viewport |
rootMargin |
string |
'100px' |
IntersectionObserver rootMargin for early trigger |
footer |
ReactNode |
— | Static content below the card container |
classNames |
DataGridCardClassNames |
— | Slot-based class injection for card elements |
| Variable | Default | Description |
|---|---|---|
--dg-card-gap |
16px |
Gap between cards |
--dg-card-padding |
16px |
Padding around the grid |
import { DataGridDrag, DragHandleCell } from '@loykin/gridkit'
const columns = [
{
id: 'drag',
size: 36,
enableResizing: false,
cell: () => <DragHandleCell />,
},
{ accessorKey: 'name', header: 'Name' },
]
export function MyDraggableTable() {
const [rows, setRows] = useState(data)
return (
<DataGridDrag
data={rows}
columns={columns}
getRowId={(row) => row.id}
onRowReorder={setRows}
/>
)
}Place
DragHandleCellin thecellof whichever column should act as the grab handle.
Columns follow @tanstack/react-table's ColumnDef with additional meta options:
import type { DataGridColumnDef } from '@loykin/gridkit'
const columns: DataGridColumnDef<User>[] = [
{
accessorKey: 'name',
header: 'Name',
meta: {
flex: 1, // stretch proportionally to fill remaining width
minWidth: 100,
align: 'left', // 'left' | 'center' | 'right'
pin: 'left', // 'left' | 'right'
wrap: true, // allow multi-line cell content
filterType: 'text', // 'text' | 'select' | 'multi-select' | 'number' | 'date' | 'date-range' | 'datetime' | 'datetime-range' | 'custom' | false
filterParams: {
width: 260, // filter popover width for icon-mode filters
placeholder: 'Search name…',
},
},
},
{
id: 'actions',
header: '',
meta: {
actions: (row) => [
{ label: 'Edit', onClick: (row) => openEdit(row) },
{ label: 'Delete', onClick: (row) => deleteRow(row), variant: 'destructive' },
],
},
},
]Grouped headers use TanStack's nested columns shape:
const columns: DataGridColumnDef<User>[] = [
{
id: 'identity',
header: 'Identity',
columns: [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
],
},
{
id: 'activity',
header: 'Activity',
columns: [
{ accessorKey: 'status', header: 'Status' },
{ accessorKey: 'lastSeen', header: 'Last Seen' },
],
},
]headerGroupLayout controls how ungrouped leaf columns are rendered alongside group headers.
| Value | Behaviour |
|---|---|
'padded' (default) |
Ungrouped leaf columns show a blank placeholder cell in the group row. Layout is uniform — all header rows are the same height |
'span' |
Ungrouped leaf columns stretch to fill the full header height. No placeholder is rendered |
// padded (default) — blank cell above "ID", full height group headers
<DataGrid columns={columns} />
// span — "ID" occupies both rows, no blank placeholder
<DataGrid columns={columns} headerGroupLayout="span" />Group header resize is intentionally disabled. Group header width is always the sum of its leaf columns. Only leaf column resize handles are shown.
| Field | Type | Description |
|---|---|---|
flex |
number |
Flex ratio — distributes remaining container width proportionally |
width |
number |
Fixed preferred column width in px |
autoSize |
boolean |
Auto-fit column width to content via canvas text measurement |
minWidth |
number |
Minimum column width in px |
maxWidth |
number |
Maximum column width in px |
align |
'left' | 'center' | 'right' |
Cell text alignment |
pin |
'left' | 'right' |
Pin column at definition level |
wrap |
boolean |
Allow multi-line content; row height adjusts automatically |
filterType |
'text' | 'select' | 'multi-select' | 'number' | 'date' | 'date-range' | 'datetime' | 'datetime-range' | 'custom' | false |
Filter input type for this column |
filterParams.width |
number |
Filter popover width in px for icon-mode filter popovers and row-mode multi-select popups. Does not resize the column menu popover |
filterParams.maxOptionsHeight |
number |
Multi-select option list max height in px. Defaults to 192 |
filterParams.placeholder |
string |
Text filter placeholder. Defaults to Filter… |
backend.field |
string |
Backend field name sent to DataStoreBackend params. Defaults to the column id |
backend.filterType |
'text' | 'multi-select' | 'range' | false |
Override filterType for backend mode only |
backend.sortable |
boolean |
Whether this column is sortable in backend mode |
editCell |
(props: EditCellProps<T, V>) => ReactNode |
Inline cell editor triggered by double-click. Must call props.onCommit(value) or props.onCancel(). Requires onCellValueChange on the DataGrid |
actions |
(row: T) => Action[] |
Row action menu items |
| Prop | Type | Default | Description |
|---|---|---|---|
data |
T[] |
[] |
Row data |
dataStore |
DataStore<T> |
— | Map-based store for real-time updates. Mutually exclusive with data |
queryMode |
'client' | 'backend' |
'client' |
In backend mode, sorting, filtering, search, and pagination call dataStore.query() |
columns |
DataGridColumnDef<T>[] |
— | Column definitions |
error |
Error | null |
— | Display error state |
isLoading |
boolean |
— | Show loading skeleton |
emptyMessage |
string |
— | Message when data is empty |
emptyContent |
ReactNode |
— | Custom empty state UI (overrides emptyMessage) |
showHeader |
boolean |
true |
Show/hide the header row |
fillContainer |
boolean |
false |
Fit inside an explicit parent height while keeping short content natural and scrolling only the body on overflow |
fillParent |
boolean |
false |
DataGrid table only. Fill a parent-owned height with an internal flex scroll chain |
tableHeight |
string | number | 'auto' |
'auto' |
Fixed height — enables internal scroll and virtualization |
maxTableHeight |
string | number |
— | Cap height — grows with content up to this limit, then scrolls |
minTableHeight |
string | number |
— | Floor height — content shorter than this keeps minimum space |
rowHeight |
number |
33 |
Row height in px (also sets virtualizer estimate) |
estimateRowHeight |
number |
— | Override virtualizer estimate independently of rowHeight |
overscan |
number |
10 |
Rows to render outside the visible area |
bordered |
boolean |
false |
Show vertical dividers between columns |
tableWidthMode |
'spacer' | 'fill-last' | 'independent' |
'spacer' |
How remaining horizontal space is distributed |
onRowClick |
(row: T) => void |
— | Row click handler |
rowCursor |
boolean |
false |
Show pointer cursor on rows |
classNames |
DataGridClassNames |
— | Slot-based class injection for table elements |
icons |
DataGridIcons |
— | Override any built-in icon slot |
| Prop | Type | Default | Description |
|---|---|---|---|
headerGroupLayout |
'padded' | 'span' |
'padded' |
Header group layout. span lets ungrouped leaf headers occupy the full grouped header height |
enableColumnMenu |
boolean |
false |
Show a ⋯ menu button inside each column header |
renderColumnMenu |
(col: Column<T>, table: Table<T>, close: () => void, ctx: ColumnMenuContext) => ReactNode |
— | Custom column header menu. ctx provides pre-resolved canSort, canFilter, canPin flags |
| Prop | Type | Default | Description |
|---|---|---|---|
enableSorting |
boolean |
true |
Enable column sorting |
enableMultiSort |
boolean |
false |
Enable Shift+click multi-column sorting |
maxMultiSortColCount |
number |
3 |
Maximum sorted columns when multi-sort is enabled |
initialSorting |
SortingState |
— | Initial sort state |
onSortingChange |
(s: SortingState) => void |
— | Called on sort change |
manualSorting |
boolean |
false |
Disable client-side sort — handle externally |
| Prop | Type | Default | Description |
|---|---|---|---|
enableColumnFilters |
boolean |
false |
Show per-column filter UI |
filterDisplay |
'row' | 'icon' |
'row' |
Filter as dedicated row or icon inside header cell |
customFilterComponents |
Record<string, ComponentType<CustomFilterProps<T, any>>> |
— | Register custom filter UI by filterType |
manualFiltering |
boolean |
false |
Disable client-side filtering — handle externally |
columnFilters |
ColumnFiltersState |
— | Controlled column filter state |
onColumnFiltersChange |
(f: ColumnFiltersState) => void |
— | Called on filter change |
globalFilter |
string |
— | Controlled global search value |
onGlobalFilterChange |
(v: string) => void |
— | Called on global search change |
searchableColumns |
string[] |
— | Column keys included in global search |
headerLeft |
ReactNode | (table: Table<T>) => ReactNode |
— | Toolbar content on the left. Function form receives the table instance |
headerRight |
ReactNode | (table: Table<T>) => ReactNode |
— | Toolbar content on the right. Function form receives the table instance |
Per-column filter UI can be tuned with column.meta.filterParams:
const columns = [
{
accessorKey: 'country',
meta: {
filterType: 'multi-select',
filterParams: {
width: 280,
maxOptionsHeight: 320,
},
},
},
{
accessorKey: 'description',
meta: {
filterType: 'text',
filterParams: {
width: 320,
placeholder: 'Search description…',
},
},
},
]width applies to filter popovers opened from header filter icons and to row-mode multi-select popups. It intentionally does not resize the column menu popover, which also contains sort and pinning actions.
Custom filter UI can replace any built-in filter type. The second type parameter V on CustomFilterProps types the filter value — each component can declare its own value shape with no casting required:
import type { CustomFilterProps } from '@loykin/gridkit'
type DateTimeRange = [string, string] | undefined
function MyDateTimeRangeFilter<T extends object>({
value,
onChange,
close,
}: CustomFilterProps<T, DateTimeRange>) {
const [start = '', end = ''] = value ?? ['', '']
return (
<DateTimeRangePicker
start={start}
end={end}
onChange={(nextStart, nextEnd) => onChange([nextStart, nextEnd])}
onApply={close}
/>
)
}
<DataGrid
columns={[
{ accessorKey: 'timestamp', header: 'Time', meta: { filterType: 'datetime-range' } },
]}
enableColumnFilters
filterDisplay="icon"
customFilterComponents={{
'datetime-range': MyDateTimeRangeFilter,
}}
/>| Prop | Type | Default | Description |
|---|---|---|---|
enableColumnResizing |
boolean |
true |
Enable drag-to-resize columns |
columnResizeMode |
'onChange' | 'onEnd' |
'onChange' |
When resize updates are applied |
columnSizingMode |
'auto' | 'flex' | 'fixed' |
'flex' |
Column width strategy |
columnSizing |
ColumnSizingState |
— | Initial column widths |
onColumnSizingChange |
(s: ColumnSizingState) => void |
— | Called on column resize |
| Prop | Type | Default | Description |
|---|---|---|---|
visibilityState |
VisibilityState |
— | Controlled column visibility |
onColumnVisibilityChange |
(v: VisibilityState) => void |
— | Called when column visibility changes |
initialPinning |
ColumnPinningState |
— | Initial pinned columns { left: [...], right: [...] } |
enableColumnPinning |
boolean |
false |
Show pin/unpin menu inside each column header |
enableColumnReordering |
boolean |
false |
Enable drag-to-reorder columns by dragging the header |
| Prop | Type | Default | Description |
|---|---|---|---|
enableExpanding |
boolean |
false |
Enable collapsible sub-rows |
getSubRows |
(row: T, index: number) => T[] | undefined |
— | Extract sub-rows from a row item |
renderDetailRow |
(row: Row<unknown>) => ReactNode |
— | Render a master-detail panel below each row. Use ExpandToggleCell in a column to toggle |
| Prop | Type | Default | Description |
|---|---|---|---|
enableGrouping |
boolean |
false |
Enable grouping rows by column value |
grouping |
GroupingState |
— | Controlled array of column IDs to group by |
onGroupingChange |
(grouping: GroupingState) => void |
— | Called when grouping changes |
renderGroupRow |
(row: Row<T>) => ReactNode |
— | Custom group header renderer |
| Prop | Type | Default | Description |
|---|---|---|---|
checkboxConfig |
CheckboxConfig<T> |
— | Row checkbox selection configuration |
| Prop | Type | Default | Description |
|---|---|---|---|
tableKey |
string |
— | Key for in-memory Zustand state persistence |
syncState |
boolean |
false |
Sync pagination and search state (requires tableKey) |
statePersistence |
GridKitStatePersistence |
— | Load/save grid preferences through localStorage, backend APIs, etc. Requires tableKey |
| Prop | Type | Default | Description |
|---|---|---|---|
onTableReady |
(table: Table<T>) => void |
— | Called when TanStack Table instance is ready |
onCellValueChange |
(rowId: string, columnId: string, value: unknown) => void |
— | Called when the user commits an inline cell edit |
onColumnOrderChange |
(order: string[]) => void |
— | Called when column order changes via drag |
onColumnPinningChange |
(pinning: ColumnPinningState) => void |
— | Called when column pinning changes |
| Prop | Type | Default | Description |
|---|---|---|---|
tableOptions |
PassthroughTableOptions<T> |
— | Escape hatch for advanced TanStack Table options |
<DataGrid
tableKey="users-grid"
statePersistence={{
load: async (tableKey) => api.get(`/grid-preferences/${tableKey}`),
save: async (tableKey, state) => {
await api.put(`/grid-preferences/${tableKey}`, state)
},
debounce: 500,
include: [
'columnSizing',
'columnOrder',
'columnPinning',
'columnVisibility',
'sorting',
'pageSize',
],
}}
/>| Prop | Type | Default | Description |
|---|---|---|---|
pagination |
DataGridPaginationConfig |
— | Enables TanStack pagination. Omit to disable |
footer |
(table: Table<T>) => ReactNode |
— | Render slot below the grid (pagination bar, totals row, etc.) |
tableRef |
RefObject<Table<T> | null> |
— | Ref populated with the TanStack Table instance |
| Prop | Type | Default | Description |
|---|---|---|---|
hasNextPage |
boolean |
— | Whether more pages exist |
isFetchingNextPage |
boolean |
— | Show loading indicator at bottom |
fetchNextPage |
() => void |
— | Called to load the next page |
rootMargin |
string |
'100px' |
IntersectionObserver rootMargin for early trigger |
| Prop | Type | Default | Description |
|---|---|---|---|
getRowId |
(row: T, index: number) => string |
— | Required. Stable unique id per row |
onRowReorder |
(newData: T[]) => void |
— | Called with the full reordered data array after each drag |
import {
GlobalSearch,
SelectFilter,
MultiSelectFilter,
ColumnVisibilityDropdown,
} from '@loykin/gridkit'
<DataGrid
headerLeft={(table) => (
<>
<GlobalSearch table={table} placeholder="Search…" />
<SelectFilter table={table} columnId="status" label="Status" />
<MultiSelectFilter table={table} columnId="department" label="Dept" />
</>
)}
headerRight={(table) => <ColumnVisibilityDropdown table={table} />}
...
/>Use queryMode="backend" when the grid should hold only the current backend result window while sorting, filtering, global search, and pagination are translated into backend-neutral query params.
GridKit owns the grid lifecycle and query state. Your backend owns data semantics: REST params, SQL, IndexedDB queries, cache policy, polling, schema setup, and domain-specific filter behavior.
import {
DataGrid,
DataGridPaginationBar,
GlobalSearch,
useDataStore,
useDataStoreQueryState,
} from '@loykin/gridkit'
import type { DataStoreBackend, QueryParams } from '@loykin/gridkit'
interface AuditEvent {
id: string
user: string
action: string
status: string
}
const backend: DataStoreBackend<AuditEvent> = {
capabilities: {
filtering: true,
sorting: true,
pagination: true,
globalSearch: true,
facets: true,
},
async query(params: QueryParams) {
// Translate params.filters / params.globalFilter / params.sort
// / params.limit / params.offset into REST, SQL, IndexedDB, etc.
return fetchAuditEvents(params)
},
async getFacets(params) {
// Optional: values for select and multi-select filter UIs.
return fetchAuditFacetValues(params)
},
}
const columns = [
{ accessorKey: 'user', meta: { filterType: 'text', backend: { field: 'user_name' } } },
{ accessorKey: 'action', meta: { filterType: 'multi-select' } },
{ accessorKey: 'status', meta: { filterType: 'select' } },
]
export function AuditGrid() {
const store = useDataStore<AuditEvent>({
getRowId: (row) => row.id,
backend,
})
const queryState = useDataStoreQueryState(store)
return (
<DataGrid
dataStore={store}
queryMode="backend"
columns={columns}
enableColumnFilters
filterDisplay="icon"
enableMultiSort
isLoading={queryState.isHydrating || queryState.isQuerying}
error={queryState.error}
headerLeft={(table) => <GlobalSearch table={table} />}
pagination={{ pageSize: 100 }}
footer={(table) => (
<DataGridPaginationBar table={table} className="grid-footer-pagination" totalCount={queryState.total} />
)}
/>
)
}type FilterOperator =
| 'eq' | 'neq'
| 'in' | 'notIn'
| 'like' | 'startsWith' | 'endsWith'
| 'empty' | 'notEmpty'
| 'range'
| 'gt' | 'gte' | 'lt' | 'lte'
interface FilterExpr {
field: string
op: FilterOperator
value?: unknown
}
interface SortExpr {
field: string
desc?: boolean
}
interface QueryParams {
filters?: FilterExpr[]
globalFilter?: string
sort?: SortExpr[]
limit?: number
offset?: number
}field is column.meta.backend.field when provided, otherwise the column id. GridKit does not generate SQL, know schemas, escape database paths, poll APIs, or choose fallback/cache policy.
When query criteria change, backend mode resets pagination to page 0 before querying. With controlled pagination.pageIndex, GridKit calls onPageChange(0, pageSize) and waits for the caller to pass the updated pageIndex.
queryMode="backend" automatically sets manualSorting, manualFiltering, and manualPagination to true — client-side row processing is disabled entirely. You do not need to set these manually.
getFacets is optional. When present, select and multi-select filter options are loaded from the backend instead of scanning the current page.
interface FacetParams {
field: string
filters?: FilterExpr[]
globalFilter?: string
limit?: number
}
interface FacetResult {
values: string[]
truncated?: boolean
hasEmpty?: boolean
}Facet requests exclude the current column's own filter but include the other active filters and global search.
When hasEmpty is true, the multi-select filter UI automatically prepends an (empty) option so users can filter for null or blank values without custom UI.
To avoid redundant getFacets calls when only one column's filter changes, enable the built-in cache:
const store = useDataStore<Row>({
getRowId: (r) => r.id,
backend,
facetCache: {
strategy: 'by-other-filters', // invalidate only when other column filters change
maxEntries: 100, // LRU eviction limit (default: 100)
},
})Set facetCache: true for the same behaviour with default options.
applyTransaction() is the synchronous local path for realtime updates. If persist: true is set, GridKit fires backend.applyTransaction() without awaiting it.
applyTransactionAsync() is persistence-first. When persist: true, it awaits backend.applyTransaction() before updating the in-memory store; if the backend write fails, local rows are not changed.
const result = await store.applyTransactionAsync({
update: [{ id: row.id, data: { status: 'done' } }],
persist: true,
})
if (!result.ok) {
reportError(result.error)
}backend.capabilities is optional. In development, GridKit warns when queryMode="backend" uses a feature the backend declares unsupported.
const backend: DataStoreBackend<Row> = {
capabilities: {
filtering: true,
sorting: false,
pagination: true,
},
query,
}This is the lower-level manual alternative to queryMode="backend". Use it when the application wants to own all query orchestration and pass only the current page rows into data.
import { DataGrid, DataGridPaginationBar } from '@loykin/gridkit'
import type { ColumnFiltersState, SortingState } from '@tanstack/react-table'
export function ServerGrid() {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [pageRows, setPageRows] = useState([])
const [totalCount, setTotalCount] = useState(0)
const PAGE_SIZE = 20
async function load(pageIndex: number, pageSize: number) {
const { rows, total } = await fetchItems({ sorting, columnFilters, pageIndex, pageSize })
setPageRows(rows)
setTotalCount(total)
}
return (
<DataGrid
data={pageRows}
columns={columns}
isLoading={isLoading}
manualSorting
onSortingChange={setSorting}
manualFiltering
enableColumnFilters
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
pagination={{
pageSize: PAGE_SIZE,
pageCount: Math.ceil(totalCount / PAGE_SIZE),
onPageChange: (pageIndex, pageSize) => load(pageIndex, pageSize),
}}
footer={(table) => (
<DataGridPaginationBar table={table} className="grid-footer-pagination" totalCount={totalCount} />
)}
tableHeight={500}
/>
)
}// localStorage
const [sizing, setSizing] = useLocalStorageState('my-table-sizing', { defaultValue: {} })
<DataGrid
columnSizing={sizing}
onColumnSizingChange={setSizing}
...
/>import { TreeCell } from '@loykin/gridkit'
const columns = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row, getValue }) => (
<TreeCell row={row}>{getValue<string>()}</TreeCell>
),
},
]
<DataGrid
data={treeData}
columns={columns}
enableExpanding
getSubRows={(row) => row.children}
/>Group rows by one or more column values. Grouped rows are collapsible and show the group value and sub-row count by default.
<DataGrid
data={data}
columns={columns}
enableGrouping
grouping={['status']}
/>Grouping state can be controlled externally:
const [grouping, setGrouping] = useState<GroupingState>(['department'])
<DataGrid
data={data}
columns={columns}
enableGrouping
grouping={grouping}
onGroupingChange={setGrouping}
/>Use renderGroupRow to customize the group header:
<DataGrid
data={data}
columns={columns}
enableGrouping
grouping={['status']}
renderGroupRow={(row) => (
<span>{String(row.groupingValue)} — {row.subRows.length} items</span>
)}
/>| Prop | Type | Default | Description |
|---|---|---|---|
enableGrouping |
boolean |
false |
Enable row grouping |
grouping |
GroupingState |
— | Controlled array of column IDs to group by |
onGroupingChange |
(grouping: GroupingState) => void |
— | Called when grouping changes |
renderGroupRow |
(row: Row<T>) => ReactNode |
— | Custom group header renderer |
GroupingStateisstring[]from@tanstack/react-table.
For high-frequency updates (WebSocket, polling) — only changed rows are re-evaluated:
import { useDataStore, DataGrid } from '@loykin/gridkit'
export function LiveTable() {
const store = useDataStore<Order>({ getRowId: (o) => o.id })
useEffect(() => {
ws.on('order', (order) => {
store.applyTransaction({ update: [{ id: order.id, data: order }] })
})
}, [])
return <DataGrid dataStore={store} columns={columns} tableHeight={500} />
}Re-runs the last query with the same parameters — useful for polling or triggering a refresh after an external data change:
// Refresh every 30 seconds
useEffect(() => {
const id = setInterval(() => store.refetch(), 30_000)
return () => clearInterval(id)
}, [store])When the underlying data source is not yet available on mount (e.g. a SQLite cache still loading), pass ready: false to delay the first query until the source is ready:
const store = useDataStore<Row>({
getRowId: (r) => r.id,
backend,
ready: false, // first query is queued, not fired
})
// Later, once the data source is ready:
store.setReady(true) // flushes the pending query immediatelystore.isReady() returns the current ready state synchronously.
Post-process each row after it enters the store. Receives the previous row with the same id so unchanged derived values can be returned by reference, avoiding unnecessary re-renders:
const store = useDataStore<Row>({
getRowId: (r) => r.id,
backend,
transformRow: (row, prev) => {
// Skip re-parsing when the row hasn't changed
if (prev && prev.rawJson === row.rawJson) return prev
return { ...row, parsed: JSON.parse(row.rawJson) }
},
})Replace any built-in icon slot. All icons accept any React node.
import { ChevronUp, ChevronDown, Filter } from 'lucide-react'
<DataGrid
icons={{
sortAsc: <ChevronUp size={12} />,
sortDesc: <ChevronDown size={12} />,
filter: <Filter size={13} />,
}}
...
/>| Slot | Default icon | Used in |
|---|---|---|
sortAsc |
ArrowUp | Sorted ascending header |
sortDesc |
ArrowDown | Sorted descending header |
sortNone |
ArrowUpDown | Sortable but unsorted header |
filter |
Filter | Header filter icon button |
filterRange |
SlidersHorizontal | Number range filter button |
clearFilter |
X | Clear filter / search button |
rowActions |
MoreHorizontal | Row actions trigger (⋯) |
columnVisibility |
Columns3 | Column visibility dropdown button |
loading |
Loader2 | Loading spinner |
pageFirst |
ChevronsLeft | Go to first page |
pagePrev |
ChevronLeft | Go to previous page |
pageNext |
ChevronRight | Go to next page |
pageLast |
ChevronsRight | Go to last page |
search |
Search | Global search input prefix |
treeExpand |
ChevronRight | Tree row expand |
treeCollapse |
ChevronDown | Tree row collapse |
dragHandle |
GripVertical | Row drag handle |
<DataGrid
tableOptions={{
meta: { myData: 'value' },
autoResetPageIndex: false,
autoResetColumnFilters: false,
defaultColumn: { size: 150 },
}}
...
/>Excluded from tableOptions (managed internally): data, columns, state, getRowId, all on*Change handlers, all row model getters, manualSorting, manualPagination, manualFiltering. The manual-mode flags are set automatically when queryMode="backend".
interface CheckboxConfig<T> {
getRowId: (row: T) => string
selectedIds: Set<string>
onSelectAll: (rows: Row<T>[], checked: boolean) => void
onSelectOne: (rowId: string, checked: boolean) => void
}GridKit treats src/core/table as the built-in table composition layer, not as a feature-free rendering primitive. It owns the standard DataGrid table structure and composes the built-in feature slices used by the default grid experience:
- sorting indicators
- header filter controls
- pinning and resize controls
- row action triggers
- inline editing cell content
- selection, expansion, and reordering integration
The src/features/* folders contain reusable built-in feature slices. Importing those slices from core/table is intentional for the current architecture. A stricter slot/injection model can be introduced later if GridKit needs a plugin system or alternate table renderers, but it should start as an internal refactor rather than a public extension API.
Good future candidates for partial injection, if the coupling starts to hurt:
DataGridBodyCell: editable cell content and row action triggerDataGridFilterRow: built-in filter controlsDataGridHeaderCell: sort, filter, pinning, and resize controls
Until then, keep core/engine backend-neutral, keep filter-specific option/facet loading in features/filters, and avoid moving product/domain behavior into GridKit.
MIT