A strongly typed, virtualized, themeable React data grid built on TanStack Table v9. It consolidates the best behavior from the HOPS, Compflow, and Pullit grids into one package without depending on any of their UI kits.
| Source | Consolidated behavior |
|---|---|
| HOPS | Stable row-ID requirement for selection, Shift range selection through v9, deterministic skeleton widths, per-column skeleton shapes, column formatting/alignment, and thorough interaction coverage |
| Compflow | TanStack v9 feature gating and exported feature-aware types, unified column preferences, drag/resize separation, inline filters, and the sticky far-right Columns control |
| Pullit | Presentation-only section headers that participate in virtualization without changing the flat column contract |
The package currently targets React 19 and TanStack Table v9. totalCount is required by design: it lets the grid establish a correct scroll model and record count before all server pages have loaded.
Install the complete source-owned component into your configured shadcn ui directory:
bunx shadcn@latest add https://jeff-table.vercel.app/r/data-grid.jsonOr register the hosted catalog in your components.json:
{
"registries": {
"jeff-table": {
"url": "https://jeff-table.vercel.app/r/{name}.json"
}
}
}Then install by namespace:
bunx shadcn@latest add @jeff-table/data-gridYou can also install directly from the public GitHub source registry without waiting for a hosted registry build:
bunx shadcn@latest add jhodges10/jeff-table/data-gridThe registry item installs the required TanStack and date-picker dependencies, then writes the
component source to @ui/jeff-table. Import it through the generated source entry point:
import { DataGrid, type DataGridColumnDef } from "@/components/ui/jeff-table";Its stylesheet maps the DataGrid theme to shadcn's background, foreground, primary, muted,
accent, border, destructive, and radius variables, while retaining standalone fallbacks and
the existing typed theme overrides.
bun add @jhodges/jeff-tableThe default stylesheet is included by the package entry. It is also exported as @jhodges/jeff-table/styles.css for build systems that prefer an explicit CSS entry.
import {
DataGrid,
type DataGridColumnDef,
} from "@jhodges/jeff-table";
interface User {
id: string;
name: string;
email: string;
status: "active" | "paused";
}
const columns: DataGridColumnDef<User>[] = [
{
accessorKey: "name",
enableHiding: false,
header: "Name",
meta: {
filter: { type: "text", placeholder: "Find a name…" },
minWidth: 180,
width: "minmax(180px, 1fr)",
},
},
{ accessorKey: "email", header: "Email", meta: { width: "1.5fr" } },
{
accessorKey: "status",
header: "Status",
meta: {
filter: {
type: "single-select",
options: [
{ label: "Active", value: "active" },
{ label: "Paused", value: "paused" },
],
},
},
},
];
export function UsersGrid({ users, totalCount }: { users: User[]; totalCount: number }) {
return (
<DataGrid
aria-label="Users"
columns={columns}
data={users}
totalCount={totalCount}
getRowId={(user) => user.id}
enableColumnFiltering
enableColumnReordering
enableColumnResizing
enableColumnVisibility
tableId="users"
/>
);
}Use this package's DataGridColumnDef<T> alias instead of importing ColumnDef directly from TanStack. V9 includes the registered feature set in its column, row, header, and table generics; the exported alias preserves that complete contract, including typed meta.
Virtualization is on by default. The viewport derives a useful height from totalCount, rowHeight, and maxVisibleRows; pass height when the parent owns the exact size.
<DataGrid
columns={columns}
data={loadedUsers}
totalCount={10_000}
height={600}
hasMore={loadedUsers.length < 10_000}
isLoadingMore={query.isFetchingNextPage}
onLoadMore={({ loadedCount }) => query.fetchNextPage({ offset: loadedCount })}
/>- Unloaded virtual positions render deterministic skeletons instead of blank space.
- Skeleton widths vary by absolute row index and column ID, so recycled rows do not shimmer into a different layout.
meta.skeletonsupportsbar,square, andcircle, a fixed size, width bounds, orfalse.isLoadingwith no data renders initial skeleton rows. With existing data it becomes a refresh state.isLoadingMorepreserves loaded rows and puts the loading indicator directly beside the record count in the bottom-left footer.slots.loadingIndicatorreplaces the default spinner.
Set virtualize={false} for a genuinely small, fully loaded data set. Unloaded placeholders are never materialized in that mode.
Sections are presentation-only. Filtering and sorting run first; section order follows the first row in the resulting model and rows retain their model order within each section.
<DataGrid
columns={columns}
data={users}
totalCount={users.length}
sections={{
getKey: (user) => user.status,
headerHeight: 44,
renderHeader: ({ key, rows }) => (
<div className="flex justify-between">
<strong>{key}</strong>
<span>{rows.length} loaded</span>
</div>
),
renderSelection: ({ checked, indeterminate, key, toggle }) => (
<IndeterminateCheckbox
aria-label={`Select ${key} section`}
checked={checked}
indeterminate={indeterminate}
onChange={(event) => toggle(event.currentTarget.checked)}
/>
),
}}
/>Section headers are part of the virtual item model. With row selection enabled, they receive a default animated select-all checkbox; renderSelection can replace it while retaining the section's checked, indeterminate, disabled, and toggle behavior. When a server result is partially loaded, the grid renders known sections followed by unloaded row placeholders because section keys for records not yet fetched are unknowable.
The outer header, sticky column headers, scroll body, and footer are separate surfaces. Slots accept a node or a render callback with the live table instance and useful grid state.
<DataGrid
// ...
slots={{
headerStart: <SavedViewPicker />,
headerEnd: <CreateButton />,
columnHeaderEnd: <LiveBadge />,
footerStart: ({ selectedRows }) => <BulkActions rows={selectedRows} />,
footerEnd: ({ resetPreferences }) => (
<button onClick={resetPreferences}>Reset columns</button>
),
empty: <EmptyUsers />,
error: ({ totalCount }) => <RetryState expectedCount={totalCount} />,
}}
/>Available slots are headerStart, headerEnd, columnHeaderEnd, footerStart, footerEnd, loadingIndicator, empty, and error.
Column behaviors are independent opt-ins:
<DataGrid
enableColumnReordering
enableColumnResizing
enableColumnVisibility
tableId="opportunities"
// ...
/>- Reordering begins on the six-dot grab handle.
Alt+LeftandAlt+Rightprovide a keyboard alternative. - The remaining sortable header is still a sort target and the right edge remains a resize target.
- Set
meta.reorderable: false,meta.resizable: false, or TanStack'senableHiding: falseper column. - The far-right Columns menu follows the current order, includes hidden columns, locks non-hideable columns, and never permits the final visible column to be hidden.
tableIdpersists order, sizes, and visibility in a versioned localStorage document.- Supply
initialPreferences,onPreferencesChange, or a custom synchronouspreferenceStorageadapter for SSR cookies or application-owned storage.
Set enableGlobalFilter for fuzzy, case- and accent-insensitive search. Set enableColumnFiltering and declare meta.filter to render inline controls.
Supported column filter types:
textsingle-selectmulti-selectbooleannumber-rangedate-range
Date ranges use the exported DateRangePickerPanel: preset ranges commit immediately, while calendar selections remain drafts until Apply. The standalone DateRangePicker adds a formatted trigger, popover dismissal, and clear action for use outside a grid.
<DateRangePicker
from={range.from}
to={range.to}
onChange={setRange}
placeholder="Order date"
/>All filter UIs use TanStack's columnFilters state. Sorting, column filters, and global filtering can each be controlled or uncontrolled. Set manualSorting or manualFiltering when a server owns the results; the UI continues to report state through onSortingChange, onColumnFiltersChange, and onGlobalFilterChange.
Selection is a discriminated TypeScript contract. Turning it on makes getRowId mandatory, preventing index-based selection from drifting after server sorting or infinite-page appends.
<DataGrid
enableRowSelection
getRowId={(user) => user.id}
isRowSelectable={(user) => user.status !== "paused"}
onRowSelectionChange={(selection, selectedUsers) => {
// selection is the TanStack v9 ID map; selectedUsers contains loaded originals
}}
// ...
/>TanStack v9 supplies contiguous Shift selection through the row checkbox handlers. Select-all only applies to currently loaded, selectable rows.
DataGridColumnMeta adds:
| Field | Purpose |
|---|---|
width, minWidth, maxWidth |
Default CSS grid track and resize bounds |
headerAlign, cellAlign |
Independent header/body alignment |
headerTooltip |
Accessible help content beside the label |
filter |
Inline filter configuration |
numericSort |
Numeric sorting for formatted values |
prefix, suffix |
Default-cell value decoration |
reorderable, resizable |
Per-column interaction opt-outs |
skeleton |
Placeholder shape and stable sizing |
headerClassName, cellClassName |
Per-column style hooks |
Every visual decision is a design token — 29 of them, covering the palette, overlay surfaces, elevation, focus ring, grid lines, skeletons, scrollbars, spacing, and type. Each resolves in three steps:
- whatever you set — the
themeprop writes inline custom properties, or set--jt-*in plain CSS on any ancestor; - the matching shadcn/ui variable, if your app defines one (
--background,--foreground,--primary,--muted,--border,--radius,--ring,--popover); - a built-in light/dark pair.
In an app that already defines the shadcn variables, the grid arrives themed.
Pass a typed theme object for runtime values:
<DataGrid
theme={{
accent: "#0f766e",
border: "#99f6e4",
headerBackground: "#f0fdfa",
selected: "#ccfbf1",
radius: "1rem",
}}
// ...
/>For Tailwind, use the className, classNames, and per-column class hooks. Every important element also has a stable data-slot or state attribute.
<DataGrid
className="rounded-none border-2 border-violet-500 shadow-xl"
classNames={{
columnHeader: "bg-violet-50 uppercase tracking-wide",
row: "hover:bg-violet-50 data-[state=selected]:bg-violet-100",
footer: "bg-slate-50",
}}
// ...
/>No Tailwind runtime is required by this package. The defaults live in the standard CSS components layer, so Tailwind's utilities layer wins when class slots supply utilities. This keeps the package compatible with Tailwind v4 projects, plain CSS, CSS Modules, and other styling systems.
dataGridThemeTokens maps every theme key to its custom property, and
createThemeStyle turns a partial theme into inline properties — both are
exported for building your own theme editors.
Dark mode is a property of the page, not a prop. Every built-in default is a
light-dark() pair keyed off color-scheme, which CSS inherits:
| The page does this | The grid |
|---|---|
Declares color-scheme: dark on <html> |
Follows it |
Sets class="dark" (Tailwind, shadcn/ui) |
Follows it |
Sets data-theme="dark" (next-themes) |
Follows it |
Redefines --background / --foreground in a .dark block |
Uses those values |
| Nothing | Renders light |
Nothing needs to be passed, and nothing needs to stay in sync with your theme
state. When a grid has to differ from the page around it — a dark analytics
panel on a light dashboard — colorScheme pins it:
<DataGrid colorScheme="dark" /> // "inherit" (default) | "light" | "dark" | "system"A standalone DateRangePickerPanel rendered outside a grid needs the token
block; wrap it in .jt-theme.
Dark mode is not verified by eye: bun run test:storybook runs the whole story
suite in Chromium twice, once against a light host page and once against a dark
one, with axe assertions on both passes.
Viewport scrolling is overlay by default: inset thumbs sit on the rows instead of reserving an OS gutter. Pass scrollbar="native" for platform scrollbars — styled to the grid tokens on Chromium, thin on Firefox.
<DataGrid scrollbar="native" /> // "overlay" (default) | "native"The grid renders a window, not a table, so a 50,000-row grid commits the same
rows, cells, and DOM nodes as a 1,000-row grid. benchmarks/ asserts that
directly — and because wall-clock numbers fail for reasons nobody can act on,
every benchmark counts work instead: rendered rows, cell renders, React commits,
DOM nodes, filter evaluations, comparator calls. Fixed fixtures and a pinned
viewport mean two machines produce the same numbers.
bun run bench # assert every scenario against its budget
BENCH_REPORT_ONLY=1 bun run bench # print the numbers without failingSee benchmarks/README.md for the metrics, the budgets, and how to re-baseline.
The package is type-checked with TypeScript 7 (the native Go compiler) and formatted/linted with Biome. Install the workspace-recommended Biome and TypeScript 7 extensions so format-on-save and editor diagnostics match CI.
bun run check
bun run typecheck
bun run test
bun run test:coverage
bun run test:storybook
bun run bench
bun run registry:validate
bun run registry:build
bun run storybook
bun run build:storybook
bun run buildStorybook includes overview, 10,000-row virtualization, all column controls, every filter family, sections, stable/range selection, separate slots, initial/refresh/load-more states, custom empty/error states, CSS-variable themes, Tailwind class overrides, and density variants.