Overview
MCP Server
Custom Shadcn Data Grid for React and Tailwind CSS. A powerful TanStack Table v9 data grid with sorting, filtering, pagination, footer rows, drag-and-drop, row and column virtualization, infinite scroll, row pinning, tree rows, spreadsheet-style cell selection with clipboard and inline editing, and localized labels through one i18n prop.
The current data-grid package ships the shared grid context, table renderers, pagination, column controls, drag-and-drop helpers, virtualization, infinite scroll, footer helpers, row-pinning support, tree rows, and spreadsheet-style cell selection with clipboard and editing support (data-grid-cell-selection.tsx). Footer components (DataGridTableFoot, DataGridTableFootRow, DataGridTableFootRowCell) plus the DataGridTableRowPin and DataGridTableRowExpand toggles are exported from data-grid-table.tsx.
In the base build, data-grid-scroll-area.tsx is also included and exports DataGridScrollArea for dedicated scroll handling around sticky-header or wide tables.
Browse 34 production-ready Shadcn Data Grid components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 34 Shadcn Data Grid components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.
This primitive also powers ready-made ReUI Pro blocks: complete data grid sections assembled on top of the exact component documented here, with realistic data, polished layout details, and production interaction patterns you can copy straight into your app.
Preview all 37 Shadcn Data Grid Pro blocks in the ReUI blocks gallery.
The data grid is built on TanStack Table v9. v9 asks every table to declare which features it uses, so the grid exports a ready-made bundle:
import { useTable } from "@tanstack/react-table"
import { dataGridFeatures } from "@/components/reui/data-grid/data-grid"
const table = useTable({
features: dataGridFeatures,
columns,
data,
state: { sorting, pagination },
onSortingChange: setSorting,
onPaginationChange: setPagination,
})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, which registers every built-in v9 ships: alphanumeric, alphanumericCaseSensitive, basic,
datetime, text and textCaseSensitive. On v9 a string sortFn resolves against this registry alone, and
the default sortFn: "auto" infers one of those names from the first row's value, so the map has to be
complete or an ordinary string column warns in development and falls back to unsorted order. Register a custom
comparator by extending the bundle with tableFeatures({ ...dataGridFeatures, sortFns: { ...} }), or pass a
function directly as sortFn.columnMeta, which types columnDef.meta as DataGridColumnMeta (headerTitle, headerClassName,
cellClassName, skeleton, expandedContent, autoSize, fillWidth, cellEdit). A features-level columnMeta slot wins over the
global ColumnMeta interface, so the v8 declare module augmentation is ignored on any table built with
dataGridFeatures. Add your own fields to DataGridColumnMeta in the installed data-grid.tsx instead.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/react-table"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
const columns: ColumnDef<DataGridFeatures, IData>[] = [
// ...
]
function ActionsCell({ row }: { row: Row<DataGridFeatures, IData> }) {
// ...
}If you build your own bundle instead, annotate against that one (typeof features) rather than
DataGridFeatures.
You still own the table. <DataGrid> accepts any feature bundle, so extend it when a grid needs more:
const features = tableFeatures({
...dataGridFeatures,
columnGroupingFeature,
groupedRowModel: createGroupedRowModel(),
})or hand over a leaner one of your own when you want a smaller bundle. One dependency to keep: cellSelectionFeature gates the whole spreadsheet layer, and with a bundle that omits it tableLayout.cellSelection is silently inert (both the controller and the cell renderer check table.atoms.cellSelection, not the flag). <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 that is wider or leaner than dataGridFeatures needs a cast at those boundaries.
If you already have the Data Grid installed, paste the prompt below into your coding agent. It covers both cases: a session with the ReUI MCP connected, and a plain project where the primitive is reinstalled from the registry.
Migrate ReUI Data Grid to TanStack Table v9.
STEP 1 - Replace the Data Grid primitive from the registry.
Case A, the ReUI MCP is available in this session:
- Call search("data-grid"), then get_component("data-grid") to read the current API.
- Call get_examples("data-grid") to see real composition.
- Run the command returned by get_install_command("data-grid") and overwrite when prompted.
Case B, no ReUI MCP:
- Run: npx shadcn@latest add @reui/data-grid
- Answer yes when asked to overwrite the existing files.
Every data-grid file must come from the registry. Do not hand-patch them, do not merge
them by hand, and do not keep a local fork of any of them.
STEP 2 - Move to TanStack Table v9 and confirm v8 is gone.
- Set "@tanstack/react-table" to "^9.0.0" in package.json.
- Reinstall (npm install, pnpm install or yarn).
- Do not continue until all three checks pass:
1. package.json lists "@tanstack/react-table": "^9.0.0" and no other version of it.
2. The installed version is 9.x. Run: npm ls @tanstack/react-table
3. No v8 copy survives in the lockfile. Search the lockfile for "react-table" and
confirm every resolved version is 9.x. If an 8.x entry remains, another dependency
is pulling it in: dedupe or upgrade that package.
- This matters because v9 removed useReactTable entirely. A leftover v8 install under v9
code fails as an unresolved import, not as a helpful error.
STEP 3 - Update every table you own.
- useReactTable({ ... }) becomes useTable({ features: dataGridFeatures, ... }), importing
dataGridFeatures from your installed data-grid.
- Delete the getCoreRowModel / getSortedRowModel / getFilteredRowModel /
getPaginationRowModel options. dataGridFeatures already registers them.
- If a grid used to render every row, add manualPagination: true. The v9 paginated row
model always slices, so that grid would otherwise show only the first page.
- Add the leading TFeatures generic to types: ColumnDef<TData, TValue> becomes
ColumnDef<DataGridFeatures, TData, TValue>. Same for Row, Column, Cell, Header, Table.
- Column pinning is start/end, never left/right: ColumnPinningState, column.pin(),
getIsPinned(), and any CSS matching [data-pinned="left"].
- Renames: table.getState() becomes table.state, VisibilityState becomes
ColumnVisibilityState, sortingFn becomes sortFn, columnSizingInfo becomes columnResizing.
- Indeterminate header checkbox: use
getIsSomePageRowsSelected() && !getIsAllPageRowsSelected().
- Column meta comes from the exported DataGridColumnMeta. Delete any
declare module "@tanstack/react-table" augmentation you added.
- Remove "use no memo" from files that only wrap the grid.
- State shapes got stricter: ColumnPinningState requires both start and end,
RowPinningState requires both top and bottom, and RowSelectionState narrowed to
Record<string, true>, so deselecting by writing false no longer type-checks.
STEP 4 - Prove the migration is complete.
Search the whole project. Every one of these must return zero results:
useReactTable
getCoreRowModel
data-pinned="left"
table.getState(
declare module "@tanstack/react-table"
Any hit is a call site you missed.
STEP 5 - Verify it actually runs.
Typecheck must pass with no errors. Then open each grid and confirm: sorting, paging,
column visibility, pinning a column to each side while scrolling horizontally, row
selection including the header checkbox, and column resizing.TanStack Table v8 returned a stable table instance whose state mutated internally, so React Compiler memoized
reads against a reference that never changed and state updates could be skipped. That is what the "use no memo"
directive worked around, and on v9 it is no longer needed here: useTable returns a fresh table reference on
every state change, which is exactly the signal the compiler needs.
One gap remains, and the grid already handles it for you. State is not only read through table, it is also read
through builder calls like row.getIsSelected() and column.getIsPinned(). Those hide their dependency from the
compiler, and when a header or cell is rendered by a nested component holding a stable row or column, the
compiler can memoize that JSX and never re-run the read. ReUI wraps its own such reads - the selection checkboxes
and the column header - in TanStack's Subscribe, so sort arrows, pin controls and checkboxes stay live.
What you must do. If your own cell or header template is a named child component that reads state through
a builder call, subscribe there too:
import { Subscribe } from "@tanstack/react-table"
cell: ({ row }) => (
<Subscribe source={row.table.atoms.rowSelection}>
{() => <MyCheckbox checked={row.getIsSelected()} />}
</Subscribe>
)Note the standalone Subscribe rather than table.Subscribe: inside a column definition the table you receive
is typed as the core Table from @tanstack/table-core, and only the ReactTable that useTable returns
declares Subscribe, so row.table.Subscribe does not type-check.
The same rule applies to cell selection: a custom cell template reading cell.getIsSelected(),
cell.getIsFocused() or cell.getSelectionEdges() must subscribe to row.table.atoms.cellSelection, the same
shape as the row-selection snippet above. The grid's own cell renderer already does this for the attributes it
paints.
import { useTable, type ColumnDef } from "@tanstack/react-table"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import {
DataGridTable,
DataGridTableFootRow,
DataGridTableFootRowCell,
} from "@/components/reui/data-grid/data-grid-table"const columns: ColumnDef<DataGridFeatures, User>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
]
const footer = (
<DataGridTableFootRow>
<DataGridTableFootRowCell colSpan={columns.length}>
Showing {data.length} rows
</DataGridTableFootRowCell>
</DataGridTableFootRow>
)
const table = useTable({
features: dataGridFeatures,
data,
columns,
})
return (
<DataGrid
table={table}
recordCount={data.length}
tableLayout={{ rowsPinnable: true }}
>
<DataGridContainer>
<DataGridTable footerContent={footer} />
</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 you want the dedicated base scroll wrapper. When rows can be pinned or virtualized, provide a stable getRowId so row identity stays intact across reordering.
Cell range selection, clipboard copy / cut / paste with Excel and Google Sheets round-trip, a fill handle, inline editors, and a bulk edit bar over row selection, all in one grid alongside sorting, pagination, resizable and pinned columns.
36 metric columns over 1,000 rows with both axes virtualized: only the
horizontal window of center columns is in the DOM, the pinned edge columns stay
mounted, and the header buttons jump the window through
scrollToColumnIndex.
One i18n prop swaps every built-in string, so the header menu, the row
select and pagination aria labels, and the pagination copy follow the language
switch. The column titles and cell text stay the consumer's own, alongside a
locale-aware Intl.NumberFormat.
The end-to-end pattern for a grid backed by a large server-side dataset: the table holds exactly one page, and pagination, sorting, and a debounced search are sent to the server, which returns the page plus the total after filtering. The demo simulates the server with an in-file async function over 487 records; replace its body with a fetch to your own API and keep the return shape.
The contract that makes the controls work: pass the server-side total twice.
rowCount on useTable drives table.getPageCount(), so the page buttons know
how many pages exist beyond the loaded rows; recordCount on DataGrid drives
the "1 - 5 of 487" info text. With manualPagination and only recordCount
set, the info text claims the full count while the buttons collapse to a single
page. isLoading renders the built-in skeleton rows during each fetch, and a
request id guard keeps a slow response from overwriting a newer one.
For the full set of variations (expandable rows, sub-grids, tree rows, sortable / movable / draggable / resizable / pinnable columns, sticky header, column controls and visibility, loading skeleton, CRUD in frame, footer totals / summary / aggregates, infinite scroll, server side pagination, row pinning, and spreadsheet editing) browse the Data Grid components.
The root component that provides the table context.
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: useTable hands back the options object you passed, so feature defaults never show up on table.options.
Custom CSS classes for different parts of the table.
The outer wrapper for the grid. It clips overflow, so scrolling comes from 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 component that renders the actual HTML table. It automatically 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 on useTable.
The component for table pagination controls. The record info comes from the recordCount prop on DataGrid, and the page buttons come from table.getPageCount(), so they only render when there is more than one page. The page row is an adaptive window: the first page, the run around the current one, and the last page, with a clickable ellipsis standing in for each hidden stretch. The last page is therefore always one click away, and the row does not change width as you page. In v9 getPageCount() is pageCount ?? Math.ceil(rowCount / pageSize) and rowCount falls back to the pre-paginated row count, so a Data Grid whose data holds only the current page must pass rowCount or pageCount to useTable or the buttons never appear.
sizesInfo, sizesLabel, sizesDescription and more are still accepted by DataGridPaginationProps but are not rendered.
The count beside each option comes from column.getFacetedUniqueValues(). On v9 that method only exists when the feature bundle registers columnFacetingFeature: leave the feature out of a leaner bundle of your own and the call throws, because the method is never added to the column. The numbers themselves come from the facetedUniqueValues row model, and without it the method still answers, with an empty map, so the counts quietly vanish. facetedRowModel is what makes those counts respect the table's other active filters instead of the unfiltered rows. 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/react-table"
const columns = [
{
accessorKey: "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). Without a function the value still lands in columnFilters, no row is ever filtered, and v9 warns in development that the filter function is not registered. The alternative is to register the names you want in your own bundle through the filterFns slot, the way dataGridFeatures registers sortFns.
Used for enabling column drag-and-drop reordering with optional footer rendering.
The sortable items come from table.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] = useState<string[]>(() =>
columns.map((column) => column.id as string)
)
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return
setColumnOrder((order) =>
arrayMove(
order,
order.indexOf(active.id as string),
order.indexOf(over.id as string)
)
)
}
const table = useTable({
features: dataGridFeatures,
columns,
data,
state: { columnOrder },
onColumnOrderChange: setColumnOrder,
})Leave columnOrder empty and the headers never resolve a position inside the sortable context, so the columns do not shift during the gesture and arrayMove runs against an empty array, which commits nothing. The slice is read as table.state.columnOrder on v9, where v8 used table.getState().columnOrder; setColumnOrder, onColumnOrderChange and ColumnOrderState keep their v8 names and shape.
Used for enabling 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:
getRowId must be stable and match dataIds. dnd-kit identifies rows by row.id, so dataIds has to be the same ids in the same order as the rendered rows. Deriving both from the record's own id is the reliable pattern.data, not by mutating it. TanStack reprocesses rows when the data reference changes, so an in-place splice leaves the grid showing the old order.const [data, setData] = useState(rows)
const dataIds = useMemo(() => data.map(({ id }) => id), [data])
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return
setData((current) => {
const from = current.findIndex((row) => row.id === active.id)
const to = current.findIndex((row) => row.id === over.id)
return from === -1 || to === -1 ? current : arrayMove(current, from, to)
})
}
const table = useTable({
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,
data,
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 also takes className, 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:
verticalListSortingStrategy as sortingStrategy for the old behaviour.data-edge says 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"]; it carries data-edge="top" | "bottom" for the side the row would land on, so you can style your own seam from it.
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
}
// in onDragOver / handleDragEnd
const active = event.active.data.current as DataGridTableDndRowData
const over = event.over?.data.current as DataGridTableDndRowData | undefinedTogether these cover cross-parent (re-parenting) drops: drop restrictToVerticalAxis from modifiers so a horizontal gesture can express a depth change, 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.
Every built-in string - the header menu items, aria labels for row pin / select / expand and the drag handles, the pagination copy, the loading, empty and end-of-list states, and the faceted filter's texts - is replaceable in one place through i18n on DataGrid. Labels that interpolate are functions, so pluralization and word order live in the label rather than in string concatenation:
<DataGrid
i18n={{
labels: {
sortAscending: "Aufsteigend",
pinColumnStart: "Links anheften",
rowsPerPage: "Zeilen pro Seite",
paginationInfo: ({ from, to, count }) => `${from}-${to} von ${count}`,
goToPage: (page) => `Seite ${page}`,
},
}}
>The merge is shallow per section: untouched keys keep their defaults, and a more specific component prop that predates i18n (rowCreateLabel, loadingMessage, emptyMessage, the DataGridPagination label props, the row-DnD disabledLabel) still wins over its i18n counterpart, so adopting i18n is never a breaking change. The full key set with defaults ships in data-grid-i18n.tsx; DataGridI18nLabels, DataGridI18nConfig, DataGridI18nOverrides and mergeDataGridI18n are exported from it.
A virtualized table renderer using @tanstack/react-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.
Column virtualization. With columnVirtualizerOptions={{ enabled: true }}, only the center columns inside the horizontal window render; each off-window flank collapses into one colSpan spacer (data-slot="data-grid-table-virtual-col-spacer") whose width comes from the intact colgroup, so a fixed table layout cannot drift. It activates only under tableLayout.width: "fixed" (the default) with a single ungrouped header row; grouped headers or width: "auto" silently fall back to full-column rendering. Start- and end-pinned columns stay mounted outside the window, the footer always renders in full, both virtualizers share the grid's resolved scroll element, and RTL mirrors automatically. Windowed body cells carry data-column-index with their center-column index. Combining a column window with cellSelection is not supported yet: keyboard ranges can span unmounted columns.
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. manualPagination: true is v9's way to say the data already is the page, so it keeps the pagination APIs while leaving the rows unsliced.
The headless spreadsheet controller: keyboard navigation, clipboard, delete-to-clear, and the fill-handle drag session. Mount it once inside DataGridContainer, next to the table, and turn the feature on with tableLayout={{ cellSelection: true }}. It renders a hidden anchor plus the built-in editor overlay while an edit is open; listeners attach to the grid's own body viewport, while DOM focus sits on the <table> inside it, so a virtualized row unmounting can never strand keyboard focus.
Mouse selection itself (click, Shift-click, Ctrl/Cmd-click for extra regions, and drag) is wired by the cell renderer whenever cellSelection is on; the controller adds everything that needs a listener beyond the cells.
The imperative API. A create-row flow cannot focus the new row's cell through state alone: setFocusedCell needs the row in the table model, DOM focus needs the grid's focus target, and both race the commit that mounts the row. apiRef hands you focusCell(rowId, columnId, { edit? }), which retries briefly until the row renders, then sets the focused cell, focuses the grid, scrolls the cell into view and points aria-activedescendant at it; edit: true also opens the cell's editor the way Enter would. Call it after appending a row (or saving a draft) and the user can type immediately. The api also carries clearSelection() and scrollToCell(rowId, columnId) for rendered cells.
Accessibility. With cellSelection on, the table is an ARIA grid: role="grid" with aria-multiselectable, aria-rowcount behind pagination, aria-rowindex/aria-colindex on rows and cells, aria-sort on sortable headers (present with or without cellSelection), and aria-selected on every body cell, true or false. DOM focus stays on the table (the container-focus model) while aria-activedescendant tracks the focused cell, so screen readers announce every move; keys from a focused in-cell control (a button, link, checkbox, select trigger) always stay with that control, and Escape hands focus back to the grid. aria-multiselectable reflects cellSelectionMode, and the built-in editor is named after its column's header. Keyboard navigation itself lives in the body cells; header controls are reached with Tab and keep their native keyboard.
Keyboard map.
In RTL, ArrowLeft and ArrowRight mirror to the visual direction; Tab and Shift+Tab do not - Tab always means the next or previous cell in display order, matching DOM tab order.
The write contract. The grid never mutates data. Every write path (paste, cut, clear, fill, and your own editors if you route them the same way) produces one batched call to onCellsChange on DataGrid, and your state update is the single owner of the rows:
interface DataGridCellsChangeDetails<TData> {
source: "paste" | "cut" | "clear" | "fill" | "edit"
changes: Array<{
rowId: string
columnId: string
row: TData // the row object, no lookup needed
previousValue: unknown // enables an undo stack
value: unknown
}>
rejected: Array<{
rowId: string
columnId: string
raw: string
reason: "readonly" | "invalid"
}>
}Without onCellsChange, copy still works and every write path is a no-op.
Batches act on what the view can show. Selection bounds live in pre-paginated display order, so a range whose corners are both visible can still span off-page rows in between (a page-tail cell extended to a bottom-pinned draft row); copy, cut, clear, paste and fill skip rows outside the page slice plus rendered pinned rows, so the cells a batch touches are exactly the cells the selection paints. A fill that crosses into a different column round-trips each value through the source's format and the target's parse - the paste contract - and reports failures in rejected; a same-column fill keeps raw values.
Per-column editing via meta.cellEdit. Presence marks the column writable; every field is optional:
meta: {
cellEdit: {
editable: true, // false: formats clipboard output but never writes; a function decides per row
parse: (raw, row) => Number(raw) || undefined, // undefined rejects the cell
format: (value, row) => String(value), // clipboard output
clearValue: 0, // Delete/cut value, default null
control: "text", // built-in editor: "text" | "textarea", optional
}
}Copy uses format on any column, so a select column can emit labels and map them back in parse. Function-form editable locks individual rows (archived, another user's, a totals row): every write path treats a locked row's cell as read-only, the fill preview never tints it, and paste reports it in rejected. A column without parse receives the raw pasted string unchanged. A column without cellEdit is read-only: pastes over it land in rejected with "readonly" instead of changing data.
Built-in editors. A column with cellEdit.control gets the grid's own free-text editor: an overlay flush over the focused cell, opened by Enter, F2, typing, or double-click, with the cell's own font, alignment and padding so the text keeps its exact place, and one primary border - the overlay covers the cell's focus chrome, so opening it reads as the focused box becoming editable. "text" is a single-line input; "textarea" grows downward as the text wraps, the Sheets model. Enter commits and keeps focus on the cell so the result can be reviewed in place (tableLayout.cellEditEnterAdvance restores the Sheets move-down, with Shift+Enter up), Tab commits and moves across, Escape cancels, blur commits; Shift/Alt+Enter inserts a newline in a textarea, the caret opens at the end of the value, an unchanged commit dispatches nothing, and rows changing under an open editor cancel it rather than committing blind. Commits arrive as one onCellsChange batch with source "edit", parse applied and rejections reported, so the write path is the same one paste and fill use.
CRUD affordances. All optional and prop-gated. onRowCreate renders a quick-create "Add row" as the body's last row, the Notion/Airtable idiom; what the click creates stays yours. It follows tableLayout.dense, accepts tableClassNames.rowCreate for row-height alignment, renders in the virtual layout too (as does appendRow), and when creating unmounts it (a draft row takes its place) it hands DOM focus to the grid so the keyboard stays live. getRowStatus tints rows the consumer tracks as "new" or "dirty" and mutes plus strikes "deleted" ones; getCellStatus draws the classic corner mark on "dirty" (amber) or "invalid" (destructive) cells. Omit any of them and nothing renders - a read-only grid carries zero CRUD chrome.
Consumer editors. Columns without control hand the same keystrokes to yours instead; onCellEditRequest on DataGrid is how the keyboard asks it to open. It fires only when the column is writable via meta.cellEdit. Without it, Enter, F2 and double-click fall back to activating whatever interactive content the cell renders, focusing it and clicking it exactly as a mouse would, so a select, combobox or checkbox in a cell is keyboard-reachable with no wiring at all; a cell holding plain text keeps the Excel move-down. Typing a character never activates a control:
interface DataGridCellEditRequest<TData> {
rowId: string
columnId: string
row: TData
previousValue: unknown
initialText?: string // the typed character; absent for Enter and F2
}Seed your input with initialText when it is present so typing replaces the value, the way Notion and Airtable start an edit; commit the result through the same state update as onCellsChange batches, and hand focus back to the grid when the editor closes: focus the [data-slot="data-grid-table"] element inside it, or simply call apiRef.current?.focusCell(rowId, columnId), which is what the controller itself does.
Exports. Alongside the components, the module exports the pure pieces so a consumer can build custom flows on the same contracts: getDataGridVisibleSelectedCellCount(table, viewport) (the selection count that matches what is painted), invertDataGridCellsChange(details) (the undo batch: values and previous values swapped, so feeding it through your onCellsChange state update implements undo, and inverting the inverse is redo), getDataGridActiveRegionGrid, buildDataGridClearDetails, buildDataGridPasteDetails, parseDataGridClipboardText, serializeDataGridClipboardText, tileDataGridClipboardBlock, and the types DataGridCellSelectionApi, DataGridFocusCellOptions and DataGridPasteTarget (DataGridCellSelectionSnapshot, DataGridCellSelectionBound and DataGridCopyDetails come from the data-grid module). Every viewport argument is the grid's body scroll viewport, [data-slot="data-grid-table-viewport"] inside DataGridContainer; passing null falls back to full-range math that can span off-page rows. For a custom cell renderer, the data-grid module exports getDataGridCellSelectionCellAttrs(cell), the whole td contract (aria-selected, data-col-id, data-cell-selected, data-cell-focused and the four data-cell-edge-* sides), and dataGridCellSelectionCellClasses, the chrome those attributes drive; read both inside a Subscribe source={table.atoms.cellSelection} render. DataGridTableAddRow is exported from the table module for custom layouts. The selection overlay's boundary clamps are driven by the custom properties --data-grid-overlay-start, --data-grid-overlay-end and --data-grid-overlay-bottom (each defaults to -1px), which dataGridCellSelectionCellClasses consumers can zero out at their own boundaries.
meta.autoSize. One column can absorb the container's free space into its committed width, and the absorbed width reflows LIVE with the container: growing the window widens the column, shrinking hands the space back down to its minSize (or its starting width when none is set), and a streaming window drag is coalesced into a leading commit plus one settle commit. A width the user dragged is never touched; a double-click reset on its handle re-arms the fill.
meta.fillWidth. Under columnsResizable, one column can absorb the free space the filler strip would otherwise hold, so the grid always reads full-width and the built-in editor covers the whole cell. While free space remains, manually resizing that column is visually a no-op (the absorbed space compensates), the usual flex-column trade-off.
Paste semantics follow Excel: a 1x1 block fills the whole selected range; a block tiles when the range is an exact multiple of it; otherwise it pastes once from the range's top-left, clamped at the grid edges (rows are never grown). The pasted region becomes the selection (under cellSelectionMode: "single" the focused cell is kept instead). Cut copies the active region, then clears its writable cells.
Fill handle. With cellFillHandle on, dragging the corner handle down or right repeats the source range over the dragged extent (dominant axis, like Excel), then grows the selection over source plus filled cells. A same-column fill copies raw values; a fill crossing into a different column round-trips through the source's format and the target's parse, rejecting failures into the batch. Cells that cannot be written are skipped and never tinted by the preview, a multi-region selection refuses the drag entirely, Escape cancels it, and under virtualization the drag can only target rendered rows. tableLayout.cellFillHandleVariant picks the handle's look, tableClassNames.cellFillHandle restyles it freely, and the handle hides while an editor session is open.
Interplay with grid features.
enableCellSelection: false on the column definition; ranges, keyboard stepping and select-all skip them.cellEditMode: "click" makes everything single-click. Keys inside an editable element never move the selection; Escape hands focus back to the grid unless the control consumed it with preventDefault. For custom widgets built from other elements, put data-cell-interactive on the widget's root to opt out entirely, or data-cell-control on a widget whose active surface is bigger than its buttons (a combobox opened by its whole chips strip) to make a press anywhere on it two-step.autoResetPageIndex: false to useTable; every commit replaces data, and the TanStack default would snap a paginated grid back to page 1 on each write.cellSelection on, the grid sets autoResetCellSelection: false on your table, overriding the TanStack default: every write batch replaces data, and the default would wipe the selection after each commit.rowsPinnable, keyboard navigation and every batch walk rendered rows, so pinned rows (a bottom-pinned create draft) select, edit and fill like any other row; only the selection bounds are stored in data order.previousValue, so an undo stack is a list of batches replayed through the same state update with value and previousValue swapped.A thin bulk-edit shell over row selection: selected count, a slot for your controls, and a clear action. Hidden while nothing is selected, and presented as a floating toolbar the Google Sheets way: auto width, centered, elevated, sticky to the viewport bottom with breathing room. What the controls do stays yours; apply bulk updates with your own state update over table.getSelectedRowModel().rows.
A pin/unpin toggle button for use in column definitions to enable row pinning.
DataGridFeatures is the type of the exported dataGridFeatures bundle. 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: useState<RowPinningState>({ top: [], bottom: [] }).
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, for example {row.getIsExpanded() ? <FolderOpenIcon /> : <FolderIcon />}; 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.
Wrapper component for the table footer (<tfoot>).
A row inside the table footer.
A cell inside a footer row.
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. v9 moves column pinning to logical regions, and the grid follows it, so any CSS selecting [data-pinned="left"] needs updating to [data-pinned="start"].
Rows carry data-row-status ("new" | "dirty" | "deleted", from getRowStatus) and cells data-cell-status ("dirty" | "invalid", from getCellStatus) whenever those props are wired, independent of cellSelection; they drive the CRUD tints and corner marks. With tableLayout.cellSelection on, body cells additionally carry the spreadsheet contract below (plus aria-selected on every body cell, true or false, with role="grid" and aria-multiselectable on the table). None of these render while the flag is off.
The selection chrome is the Sheets model: a light background tint marks membership, a solid primary perimeter wraps the range and follows it LIVE while a drag or Shift+arrows grow it, the anchor cell stays unfilled so the typing target reads at a glance, and a lone focused cell draws its box with no fill at all. While a fill drag is live, one dashed border (the Sheets fill marquee) wraps the whole pending region - the source plus the extension - and the source's own chrome rests, so the drag reads as one growing region. Every line except the fill marquee is each cell's own ::before overlay driven purely by the data attributes above, so the whole language can be restyled with data-[cell-...] variants on the cell. The fill handle is data-slot="data-grid-cell-fill-handle" on the region's bottom-right cell, the marquee is data-slot="data-grid-cell-fill-preview" (one positioned element appended to the viewport for the session), and the built-in editor overlay is data-slot="data-grid-cell-editor". The body scroll viewport ([data-slot="data-grid-table-viewport"]) carries one session attribute per gesture: data-cell-selecting while a drag-selection is in progress (the fill handle hides), data-cell-filling for the duration of a fill drag (every selected cell's own chrome rests, so the marquee is the only painter), and data-cell-editing while the built-in editor is open (the edited cell's chrome and the fill handle hide).
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 from a layout effect rather than through React state: a resize drag fires at pointer rate, and routing that through a render would re-render every row on every frame. The header height is measured once per drag session for the same reason.
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.
"use client"
import { useMemo, useState } from "react"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
ColumnOrderState,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Card } from "@/components/ui/card"
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string // Emoji flags
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const users = [
{
id: "1",
name: "Alex Johnson",
email: "alex@example.com",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
initials: "AJ",
},
{
id: "2",
name: "Sarah Chen",
email: "sarah@example.com",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
initials: "SC",
},
{
id: "3",
name: "Michael Rodriguez",
email: "michael@example.com",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
initials: "MR",
},
{
id: "4",
name: "Emma Wilson",
email: "emma@example.com",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
initials: "EW",
},
{
id: "5",
name: "David Kim",
email: "david@example.com",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
initials: "DK",
},
{
id: "6",
name: "Aron Thompson",
email: "lisa@example.com",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
initials: "LT",
},
{
id: "7",
name: "James Brown",
email: "james@example.com",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
initials: "JB",
},
{
id: "8",
name: "Maria Garcia",
email: "maria@example.com",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
initials: "MG",
},
{
id: "9",
name: "Nick Johnson",
email: "nick@example.com",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
initials: "NJ",
},
{
id: "10",
name: "Liam Thompson",
email: "liam@example.com",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
initials: "LT",
},
]
const demoData: IData[] = users.map((user, index) => ({
...user,
availability: (["online", "away", "busy", "offline"] as const)[index % 4],
status: (index % 2 === 0 ? "active" : "inactive") as "active" | "inactive",
flag: (["us", "gb", "ca", "au", "de", "my", "es", "jp", "fr", "it"] as const)[
index % 10
],
company: (
[
"Apple",
"OpenAI",
"Meta",
"Tesla",
"SAP",
"Keenthemes",
"BBVA",
"Sony",
"LVMH",
"ENI",
] as const
)[index % 10],
role: (
[
"CEO",
"CTO",
"Designer",
"Developer",
"Lawyer",
"Director",
"Product Manager",
"Marketing Lead",
"Data Scientist",
"Engineer",
] as const
)[index % 10],
joined: "Jan, 2024",
location: (
[
"United States",
"United Kingdom",
"Canada",
"Australia",
"Germany",
"Malaysia",
"Spain",
"Japan",
"France",
"Italy",
] as const
)[index % 10],
balance: 5143.03 + index * 100,
}))
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: true },
])
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: "Name",
cell: ({ row }) => {
return (
<div className="flex items-center gap-3">
<Avatar className="size-8">
<AvatarImage
src={row.original.avatar}
alt={row.original.name}
/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="space-y-px">
<div className="text-foreground font-medium">
{row.original.name}
</div>
<div className="text-muted-foreground">
{row.original.email}
</div>
</div>
</div>
)
},
size: 250,
enableSorting: true,
enableHiding: false,
},
{
accessorKey: "company",
header: "Company",
cell: (info) => <span>{info.getValue() as string}</span>,
size: 100,
meta: {
headerClassName: "",
},
},
{
accessorKey: "role",
header: "Occupation",
cell: (info) => <span>{info.getValue() as string}</span>,
size: 100,
meta: {
headerClassName: "",
},
},
{
accessorKey: "balance",
header: "Salary",
cell: (info) => (
<span className="font-semibold">
${(info.getValue() as number).toFixed(2)}
</span>
),
size: 100,
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data: demoData,
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting,
columnOrder,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnOrderChange: setColumnOrder,
})
return (
<DataGrid
table={table}
recordCount={demoData?.length || 0}
tableLayout={{
cellBorder: true,
}}
>
<div className="w-full space-y-2.5">
<Card className="p-0">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
</Card>
<DataGridPagination />
</div>
</DataGrid>
)
}
"use client"
import { useMemo, useState } from "react"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string // Emoji flags
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const users = [
{
id: "1",
name: "Alex Johnson",
email: "alex@example.com",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
initials: "AJ",
},
{
id: "2",
name: "Sarah Chen",
email: "sarah@example.com",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
initials: "SC",
},
{
id: "3",
name: "Michael Rodriguez",
email: "michael@example.com",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
initials: "MR",
},
{
id: "4",
name: "Emma Wilson",
email: "emma@example.com",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
initials: "EW",
},
{
id: "5",
name: "David Kim",
email: "david@example.com",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
initials: "DK",
},
{
id: "6",
name: "Aron Thompson",
email: "lisa@example.com",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
initials: "LT",
},
{
id: "7",
name: "James Brown",
email: "james@example.com",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
initials: "JB",
},
{
id: "8",
name: "Maria Garcia",
email: "maria@example.com",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
initials: "MG",
},
{
id: "9",
name: "Nick Johnson",
email: "nick@example.com",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
initials: "NJ",
},
{
id: "10",
name: "Liam Thompson",
email: "liam@example.com",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
initials: "LT",
},
]
const demoData: IData[] = users.map((user, index) => ({
...user,
availability: (["online", "away", "busy", "offline"] as const)[index % 4],
status: (index % 2 === 0 ? "active" : "inactive") as "active" | "inactive",
flag: (["us", "gb", "ca", "au", "de", "my", "es", "jp", "fr", "it"] as const)[
index % 10
],
company: (
[
"Apple",
"OpenAI",
"Meta",
"Tesla",
"SAP",
"Keenthemes",
"BBVA",
"Sony",
"LVMH",
"ENI",
] as const
)[index % 10],
role: (
[
"CEO",
"CTO",
"Designer",
"Developer",
"Lawyer",
"Director",
"Product Manager",
"Marketing Lead",
"Data Scientist",
"Engineer",
] as const
)[index % 10],
joined: "Jan, 2024",
location: (
[
"United States",
"United Kingdom",
"Canada",
"Australia",
"Germany",
"Malaysia",
"Spain",
"Japan",
"France",
"Italy",
] as const
)[index % 10],
balance: 5143.03 + index * 100,
}))
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: true },
])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: "Name",
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage
src={row.original.avatar}
alt={row.original.name}
/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<a
href="#"
className="text-foreground hover:text-primary font-medium"
>
{row.original.name}
</a>
</div>
)
},
size: 200,
enableSorting: true,
enableHiding: false,
},
{
accessorKey: "email",
header: "Email",
cell: (info) => (
<a
href={`mailto:${info.getValue()}`}
className="hover:text-primary hover:underline"
>
{info.getValue() as string}
</a>
),
size: 175,
meta: {
headerClassName: "",
},
},
{
accessorKey: "location",
header: "Location",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<div className="text-foreground font-medium">
{row.original.location}
</div>
</div>
)
},
size: 175,
meta: {
headerClassName: "",
cellClassName: "text-start",
},
},
{
accessorKey: "balance",
header: "Balance ($)",
cell: (info) => (
<span className="font-semibold">
${(info.getValue() as number).toFixed(2)}
</span>
),
size: 125,
meta: {
headerClassName: "text-right rtl:text-left",
cellClassName: "text-right rtl:text-left",
},
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data: demoData,
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
return (
<DataGrid
table={table}
recordCount={demoData?.length || 0}
tableLayout={{ dense: true }}
>
<div className="w-full space-y-2.5">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
)
}
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
import { cn } from "cn"
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string // Emoji flags
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const demoData: IData[] = [
{
id: "1",
name: "Alex Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "us",
email: "alex@apple.com",
company: "Apple",
role: "CEO",
joined: "Jan, 2024",
location: "United States",
balance: 5143.03,
},
{
id: "2",
name: "Sarah Chen",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "gb",
email: "sarah@openai.com",
company: "OpenAI",
role: "CTO",
joined: "Mar, 2023",
location: "United Kingdom",
balance: 4321.87,
},
{
id: "3",
name: "Michael Rodriguez",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "ca",
email: "michael@meta.com",
company: "Meta",
role: "Designer",
joined: "Jun, 2022",
location: "Canada",
balance: 7654.98,
},
{
id: "4",
name: "Emma Wilson",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "au",
email: "emma@tesla.com",
company: "Tesla",
role: "Developer",
joined: "Sep, 2024",
location: "Australia",
balance: 3456.45,
},
{
id: "5",
name: "David Kim",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "de",
email: "david@sap.com",
company: "SAP",
role: "Lawyer",
joined: "Nov, 2023",
location: "Germany",
balance: 9876.54,
},
{
id: "6",
name: "Aron Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "my",
email: "aron@keenthemes.com",
company: "Keenthemes",
role: "Director",
joined: "Feb, 2022",
location: "Malaysia",
balance: 6214.22,
},
{
id: "7",
name: "James Brown",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "es",
email: "james@bbva.es",
company: "BBVA",
role: "Product Manager",
joined: "Aug, 2024",
location: "Spain",
balance: 5321.77,
},
{
id: "8",
name: "Maria Garcia",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "jp",
email: "maria@sony.jp",
company: "Sony",
role: "Marketing Lead",
joined: "Dec, 2023",
location: "Japan",
balance: 8452.39,
},
{
id: "9",
name: "Nick Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "fr",
email: "nick@lvmh.fr",
company: "LVMH",
role: "Data Scientist",
joined: "Apr, 2022",
location: "France",
balance: 7345.1,
},
{
id: "10",
name: "Liam Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "it",
email: "liam@eni.it",
company: "ENI",
role: "Engineer",
joined: "Jul, 2024",
location: "Italy",
balance: 5214.88,
},
{
id: "11",
name: "Alex Johnson",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "br",
email: "alex@vale.br",
company: "Vale",
role: "Software Engineer",
joined: "May, 2023",
location: "Brazil",
balance: 9421.5,
},
{
id: "12",
name: "Sarah Chen",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "in",
email: "sarah@tata.in",
company: "Tata",
role: "Sales Manager",
joined: "Oct, 2024",
location: "India",
balance: 4521.67,
},
]
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: true },
])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: "Name",
cell: ({ row }) => {
const availability = row.original.availability
const statusColors = {
online: "bg-green-500",
away: "bg-yellow-500",
busy: "bg-orange-500",
offline: "bg-gray-400",
}
return (
<div className="flex items-center gap-3">
<Avatar className="size-8">
<AvatarImage
src={row.original.avatar}
alt={row.original.name}
/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
<AvatarBadge
className={cn(
"size-1.5! p-0",
statusColors[availability] || statusColors.offline
)}
/>
</Avatar>
<div className="space-y-px">
<div className="text-foreground font-medium">
{row.original.name}
</div>
<div className="text-muted-foreground">
{row.original.email}
</div>
</div>
</div>
)
},
size: 225,
enableSorting: true,
enableHiding: false,
},
{
accessorKey: "location",
header: "Location",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<div className="text-foreground font-medium">
{row.original.location}
</div>
</div>
)
},
size: 160,
meta: {
headerClassName: "",
cellClassName: "text-start",
},
},
{
accessorKey: "status",
id: "status",
header: "Status",
cell: ({ row }) => {
const status = row.original.status
if (status == "active") {
return <Badge variant="success-outline">Approved</Badge>
} else {
return <Badge variant="warning-outline">Pending</Badge>
}
},
size: 100,
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data: demoData,
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
return (
<DataGrid
table={table}
recordCount={demoData?.length || 0}
tableLayout={{
headerBackground: false,
rowBorder: false,
rowRounded: true,
}}
>
<div className="w-full space-y-2.5">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
)
}
"use client"
import { useMemo, useState } from "react"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string // Emoji flags
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const demoData: IData[] = [
{
id: "1",
name: "Alex Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "us",
email: "alex@apple.com",
company: "Apple",
role: "CEO",
joined: "Jan, 2024",
location: "United States",
balance: 5143.03,
},
{
id: "2",
name: "Sarah Chen",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "gb",
email: "sarah@openai.com",
company: "OpenAI",
role: "CTO",
joined: "Mar, 2023",
location: "United Kingdom",
balance: 4321.87,
},
{
id: "3",
name: "Michael Rodriguez",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "ca",
email: "michael@meta.com",
company: "Meta",
role: "Designer",
joined: "Jun, 2022",
location: "Canada",
balance: 7654.98,
},
{
id: "4",
name: "Emma Wilson",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "au",
email: "emma@tesla.com",
company: "Tesla",
role: "Developer",
joined: "Sep, 2024",
location: "Australia",
balance: 3456.45,
},
{
id: "5",
name: "David Kim",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "de",
email: "david@sap.com",
company: "SAP",
role: "Lawyer",
joined: "Nov, 2023",
location: "Germany",
balance: 9876.54,
},
{
id: "6",
name: "Aron Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "my",
email: "aron@keenthemes.com",
company: "Keenthemes",
role: "Director",
joined: "Feb, 2022",
location: "Malaysia",
balance: 6214.22,
},
{
id: "7",
name: "James Brown",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "es",
email: "james@bbva.es",
company: "BBVA",
role: "Product Manager",
joined: "Aug, 2024",
location: "Spain",
balance: 5321.77,
},
{
id: "8",
name: "Maria Garcia",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "jp",
email: "maria@sony.jp",
company: "Sony",
role: "Marketing Lead",
joined: "Dec, 2023",
location: "Japan",
balance: 8452.39,
},
{
id: "9",
name: "Nick Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "fr",
email: "nick@lvmh.fr",
company: "LVMH",
role: "Data Scientist",
joined: "Apr, 2022",
location: "France",
balance: 7345.1,
},
{
id: "10",
name: "Liam Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "it",
email: "liam@eni.it",
company: "ENI",
role: "Engineer",
joined: "Jul, 2024",
location: "Italy",
balance: 5214.88,
},
{
id: "11",
name: "Alex Johnson",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "br",
email: "alex@vale.br",
company: "Vale",
role: "Software Engineer",
joined: "May, 2023",
location: "Brazil",
balance: 9421.5,
},
{
id: "12",
name: "Sarah Chen",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "in",
email: "sarah@tata.in",
company: "Tata",
role: "Sales Manager",
joined: "Oct, 2024",
location: "India",
balance: 4521.67,
},
]
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: true },
])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: "Name",
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage
src={row.original.avatar}
alt={row.original.name}
/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<a
href="#"
className="text-foreground hover:text-primary font-medium"
>
{row.original.name}
</a>
</div>
)
},
size: 175,
enableSorting: true,
enableHiding: false,
},
{
accessorKey: "email",
header: "Email",
cell: (info) => (
<a
href={`mailto:${info.getValue()}`}
className="hover:text-primary hover:underline"
>
{info.getValue() as string}
</a>
),
size: 180,
},
{
accessorKey: "location",
header: "Location",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<div className="text-foreground font-medium">
{row.original.location}
</div>
</div>
)
},
size: 170,
},
{
accessorKey: "balance",
header: "Balance ($)",
cell: (info) => (
<span className="font-semibold">
${(info.getValue() as number).toFixed(2)}
</span>
),
size: 120,
meta: {
headerClassName: "text-right rtl:text-left",
cellClassName: "text-right rtl:text-left",
},
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data: demoData,
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
return (
<DataGrid
table={table}
recordCount={demoData?.length || 0}
tableLayout={{
stripped: true,
rowRounded: true,
}}
>
<div className="w-full space-y-2.5">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
)
}
"use client"
import { useMemo, useState } from "react"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
interface Data {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string // Emoji flags
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const demoData: Data[] = [
{
id: "1",
name: "Alex Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "us",
email: "alex@apple.com",
company: "Apple",
role: "CEO",
joined: "Jan, 2026",
location: "United States",
balance: 5143.03,
},
{
id: "2",
name: "Sarah Chen",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "gb",
email: "sarah@openai.com",
company: "OpenAI",
role: "CTO",
joined: "Jul, 2025",
location: "United Kingdom",
balance: 4321.87,
},
{
id: "3",
name: "Michael Rodriguez",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "ca",
email: "michael@meta.com",
company: "Meta",
role: "Designer",
joined: "Mar, 2019",
location: "Canada",
balance: 7654.98,
},
{
id: "4",
name: "Emma Wilson",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "au",
email: "emma@tesla.com",
company: "Tesla",
role: "Developer",
joined: "Jan, 2024",
location: "Australia",
balance: 3456.45,
},
{
id: "5",
name: "David Kim",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "de",
email: "david@sap.com",
company: "SAP",
role: "Lawyer",
joined: "May, 2023",
location: "Germany",
balance: 9876.54,
},
{
id: "6",
name: "Aron Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "my",
email: "aron@keenthemes.com",
company: "Keenthemes",
role: "Director",
joined: "Nov, 2018",
location: "Malaysia",
balance: 6214.22,
},
{
id: "7",
name: "James Brown",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "es",
email: "james@bbva.es",
company: "BBVA",
role: "Product Manager",
joined: "Jun, 2021",
location: "Spain",
balance: 5321.77,
},
{
id: "8",
name: "Maria Garcia",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "jp",
email: "maria@sony.jp",
company: "Sony",
role: "Marketing Lead",
joined: "Oct, 2020",
location: "Japan",
balance: 8452.39,
},
{
id: "9",
name: "Nick Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "fr",
email: "nick@lvmh.fr",
company: "LVMH",
role: "Data Scientist",
joined: "Sep, 2019",
location: "France",
balance: 7345.1,
},
{
id: "10",
name: "Liam Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "it",
email: "liam@eni.it",
company: "ENI",
role: "Engineer",
joined: "Feb, 2023",
location: "Italy",
balance: 5214.88,
},
{
id: "11",
name: "Alex Johnson",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "br",
email: "alex@vale.br",
company: "Vale",
role: "Software Engineer",
joined: "Dec, 2022",
location: "Brazil",
balance: 9421.5,
},
{
id: "12",
name: "Sarah Chen",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "in",
email: "sarah@tata.in",
company: "Tata",
role: "Sales Manager",
joined: "Mar, 2020",
location: "India",
balance: 4521.67,
},
]
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: true },
])
const columns = useMemo<ColumnDef<DataGridFeatures, Data>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: "Name",
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage
src={row.original.avatar}
alt={row.original.name}
/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<a
href="#"
className="text-foreground hover:text-primary font-medium"
>
{row.original.name}
</a>
</div>
)
},
size: 225,
enableSorting: true,
enableHiding: false,
},
{
accessorKey: "email",
header: "Email",
cell: (info) => (
<a
href={`mailto:${info.getValue()}`}
className="hover:text-primary hover:underline"
>
{info.getValue() as string}
</a>
),
size: 200,
},
{
accessorKey: "location",
header: "Location",
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<div className="text-foreground font-medium">
{row.original.location}
</div>
</div>
),
size: 175,
},
{
accessorKey: "joined",
header: "Joined",
cell: (info) => info.getValue() as string,
size: 120,
meta: {
cellClassName: "font-medium",
},
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data: demoData,
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
getRowId: (row: Data) => row.id,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
return (
<DataGrid
table={table}
recordCount={demoData?.length || 0}
tableLayout={{
width: "auto",
}}
>
<div className="w-full space-y-2.5">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
)
}
"use client"
import { useEffect, useMemo, useState } from "react"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
PaginationState,
RowSelectionState,
SortingState,
useTable,
} from "@tanstack/react-table"
import { cn } from "cn"
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const demoData: IData[] = [
{
id: "1",
name: "Alex Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "us",
email: "alex@apple.com",
company: "Apple",
role: "CEO",
joined: "Apr, 2021",
location: "United States",
balance: 5143.03,
},
{
id: "2",
name: "Sarah Chen",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "gb",
email: "sarah@openai.com",
company: "OpenAI",
role: "CTO",
joined: "Jul, 2020",
location: "United Kingdom",
balance: 4321.87,
},
{
id: "3",
name: "Michael Rodriguez",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "ca",
email: "michael@meta.com",
company: "Meta",
role: "Designer",
joined: "Mar, 2019",
location: "Canada",
balance: 7654.98,
},
{
id: "4",
name: "Emma Wilson",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "au",
email: "emma@tesla.com",
company: "Tesla",
role: "Developer",
joined: "Jan, 2022",
location: "Australia",
balance: 3456.45,
},
{
id: "5",
name: "David Kim",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "de",
email: "david@sap.com",
company: "SAP",
role: "Lawyer",
joined: "May, 2023",
location: "Germany",
balance: 9876.54,
},
{
id: "6",
name: "Aron Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "my",
email: "aron@keenthemes.com",
company: "Keenthemes",
role: "Director",
joined: "Nov, 2018",
location: "Malaysia",
balance: 6214.22,
},
{
id: "7",
name: "James Brown",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "es",
email: "james@bbva.es",
company: "BBVA",
role: "Product Manager",
joined: "Jun, 2021",
location: "Spain",
balance: 5321.77,
},
{
id: "8",
name: "Maria Garcia",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "jp",
email: "maria@sony.jp",
company: "Sony",
role: "Marketing Lead",
joined: "Oct, 2020",
location: "Japan",
balance: 8452.39,
},
{
id: "9",
name: "Nick Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "fr",
email: "nick@lvmh.fr",
company: "LVMH",
role: "Data Scientist",
joined: "Sep, 2019",
location: "France",
balance: 7345.1,
},
{
id: "10",
name: "Liam Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "it",
email: "liam@eni.it",
company: "ENI",
role: "Engineer",
joined: "Feb, 2023",
location: "Italy",
balance: 5214.88,
},
{
id: "11",
name: "Alex Johnson",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "br",
email: "alex@vale.br",
company: "Vale",
role: "Software Engineer",
joined: "Dec, 2022",
location: "Brazil",
balance: 9421.5,
},
{
id: "12",
name: "Sarah Chen",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "in",
email: "sarah@tata.in",
company: "Tata",
role: "Sales Manager",
joined: "Mar, 2020",
location: "India",
balance: 4521.67,
},
]
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: true },
])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [selectedIds, setSelectedIds] = useState<string[]>([])
useEffect(() => {
const selectedRowIds = Object.keys(rowSelection)
if (selectedRowIds.length > 0) {
setSelectedIds(selectedRowIds)
} else {
setSelectedIds([])
}
}, [rowSelection])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "id",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
enableSorting: false,
size: 20,
meta: {
headerClassName: "",
cellClassName: "",
},
},
{
accessorKey: "name",
id: "name",
header: "Name",
cell: ({ row }) => {
const availability = row.original.availability
const statusColors = {
online: "bg-green-500",
away: "bg-yellow-500",
busy: "bg-orange-500",
offline: "bg-gray-400",
}
return (
<div className="flex items-center gap-3">
<Avatar className="size-8">
<AvatarImage
src={row.original.avatar}
alt={row.original.name}
/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
<AvatarBadge
className={cn(
"size-1.5! p-0",
statusColors[availability] || statusColors.offline
)}
/>
</Avatar>
<div className="space-y-px">
<div className="text-foreground font-medium">
{row.original.name}
</div>
<div className="text-muted-foreground">
{row.original.email}
</div>
</div>
</div>
)
},
size: 200,
enableSorting: true,
enableHiding: false,
},
{
accessorKey: "location",
header: "Location",
cell: ({ row }) => {
return (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<div className="text-foreground font-medium">
{row.original.location}
</div>
</div>
)
},
size: 180,
meta: {
headerClassName: "",
cellClassName: "text-start",
},
},
{
accessorKey: "joined",
header: "Joined",
cell: (info) => info.getValue() as string,
size: 120,
meta: {
headerClassName: "",
cellClassName: "font-medium",
},
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data: demoData,
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
return (
<DataGrid table={table} recordCount={demoData?.length || 0}>
<div className="w-full space-y-2.5">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
)
}
"use client"
import { useEffect, useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import {
DataGridTable,
DataGridTableRowExpand,
} from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
ExpandedState,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Card } from "@/components/ui/card"
interface IData {
id: string
name: string
type: "department" | "team" | "member"
status: "active" | "inactive"
role?: string
avatar?: string
flag?: string
location?: string
children?: IData[]
}
const demoData: IData[] = [
{
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://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
flag: "us",
location: "United States",
},
{
id: "eng-platform-2",
name: "Sarah Chen",
type: "member",
status: "active",
role: "Senior Engineer",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
flag: "gb",
location: "United Kingdom",
},
{
id: "eng-platform-3",
name: "Michael Rodriguez",
type: "member",
status: "inactive",
role: "Frontend Engineer",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
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://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
flag: "au",
location: "Australia",
},
{
id: "eng-mobile-2",
name: "David Kim",
type: "member",
status: "active",
role: "Android Engineer",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
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://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
flag: "my",
location: "Malaysia",
},
{
id: "design-product-2",
name: "Maria Garcia",
type: "member",
status: "active",
role: "Product Designer",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
flag: "jp",
location: "Japan",
},
{
id: "design-product-3",
name: "James Brown",
type: "member",
status: "inactive",
role: "UX Researcher",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
flag: "es",
location: "Spain",
},
],
},
{
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://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
flag: "fr",
location: "France",
},
{
id: "design-brand-2",
name: "Liam Thompson",
type: "member",
status: "inactive",
role: "Motion Designer",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
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://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
flag: "us",
location: "United States",
},
{
id: "marketing-growth-2",
name: "Ethan Clark",
type: "member",
status: "active",
role: "Performance Marketer",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
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://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
flag: "it",
location: "Italy",
},
{
id: "marketing-content-2",
name: "Lucas Meyer",
type: "member",
status: "inactive",
role: "Copywriter",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
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://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
flag: "kr",
location: "South Korea",
},
{
id: "operations-finance-2",
name: "Daniel Novak",
type: "member",
status: "active",
role: "Accountant",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
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://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
flag: "fr",
location: "France",
},
{
id: "operations-people-2",
name: "Ryan Walsh",
type: "member",
status: "active",
role: "Recruiter",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
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://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
flag: "kr",
location: "South Korea",
},
{
id: "sales-accounts-2",
name: "Noah Fischer",
type: "member",
status: "inactive",
role: "Account Manager",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
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(rows: IData[]): string[] {
return rows.flatMap((row) => [
...(row.avatar ? [row.avatar] : []),
...(row.flag ? [`https://flagcdn.com/${row.flag.toLowerCase()}.svg`] : []),
...(row.children ? collectImageUrls(row.children) : []),
])
}
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 4,
})
const [sorting, setSorting] = useState<SortingState>([])
const [expanded, setExpanded] = useState<ExpandedState>({
eng: true,
"eng-platform": true,
})
useEffect(() => {
for (const src of collectImageUrls(demoData)) {
const image = new Image()
image.src = src
}
}, [])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader title="Name" column={column} />
),
cell: ({ row }) => {
const item = row.original
return (
<div className="flex items-center gap-1">
<DataGridTableRowExpand row={row} className="-ms-1.5 -me-1" />
{item.type === "member" ? (
<>
<Avatar className="size-6 shrink-0">
<AvatarImage src={item.avatar} alt={item.name} />
<AvatarFallback>
{item.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<a
href="#"
className="text-foreground hover:text-primary font-medium"
>
{item.name}
</a>
</>
) : (
<span className="text-foreground font-medium">{item.name}</span>
)}
</div>
)
},
minSize: 260,
enableSorting: true,
enableHiding: false,
meta: {
autoSize: true,
},
},
{
accessorKey: "role",
header: ({ column }) => (
<DataGridColumnHeader title="Role" column={column} />
),
cell: ({ row }) => {
const item = row.original
return (
<div className="text-muted-foreground">
{item.role ??
(item.type === "department" ? "Department" : "Team")}
</div>
)
},
size: 180,
},
{
accessorKey: "location",
header: ({ column }) => (
<DataGridColumnHeader title="Location" column={column} />
),
cell: ({ row }) => {
const item = row.original
if (!item.location || !item.flag) {
return <span className="text-muted-foreground">-</span>
}
return (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${item.flag.toLowerCase()}.svg`}
alt={item.flag}
className="size-4 rounded-full object-cover"
/>
<div className="text-foreground font-medium">{item.location}</div>
</div>
)
},
size: 180,
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader title="Status" column={column} />
),
cell: ({ row }) => {
if (row.original.status === "active") {
return <Badge variant="success-outline">Active</Badge>
}
return <Badge variant="warning-outline">Inactive</Badge>
},
size: 130,
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data: demoData,
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
getSubRows: (row) => row.children,
state: {
pagination,
sorting,
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 || 0}
tableLayout={{
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
}}
>
<div className="w-full space-y-2.5">
<Card className="overflow-hidden p-0">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
</Card>
<DataGridPagination sizes={[4, 8, 16]} />
</div>
</DataGrid>
)
}
"use client"
import {
Fragment,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridCellEditRequest,
type DataGridCellSelectionSnapshot,
type DataGridCellsChangeDetails,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridCellSelection,
buildDataGridClearDetails,
getDataGridActiveRegionGrid,
serializeDataGridClipboardText,
type DataGridCellSelectionApi,
} from "@/components/reui/data-grid/data-grid-cell-selection"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
ColumnPinningState,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
import { format } from "date-fns"
import {
Toast,
ToastClose,
ToastContent,
ToastDescription,
ToastPortal,
ToastProvider,
ToastTitle,
ToastViewport,
toast,
useToastManager,
} from "@/components/ui/toast"
import { Badge } from "@/components/reui/badge"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxValue,
useComboboxAnchor,
} from "@/components/ui/combobox"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Card,
CardAction,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { CircleAlertIcon, InfoIcon, XIcon } from 'lucide-react'
interface IProduct {
id: string
sku: string
name: string
team: string[]
restock: string
stock: number
price: number
status: "active" | "archived"
}
interface IMember {
id: string
name: string
email: string
avatar: string
initials: string
}
const MEMBERS: IMember[] = [
{ id: "1", name: "Alex Johnson", email: "alex@example.com", avatar: "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80", initials: "AJ" },
{ id: "2", name: "Sarah Chen", email: "sarah@example.com", avatar: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80", initials: "SC" },
{ id: "3", name: "Michael Rodriguez", email: "michael@example.com", avatar: "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80", initials: "MR" },
{ id: "4", name: "Emma Wilson", email: "emma@example.com", avatar: "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80", initials: "EW" },
{ id: "5", name: "David Kim", email: "david@example.com", avatar: "https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80", initials: "DK" },
{ id: "6", name: "Aron Thompson", email: "lisa@example.com", avatar: "https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80", initials: "LT" },
{ id: "7", name: "James Brown", email: "james@example.com", avatar: "https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80", initials: "JB" },
{ id: "8", name: "Maria Garcia", email: "maria@example.com", avatar: "https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80", initials: "MG" },
{ id: "9", name: "Nick Johnson", email: "nick@example.com", avatar: "https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80", initials: "NJ" },
{ id: "10", name: "Liam Thompson", email: "liam@example.com", avatar: "https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80", initials: "LT" },
]
const memberById = new Map(MEMBERS.map((member) => [member.id, member]))
const STATUS_OPTIONS = [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
] as const
const demoData: IProduct[] = [
{ id: "1", sku: "AU-1042", name: "Studio Headphones", team: ["1", "2"], restock: "2026-09-04", stock: 42, price: 189.0, status: "active" },
{ id: "2", sku: "AU-1077", name: "Desktop Speakers", team: ["3"], restock: "2026-09-12", stock: 18, price: 129.5, status: "active" },
{ id: "3", sku: "DS-2011", name: "27in 4K Monitor", team: ["2", "4", "5"], restock: "2026-10-01", stock: 7, price: 449.99, status: "active" },
{ id: "4", sku: "DS-2048", name: "Portable Display", team: [], restock: "", stock: 25, price: 219.0, status: "archived" },
{ id: "5", sku: "PE-3003", name: "Mechanical Keyboard", team: ["6"], restock: "2026-09-18", stock: 64, price: 139.0, status: "active" },
{ id: "6", sku: "PE-3017", name: "Wireless Mouse", team: ["7", "8"], restock: "2026-09-25", stock: 120, price: 59.0, status: "active" },
{ id: "7", sku: "PE-3050", name: "USB Microphone", team: [], restock: "", stock: 33, price: 99.0, status: "archived" },
]
function StatusBadge({ status }: { status: IProduct["status"] }) {
return (
<Badge variant={status === "active" ? "success-light" : "primary-light"}>
{status === "active" ? "Active" : "Archived"}
</Badge>
)
}
/**
* Multi-member assignment on the shadcn combobox, the Notion person-property
* idiom: an inline chips input renders the value as avatar chips that fit
* and grow the cell, and typing in it filters the member popup.
*/
function TeamCell({
team,
onChange,
}: {
team: string[]
onChange: (next: string[]) => void
}) {
const anchor = useComboboxAnchor()
const value = team
.map((id) => memberById.get(id))
.filter((member): member is IMember => !!member)
return (
<Combobox
multiple
items={MEMBERS}
itemToStringValue={(member: IMember) => member.name}
value={value}
onValueChange={(next: IMember[]) =>
onChange(next.map((member) => member.id))
}
>
{/* Free-fit in the cell: every piece of the input chrome (border,
background, ring, padding, radius) is stripped, so only the chips
themselves render and the cell stays the container. */}
{/* The whole strip opens the combobox, so the grid cannot infer it
is a control: data-cell-control makes a press anywhere on it
two-step (first click focuses the cell, the second activates). */}
<ComboboxChips
ref={anchor}
data-cell-control=""
className="min-h-6! w-full rounded-none! border-0! bg-transparent! p-0! shadow-none! ring-0! outline-hidden!"
>
<ComboboxValue>
{(selected: IMember[]) => (
<Fragment>
{/* The chip primitive keeps the combobox behavior but hands its
whole look to a real outline Badge, so the chips match every
other avatar badge in the registry. Its built-in remove is
swapped for the Badge-sized ghost button. */}
{selected.map((member) => (
<ComboboxChip
key={member.id}
showRemove={false}
className="rounded-none! border-0! bg-transparent! p-0!"
>
<Badge variant="outline">
<Avatar className="size-3.5">
<AvatarImage src={member.avatar} alt={member.name} />
<AvatarFallback className="text-[8px]">
{member.initials}
</AvatarFallback>
</Avatar>
{member.name.split(" ")[0]}
<Button
variant="ghost"
size="icon"
className="size-3 hover:bg-transparent"
aria-label={`Remove ${member.name}`}
/* Removal through the controlled value, and the press
must not bubble: the chip strip opens the popup. */
onClick={(event) => {
event.stopPropagation()
onChange(team.filter((id) => id !== member.id))
}}
>
<XIcon />
</Button>
</Badge>
</ComboboxChip>
))}
{/* Zero-footprint until focused: a permanently sized input
would hold a wrap line of its own and keep the row tall
after chips are removed. Pure CSS, so row height stays in
sync with every state change at no runtime cost. */}
<ComboboxChipsInput
placeholder={selected.length ? "" : "No assignment"}
className={
selected.length
? "h-6 w-0 min-w-0 flex-none border-0! bg-transparent! p-0! shadow-none! outline-hidden! focus:min-w-16 focus:flex-1"
: "h-6 min-w-16 border-0! bg-transparent! p-0! shadow-none! outline-hidden!"
}
/>
</Fragment>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxContent anchor={anchor} align="start" sideOffset={8} className="w-56">
{/* The full default combobox: a search field filters the list. */}
<ComboboxInput
placeholder="Search members"
showTrigger={false}
className="m-1"
/>
<ComboboxEmpty>No members found.</ComboboxEmpty>
<ComboboxList>
{(member: IMember) => (
<ComboboxItem key={member.id} value={member}>
<Avatar className="size-5">
<AvatarImage src={member.avatar} alt={member.name} />
<AvatarFallback className="text-[9px]">
{member.initials}
</AvatarFallback>
</Avatar>
{member.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}
/**
* A date property on the shadcn date picker: the cell is a free-fit trigger
* showing the formatted value, and editing opens a calendar in a popover.
* The open state lives in the cell itself, so picking a date closes the
* popover without ever rebuilding the columns.
*/
function DateCell({
value,
onChange,
}: {
value: string
onChange: (next: string) => void
}) {
const [open, setOpen] = useState(false)
// Measured at open: the popup must sit exactly on the CELL's bottom-start
// corner, and the trigger sits inside the cell's padding, so the offsets
// carry the trigger-to-cell delta.
const [offsets, setOffsets] = useState({ side: 9, align: -12 })
const triggerRef = useRef<HTMLButtonElement | null>(null)
const date = value ? new Date(`${value}T00:00:00`) : undefined
return (
<Popover
open={open}
onOpenChange={(next) => {
if (next) {
const trigger = triggerRef.current
const cell = trigger?.closest("td")
if (trigger && cell) {
const triggerRect = trigger.getBoundingClientRect()
const cellRect = cell.getBoundingClientRect()
setOffsets({
side: Math.round(cellRect.bottom - triggerRect.bottom),
align: Math.round(cellRect.left - triggerRect.left),
})
}
}
setOpen(next)
}}
>
<PopoverTrigger
ref={triggerRef}
className="flex min-h-6 w-full cursor-pointer items-center text-start"
>
{date ? (
format(date, "MMM d, yyyy")
) : (
<span className="text-muted-foreground text-xs">Pick date</span>
)}
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={offsets.side}
alignOffset={offsets.align}
className="w-auto p-0"
>
<Calendar
mode="single"
selected={date}
defaultMonth={date}
onSelect={(next) => {
onChange(next ? format(next, "yyyy-MM-dd") : "")
setOpen(false)
}}
/>
</PopoverContent>
</Popover>
)
}
/**
* The stock Toaster pins its viewport to the bottom-right corner; this demo
* wants every message top-center, so it composes the exported pieces and
* flips the vertical math: viewport anchored top, toasts stacked downward
* (older ones peeking BELOW), enter and exit sliding from above.
*/
function DemoToaster() {
return (
<ToastProvider toastManager={toast}>
<ToastPortal>
<ToastViewport className="top-4 bottom-auto sm:left-4 sm:mx-auto sm:w-auto">
<DemoToastList />
</ToastViewport>
</ToastPortal>
</ToastProvider>
)
}
function DemoToastList() {
const { toasts } = useToastManager()
return toasts.map((item) => (
<Toast
key={item.id}
toast={item}
className="top-0 bottom-auto origin-top [--offset-y:calc(var(--toast-offset-y)+calc(var(--toast-index)*var(--gap))+var(--toast-swipe-movement-y))] [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--peek))+(var(--shrink)*var(--height))))_scale(var(--scale))] data-starting-style:[transform:translateY(-150%)] [&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(-150%)]"
>
<ToastContent>
{item.type === "info" || item.type === "error" ? (
<span className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4">
{item.type === "info" ? (
<InfoIcon aria-hidden="true" />
) : (
<CircleAlertIcon aria-hidden="true" />
)}
</span>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<ToastTitle />
<ToastDescription />
</div>
<ToastClose />
</ToastContent>
</Toast>
))
}
/**
* Selection feedback at the consumer layer: one updating toast (stable id)
* summarizes the captured region, so a drag reads as live feedback instead
* of a stack of stale messages.
*/
function SelectionToast({
count,
bound,
}: {
count: number
bound: {
minRowIndex: number
maxRowIndex: number
minColumnIndex: number
maxColumnIndex: number
} | null
}) {
// The manager has no sonner-style stable id: update the live toast while
// it can still be open, and add a fresh one once it must have timed out.
const toastIdRef = useRef<string | null>(null)
const shownAtRef = useRef(0)
useEffect(() => {
if (count < 2 || !bound) return
const rows = bound.maxRowIndex - bound.minRowIndex + 1
const columns = bound.maxColumnIndex - bound.minColumnIndex + 1
const description = `Selected ${rows} x ${columns} (${count} cells)`
const now = Date.now()
if (toastIdRef.current && now - shownAtRef.current < 1400) {
toast.update(toastIdRef.current, { description })
} else {
toastIdRef.current = toast.add({ description, timeout: 1500 })
}
shownAtRef.current = now
}, [count, bound])
return null
}
export function Pattern() {
// Scopes DOM lookups to THIS grid: data-cell-focused persists on a blurred
// grid, so a document-wide query could hit another instance on the page.
const cardRef = useRef<HTMLDivElement | null>(null)
// The controller's imperative API; focusCell lands cell focus, DOM focus
// and aria-activedescendant together on rows that are still mounting.
const gridApiRef = useRef<DataGridCellSelectionApi | null>(null)
const nextIdRef = useRef(demoData.length + 1)
const [data, setData] = useState<IProduct[]>(demoData)
// Optional CRUD indications: which rows are new or edited, and which
// cells were touched. Purely presentational bookkeeping the grid renders
// through getRowStatus/getCellStatus; drop it for a grid without them.
const [rowMeta, setRowMeta] = useState<Record<string, "new" | "dirty">>({})
const [dirtyCells, setDirtyCells] = useState<Set<string>>(new Set())
// The resolved selection, straight from the grid: count, bounds and the
// focused cell, with no reach into TanStack internals.
const [cellSelection, setCellSelection] =
useState<DataGridCellSelectionSnapshot | null>(null)
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([])
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
start: [],
end: [],
})
// The single write path: built-in editors, paste, fill, cut, Delete and
// the status select all land here, so state stays the only owner of the
// rows.
const updateRows = (
changes: Array<{ rowId: string; columnId: string; value: unknown }>
) => {
if (!changes.length) return
const byRow = new Map<string, Array<{ columnId: string; value: unknown }>>()
for (const change of changes) {
const rowChanges = byRow.get(change.rowId) ?? []
rowChanges.push(change)
byRow.set(change.rowId, rowChanges)
}
setData((previous) =>
previous.map((row) => {
const rowChanges = byRow.get(row.id)
if (!rowChanges) return row
const next = { ...row } as Record<string, unknown>
for (const change of rowChanges) next[change.columnId] = change.value
return next as unknown as IProduct
})
)
setRowMeta((previous) => {
const next = { ...previous }
for (const change of changes) {
if (next[change.rowId] !== "new") next[change.rowId] = "dirty"
}
return next
})
setDirtyCells((previous) => {
const next = new Set(previous)
for (const change of changes) {
next.add(`${change.rowId}:${change.columnId}`)
}
return next
})
}
const handleCellsChange = (details: DataGridCellsChangeDetails<IProduct>) => {
updateRows(details.changes)
// A paste, fill or edit the parsers refused must not die silently.
if (details.rejected.length) {
replaceToast(rejectToastRef, {
type: "error",
description: `${details.rejected.length} ${
details.rejected.length === 1 ? "value" : "values"
} could not be applied`,
})
}
}
// Consumer-level editing: text columns use the grid's built-in editor via
// `cellEdit.control`, so only the status column's select arrives here.
// One gesture for every custom-control column: Enter focuses the focused
// cell's own control and clicks it - the select and date triggers open,
// and the team combobox input opens its list - the exact toggle a mouse
// click performs, so keyboard and mouse stay interchangeable.
const handleCellEditRequest = (request: DataGridCellEditRequest<IProduct>) => {
// Deliberate activation only (Enter, F2, double-click). Type-to-edit
// also lands here, and a stray keystroke must not click a control it
// cannot type into.
if (request.initialText !== undefined) return
// Ordered lookup: the cell's PRIMARY control, never an incidental
// button inside it (a combobox chip's remove button precedes the
// input in DOM order and must not win).
const cellSelector = "td[data-cell-focused]"
const control = [
'[data-slot="select-trigger"]',
'[data-slot="popover-trigger"]',
"input",
"button",
]
.map((candidate) =>
cardRef.current?.querySelector<HTMLElement>(
`${cellSelector} ${candidate}`
)
)
.find(Boolean)
if (!control) return
control.focus()
control.click()
// The combobox input opens its list from the keyboard, not from a
// synthetic click; ArrowDown is its native open gesture.
if (control instanceof HTMLInputElement) {
control.dispatchEvent(
new KeyboardEvent("keydown", {
key: "ArrowDown",
bubbles: true,
cancelable: true,
})
)
}
}
// Quick create: one plain empty row appended to the data, landing where
// pagination puts it. The grid jumps to that page, and focusCell retries
// until the row mounts there, so the user can type immediately.
const handleRowCreate = () => {
const id = `p${nextIdRef.current}`
nextIdRef.current += 1
setData((previous) => [
...previous,
{
id,
sku: `PR-${id.slice(1).padStart(4, "0")}`,
name: "",
team: [],
restock: "",
stock: 0,
price: 0,
status: "active",
},
])
setRowMeta((previous) => ({ ...previous, [id]: "new" }))
setPagination((previous) => ({
...previous,
pageIndex: Math.ceil((data.length + 1) / previous.pageSize) - 1,
}))
gridApiRef.current?.focusCell(id, "name")
}
// Cell and row actions behind one context menu, scoped by where the
// press landed. The rows render inside the primitive, so one trigger
// wraps the whole grid and a capture handler records the cell.
const [contextCell, setContextCell] = useState<{
rowId: string
columnId: string | null
} | null>(null)
const contextTriggerRef = useRef<HTMLDivElement | null>(null)
// One live toast per channel, the way sonner's ids deduped: the newest
// replaces the previous before it stacks.
const copyToastRef = useRef<string | null>(null)
const rejectToastRef = useRef<string | null>(null)
const replaceToast = (
ref: RefObject<string | null>,
options: { type: "info" | "error"; description: string }
) => {
if (ref.current) toast.close(ref.current)
ref.current = toast.add(options)
}
const contextRowId = contextCell?.rowId ?? null
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const confirmDeleteRow = data.find((row) => row.id === confirmDeleteId)
const duplicateRow = (rowId: string) => {
const source = data.find((row) => row.id === rowId)
if (!source) return
const id = `p${nextIdRef.current}`
nextIdRef.current += 1
const index = data.findIndex((row) => row.id === rowId)
setData((previous) => [
...previous.slice(0, index + 1),
{ ...source, id, name: `${source.name} copy` },
...previous.slice(index + 1),
])
setRowMeta((previous) => ({ ...previous, [id]: "new" }))
gridApiRef.current?.focusCell(id, "name")
}
const gridViewport = () =>
cardRef.current?.querySelector<HTMLElement>(
'[data-slot="data-grid-table-viewport"]'
) ?? null
// Copies whatever the grid would copy: the active region, which the
// right-click just focused unless it landed inside a selection.
const copySelection = () => {
const grid = getDataGridActiveRegionGrid(table, gridViewport())
if (!grid) return
void navigator.clipboard?.writeText?.(
serializeDataGridClipboardText(grid)
)
const count = grid.reduce((total, line) => total + line.length, 0)
replaceToast(copyToastRef, {
type: "info",
description: `Copied ${count} ${count === 1 ? "cell" : "cells"} to clipboard`,
})
}
const clearSelection = () => {
const details = buildDataGridClearDetails(table, "clear", false, gridViewport())
if (details) handleCellsChange(details)
}
const copyRow = (rowId: string) => {
const row = data.find((candidate) => candidate.id === rowId)
if (!row) return
const line = [
row.name,
row.team
.map((memberId) => memberById.get(memberId)?.name ?? "")
.filter(Boolean)
.join(", "),
row.restock,
String(row.stock),
row.price.toFixed(2),
row.status,
].join("\t")
void navigator.clipboard?.writeText?.(line)
replaceToast(copyToastRef, {
type: "info",
description: "Row copied to clipboard",
})
}
const deleteRow = (rowId: string) => {
const remaining = data.filter((row) => row.id !== rowId)
setData(remaining)
// The removed row may have been the last page's only row; clamp back.
setPagination((previous) => ({
...previous,
pageIndex: Math.min(
previous.pageIndex,
Math.max(0, Math.ceil(remaining.length / previous.pageSize) - 1)
),
}))
}
const columns = useMemo<ColumnDef<DataGridFeatures, IProduct>[]>(
() => [
{
accessorKey: "sku",
header: "SKU",
cell: (info) => info.getValue() as string,
enableSorting: false,
size: 90,
// No cellEdit: copyable but read-only, so a paste over it lands in
// the batch's rejected list instead of changing data.
meta: { cellClassName: "text-muted-foreground font-mono text-xs" },
},
{
accessorKey: "name",
header: ({ column }) => (
<DataGridColumnHeader title="Product" column={column} />
),
cell: (info) => info.getValue() as string,
size: 180,
meta: {
headerTitle: "Product",
cellClassName: "font-medium",
// The grid's built-in flush editor: Enter, F2, typing, or
// double-click opens it right over the cell. The textarea control
// grows downward as long text wraps, the Sheets look.
cellEdit: { control: "textarea" },
},
},
{
accessorKey: "team",
header: ({ column }) => (
<DataGridColumnHeader title="Team" column={column} />
),
enableSorting: false,
cell: ({ row }) => (
<TeamCell
team={row.original.team}
onChange={(next) =>
updateRows([{ rowId: row.id, columnId: "team", value: next }])
}
/>
),
size: 230,
minSize: 200,
meta: {
headerTitle: "Team",
autoSize: true,
cellEdit: {
// Clipboard carries names; parse maps them (or emails) back.
parse: (raw) =>
raw
.split(",")
.map((value) => value.trim().toLowerCase())
.map(
(needle) =>
MEMBERS.find(
(member) =>
member.name.toLowerCase() === needle ||
member.email.toLowerCase() === needle
)?.id
)
.filter((id): id is string => !!id),
format: (value) =>
Array.isArray(value)
? value
.map((id) => memberById.get(String(id))?.name ?? "")
.filter(Boolean)
.join(", ")
: String(value ?? ""),
clearValue: [],
},
},
},
{
accessorKey: "restock",
header: ({ column }) => (
<DataGridColumnHeader title="Restock" column={column} />
),
cell: ({ row }) => (
<DateCell
value={row.original.restock}
onChange={(next) =>
updateRows([{ rowId: row.id, columnId: "restock", value: next }])
}
/>
),
size: 140,
meta: {
headerTitle: "Restock",
cellEdit: {
// Clipboard carries the readable date; parse accepts anything
// Date can read and stores the ISO day.
parse: (raw) => {
const trimmed = raw.trim()
if (!trimmed) return ""
const parsed = new Date(trimmed)
return Number.isNaN(parsed.getTime())
? undefined
: format(parsed, "yyyy-MM-dd")
},
format: (value) =>
value
? format(new Date(`${String(value)}T00:00:00`), "MMM d, yyyy")
: "",
clearValue: "",
},
},
},
{
accessorKey: "stock",
header: ({ column }) => (
<DataGridColumnHeader title="Stock" column={column} />
),
cell: (info) => String(info.getValue() as number),
size: 70,
meta: {
headerTitle: "Stock",
cellClassName: "text-end tabular-nums",
headerClassName: "justify-end",
cellEdit: {
control: "text",
parse: (raw) => {
const parsed = Number.parseInt(raw.replace(/[^0-9-]/g, ""), 10)
return Number.isNaN(parsed) ? undefined : Math.max(0, parsed)
},
clearValue: 0,
},
},
},
{
accessorKey: "price",
header: ({ column }) => (
<DataGridColumnHeader title="Price" column={column} />
),
cell: ({ row }) => row.original.price.toFixed(2),
size: 80,
meta: {
headerTitle: "Price",
cellClassName: "text-end tabular-nums",
headerClassName: "justify-end",
cellEdit: {
control: "text",
parse: (raw) => {
const parsed = Number.parseFloat(raw.replace(/[^0-9.-]/g, ""))
return Number.isNaN(parsed) ? undefined : parsed
},
format: (value) =>
typeof value === "number" ? value.toFixed(2) : String(value ?? ""),
clearValue: 0,
},
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataGridColumnHeader title="Status" column={column} />
),
// A consumer-level editor: an always-on select with badge-rendered
// value and items, opened by click or by Enter through
// onCellEditRequest. The trigger is a button, so clicking it never
// starts a cell range.
cell: ({ row }) => (
<Select
value={row.original.status}
onValueChange={(status) =>
updateRows([{ rowId: row.id, columnId: "status", value: status }])
}
>
{/* Reads as a plain value at rest: the select's arrow appears
only while the cell is the focused one, the Notion idiom. */}
<SelectTrigger
size="sm"
className="h-6 w-full border-0 bg-transparent px-1 shadow-none [&_svg]:opacity-0 [&_svg]:transition-opacity in-data-[cell-focused]:[&_svg]:opacity-100"
aria-label="Status"
>
<SelectValue>
<StatusBadge status={row.original.status} />
</SelectValue>
</SelectTrigger>
<SelectContent
alignItemWithTrigger={false}
align="start"
sideOffset={8}
>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<StatusBadge status={option.value} />
</SelectItem>
))}
</SelectContent>
</Select>
),
size: 130,
meta: {
headerTitle: "Status",
cellEdit: {
// Clipboard carries the label; parse maps it back to the value.
parse: (raw) =>
STATUS_OPTIONS.find(
(option) =>
option.label.toLowerCase() === raw.trim().toLowerCase() ||
option.value === raw.trim().toLowerCase()
)?.value,
format: (value) =>
STATUS_OPTIONS.find((option) => option.value === value)?.label ??
String(value ?? ""),
},
},
},
],
// Stable: every cell reads state through the grid's own change pipeline.
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data,
pageCount: Math.ceil(data.length / pagination.pageSize),
getRowId: (row: IProduct) => row.id,
state: {
pagination,
sorting,
columnPinning,
},
// Every commit replaces `data`; without this, editing on page 2 would
// snap the grid back to page 1 on each write.
autoResetPageIndex: false,
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnPinningChange: setColumnPinning,
})
return (
<DataGrid
table={table}
recordCount={data.length}
onCellsChange={handleCellsChange}
onCellEditRequest={handleCellEditRequest}
onCellSelectionChange={setCellSelection}
onCellsCopy={({ grid, cut }) => {
const count = grid.reduce((total, line) => total + line.length, 0)
replaceToast(copyToastRef, {
type: "info",
description: `${cut ? "Cut" : "Copied"} ${count} ${count === 1 ? "cell" : "cells"} to clipboard`,
})
}}
getRowStatus={(row) => rowMeta[row.id]}
getCellStatus={(row, columnId) =>
dirtyCells.has(`${row.id}:${columnId}`) ? "dirty" : undefined
}
tableLayout={{
dense: true,
cellSelection: true,
cellFillHandle: true,
// The Excel-style solid square inside the selection corner.
cellFillHandleVariant: "square",
cellBorder: true,
columnsResizable: true,
columnsPinnable: true,
}}
tableClassNames={{
// Breathing room against the container edges now that a data
// column leads the row.
edgeCell:
"first:ps-4 last:pe-4 [&:has(+[data-slot=data-grid-table-fill-body-cell]:last-child)]:pe-4 [&:has(+[data-slot=data-grid-table-fill-head-cell]:last-child)]:pe-4",
// The row the open context menu references, checkbox state untouched.
bodyRow:
"data-[context-open]:bg-muted/50 data-[context-open]:[&>td[data-pinned]]:bg-[color-mix(in_oklab,var(--muted)_50%,var(--background))]",
}}
>
<Card ref={cardRef} className="w-full gap-3 py-3.5">
<CardHeader className="items-center px-3.5">
<CardTitle>Products</CardTitle>
<CardAction>
<Button size="sm" variant="outline" onClick={handleRowCreate}>
Add row
</Button>
</CardAction>
</CardHeader>
<ContextMenu
onOpenChange={(open) => {
if (open) return
cardRef.current
?.querySelectorAll("[data-context-open]")
.forEach((rowEl) => rowEl.removeAttribute("data-context-open"))
setContextCell(null)
}}
>
<ContextMenuTrigger
ref={contextTriggerRef}
// Suppressing the capture stops the trigger from opening over
// headers, the add-row affordance and the empty space.
onContextMenuCapture={(event) => {
const native = event.nativeEvent as MouseEvent & {
__retargeted?: boolean
}
if (native.__retargeted) return
const target = event.target as HTMLElement
const cell = target.closest("td[data-col-id]")
const rowId = target
.closest("tr[data-row-id]")
?.getAttribute("data-row-id")
if (!rowId) {
event.stopPropagation()
return
}
const columnId = cell?.getAttribute("data-col-id") ?? null
setContextCell({ rowId, columnId })
// Anchor the menu visually to its row WITHOUT touching the
// checkbox row selection; the attribute styles via bodyRow.
target
.closest("tr[data-row-id]")
?.setAttribute("data-context-open", "")
// The spreadsheet standard: right-click focuses the cell it
// landed on (keeping an existing selection it sits inside).
// Visual focus only - DOM focus stays with the menu, so its
// own Escape and typeahead never fight the grid's keys.
if (columnId && !cell?.hasAttribute("data-cell-selected")) {
table.setFocusedCell(rowId, columnId)
}
// A custom control can swallow the contextmenu before the
// trigger sees it; prevent the control's defaults and
// retarget the press at the trigger, so the menu owns the
// gesture on every cell.
const control = target.closest(
'button, a, input, select, textarea, [contenteditable], [role="combobox"], [role="checkbox"]'
)
if (control) {
event.preventDefault()
event.stopPropagation()
const retarget = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: native.clientX,
clientY: native.clientY,
}) as MouseEvent & { __retargeted?: boolean }
retarget.__retargeted = true
contextTriggerRef.current?.dispatchEvent(retarget)
}
}}
>
<DataGridContainer className="border-y">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<DataGridCellSelection apiRef={gridApiRef} />
</DataGridContainer>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
{/* Labels must live inside a Group: the Base UI label wires
itself to the surrounding group and throws without one. */}
<ContextMenuGroup>
<ContextMenuLabel>Cell</ContextMenuLabel>
<ContextMenuItem
onClick={() =>
contextCell?.columnId &&
gridApiRef.current?.focusCell(
contextCell.rowId,
contextCell.columnId,
{ edit: true }
)
}
>
Edit cell
</ContextMenuItem>
<ContextMenuItem onClick={copySelection}>Copy</ContextMenuItem>
<ContextMenuItem onClick={clearSelection}>Clear</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuLabel>Row</ContextMenuLabel>
<ContextMenuItem
onClick={() => contextRowId && duplicateRow(contextRowId)}
>
Duplicate
</ContextMenuItem>
<ContextMenuItem
onClick={() => contextRowId && copyRow(contextRowId)}
>
Copy row
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => contextRowId && setConfirmDeleteId(contextRowId)}
>
Delete row
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
<CardFooter className="border-none bg-transparent! px-3.5 py-0">
<DataGridPagination />
</CardFooter>
</Card>
<SelectionToast
count={cellSelection?.visibleCellCount ?? 0}
bound={cellSelection?.activeBound ?? null}
/>
<AlertDialog
open={!!confirmDeleteId}
onOpenChange={(open) => {
if (!open) setConfirmDeleteId(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this row?</AlertDialogTitle>
<AlertDialogDescription>
{confirmDeleteRow?.name
? `"${confirmDeleteRow.name}" is removed from the grid.`
: "The row is removed from the grid."}{" "}
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (confirmDeleteId) deleteRow(confirmDeleteId)
setConfirmDeleteId(null)
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DemoToaster />
</DataGrid>
)
}
"use client"
import { useMemo, useState } from "react"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTableVirtual } from "@/components/reui/data-grid/data-grid-table-virtual"
import { useTable } from "@tanstack/react-table"
import type { ColumnDef, SortingState } from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
interface IPerson {
name: string
avatar: string
initials: string
}
interface IData {
id: string
person: IPerson
metrics: number[]
total: number
}
const METRIC_COLUMN_COUNT = 36
const ROW_COUNT = 1000
const COLUMN_JUMP_SIZE = 8
const columnVirtualizerOptions = { enabled: true, overscan: 3 }
const numberFormatter = new Intl.NumberFormat("en-US")
const PEOPLE: IPerson[] = [
{
name: "Alex Johnson",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
initials: "AJ",
},
{
name: "Sarah Chen",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
initials: "SC",
},
{
name: "Michael Rodriguez",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
initials: "MR",
},
{
name: "Emma Wilson",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
initials: "EW",
},
{
name: "David Kim",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
initials: "DK",
},
{
name: "Aron Thompson",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
initials: "AT",
},
{
name: "James Brown",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
initials: "JB",
},
{
name: "Maria Garcia",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
initials: "MG",
},
{
name: "Nick Johnson",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
initials: "NJ",
},
{
name: "Liam Thompson",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
initials: "LT",
},
]
// Deterministic pseudo-data: the same grid on every load, no Math.random.
function generateData(count: number): IData[] {
return Array.from({ length: count }, (_, rowIndex) => {
const metrics = Array.from(
{ length: METRIC_COLUMN_COUNT },
(_, metricIndex) => 100 + ((rowIndex * 37 + metricIndex * 19) % 900)
)
return {
id: String(rowIndex + 1),
person: PEOPLE[rowIndex % PEOPLE.length]!,
metrics,
total: metrics.reduce((sum, value) => sum + value, 0),
}
})
}
const allData = generateData(ROW_COUNT)
export function Pattern() {
const [sorting, setSorting] = useState<SortingState>([])
const [targetColumnIndex, setTargetColumnIndex] = useState(0)
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(() => {
const metricColumns = Array.from(
{ length: METRIC_COLUMN_COUNT },
(_, metricIndex): ColumnDef<DataGridFeatures, IData> => {
const metricLabel = `Metric ${String(metricIndex + 1).padStart(2, "0")}`
return {
id: `metric-${metricIndex + 1}`,
accessorFn: (row) => row.metrics[metricIndex],
header: ({ column }) => (
<DataGridColumnHeader title={metricLabel} column={column} />
),
cell: ({ row }) => (
<span className="block text-right font-mono tabular-nums">
{numberFormatter.format(row.original.metrics[metricIndex]!)}
</span>
),
size: 124,
}
}
)
return [
{
accessorKey: "id",
id: "id",
header: ({ column }) => (
<DataGridColumnHeader title="#" column={column} />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{row.original.id}
</span>
),
size: 72,
enableSorting: false,
enableHiding: false,
},
{
id: "name",
accessorFn: (row) => row.person.name,
header: ({ column }) => (
<DataGridColumnHeader title="Team member" column={column} />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage
src={row.original.person.avatar}
alt={row.original.person.name}
/>
<AvatarFallback className="text-[10px]">
{row.original.person.initials}
</AvatarFallback>
</Avatar>
<span className="text-foreground truncate font-medium">
{row.original.person.name}
</span>
</div>
),
size: 190,
enableHiding: false,
},
...metricColumns,
{
accessorKey: "total",
id: "total",
header: ({ column }) => (
<DataGridColumnHeader title="Total" column={column} />
),
cell: ({ row }) => (
<span className="block text-right font-medium tabular-nums">
{numberFormatter.format(row.original.total)}
</span>
),
size: 132,
enableHiding: false,
},
]
}, [])
const table = useTable({
features: dataGridFeatures,
columns,
data: allData,
enableColumnPinning: true,
getRowId: (row: IData) => row.id,
state: {
columnPinning: { start: ["id", "name"], end: ["total"] },
sorting,
},
onSortingChange: setSorting,
// The whole set is already client-side: without this, v9's registered
// paginated row model would clip the virtual grid to one page.
manualPagination: true,
})
return (
<DataGrid
table={table}
recordCount={allData.length}
tableLayout={{
width: "fixed",
dense: true,
columnsPinnable: true,
columnsResizable: true,
headerSticky: true,
}}
tableClassNames={{
/* z-40, not z-10: pinned BODY cells are sticky at z-30, and a lower
thead would let them paint over the header band while rows scroll
under it. 40 is the primitive's sticky-header plane. */
headerSticky: "sticky top-0 z-40 bg-muted/90 backdrop-blur-xs",
}}
>
{/* No footer here, so the grid itself closes the card: pb-0 removes
the empty strip below the container, and overflow-hidden lets the
card's own radius clip the grid's square bottom corners. */}
<Card className="w-full gap-3 overflow-hidden py-3.5 pb-0">
<CardHeader className="items-center px-3.5">
<CardTitle>Performance Matrix</CardTitle>
<CardAction className="flex items-center gap-2">
<span className="text-muted-foreground hidden text-xs tabular-nums sm:inline">
Metric {String(targetColumnIndex + 1).padStart(2, "0")} of{" "}
{METRIC_COLUMN_COUNT}
</span>
<Button
aria-label="Show previous metric columns"
title="Show previous metric columns"
className="size-8"
disabled={targetColumnIndex === 0}
size="icon"
variant="outline"
onClick={() =>
setTargetColumnIndex((current) =>
Math.max(0, current - COLUMN_JUMP_SIZE)
)
}
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
<Button
aria-label="Show next metric columns"
title="Show next metric columns"
className="size-8"
disabled={targetColumnIndex === METRIC_COLUMN_COUNT - 1}
size="icon"
variant="outline"
onClick={() =>
setTargetColumnIndex((current) =>
Math.min(METRIC_COLUMN_COUNT - 1, current + COLUMN_JUMP_SIZE)
)
}
>
<ChevronRightIcon aria-hidden="true" />
</Button>
</CardAction>
</CardHeader>
<DataGridContainer className="border-t">
<DataGridScrollArea className="h-[480px]">
{/* Column index 2 + the jump target: the controlled reveal
addresses CENTER columns only, so index 0 is Metric 01. */}
<DataGridTableVirtual
estimateSize={41}
overscan={8}
columnVirtualizerOptions={columnVirtualizerOptions}
scrollBehavior="smooth"
scrollToColumnAlign="center"
scrollToColumnIndex={targetColumnIndex}
/>
</DataGridScrollArea>
</DataGridContainer>
</Card>
</DataGrid>
)
}
"use client"
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import type { DataGridI18nOverrides } from "@/components/reui/data-grid/data-grid-i18n"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import {
DataGridTable,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from "@/components/reui/data-grid/data-grid-table"
import { useTable } from "@tanstack/react-table"
import type {
ColumnDef,
PaginationState,
RowSelectionState,
SortingState,
} from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import {
Card,
CardAction,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
interface IOrder {
id: string
reference: string
customer: string
avatar: string
initials: string
city: string
status: "shipped" | "processing"
total: number
}
type Locale = "en" | "de" | "ja"
const orders: IOrder[] = [
{
id: "1",
reference: "ORD-4417",
customer: "Alex Johnson",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
initials: "AJ",
city: "Berlin",
status: "shipped",
total: 249.9,
},
{
id: "2",
reference: "ORD-4418",
customer: "Sarah Chen",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
initials: "SC",
city: "Osaka",
status: "processing",
total: 89.0,
},
{
id: "3",
reference: "ORD-4421",
customer: "Michael Rodriguez",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
initials: "MR",
city: "Madrid",
status: "shipped",
total: 1290.5,
},
{
id: "4",
reference: "ORD-4425",
customer: "Emma Wilson",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
initials: "EW",
city: "Hamburg",
status: "processing",
total: 45.25,
},
{
id: "5",
reference: "ORD-4430",
customer: "David Kim",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
initials: "DK",
city: "Seoul",
status: "shipped",
total: 615.0,
},
{
id: "6",
reference: "ORD-4433",
customer: "Maria Garcia",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
initials: "MG",
city: "Lisbon",
status: "processing",
total: 132.75,
},
{
id: "7",
reference: "ORD-4440",
customer: "James Brown",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
initials: "JB",
city: "Toronto",
status: "shipped",
total: 78.4,
},
]
/**
* One entry per locale: the grid's own copy through `i18n`, plus the column
* titles and cell text the consumer owns. The grid never translates content,
* only its built-in chrome, so a real app pairs the two exactly like this.
*/
const localeContent: Record<
Locale,
{
label: string
columns: {
reference: string
customer: string
city: string
status: string
total: string
}
status: { shipped: string; processing: string }
currency: string
i18n: DataGridI18nOverrides
}
> = {
en: {
label: "EN",
columns: {
reference: "Order",
customer: "Customer",
city: "City",
status: "Status",
total: "Total",
},
status: { shipped: "Shipped", processing: "Processing" },
currency: "USD",
i18n: {},
},
de: {
label: "DE",
columns: {
reference: "Bestellung",
customer: "Kunde",
city: "Stadt",
status: "Status",
total: "Summe",
},
status: { shipped: "Versandt", processing: "In Bearbeitung" },
currency: "EUR",
i18n: {
labels: {
sortAscending: "Aufsteigend",
sortDescending: "Absteigend",
pinColumnStart: "Links anheften",
pinColumnEnd: "Rechts anheften",
moveColumnStart: "Nach links verschieben",
moveColumnEnd: "Nach rechts verschieben",
columnsMenu: "Spalten",
toggleColumns: "Spalten ein- und ausblenden",
selectAll: "Alle auswählen",
selectRow: "Zeile auswählen",
rowsPerPage: "Zeilen pro Seite",
paginationInfo: ({ from, to, count }) => `${from}-${to} von ${count}`,
previousPage: "Vorherige Seite",
nextPage: "Nächste Seite",
goToPage: (page) => `Seite ${page}`,
empty: "Keine Daten vorhanden",
},
},
},
ja: {
label: "日本語",
columns: {
reference: "注文",
customer: "顧客",
city: "都市",
status: "状態",
total: "合計",
},
status: { shipped: "発送済み", processing: "処理中" },
currency: "JPY",
i18n: {
labels: {
sortAscending: "昇順",
sortDescending: "降順",
pinColumnStart: "左に固定",
pinColumnEnd: "右に固定",
moveColumnStart: "左へ移動",
moveColumnEnd: "右へ移動",
columnsMenu: "列",
toggleColumns: "列の表示切り替え",
selectAll: "すべて選択",
selectRow: "行を選択",
rowsPerPage: "1ページの行数",
/* Japanese counts the total first, the reason these labels are
functions rather than templates with fixed placeholders. */
paginationInfo: ({ from, to, count }) => `${count}件中 ${from}-${to}件`,
previousPage: "前のページ",
nextPage: "次のページ",
goToPage: (page) => `${page}ページ目`,
empty: "データがありません",
},
},
},
}
const localeTags: Record<Locale, string> = {
en: "en-US",
de: "de-DE",
ja: "ja-JP",
}
export function Pattern() {
const [locale, setLocale] = useState<Locale>("en")
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const content = localeContent[locale]
const columns = useMemo<ColumnDef<DataGridFeatures, IOrder>[]>(() => {
const money = new Intl.NumberFormat(localeTags[locale], {
style: "currency",
currency: content.currency,
maximumFractionDigits: content.currency === "JPY" ? 0 : 2,
})
return [
{
id: "select",
header: () => <DataGridTableRowSelectAll />,
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
size: 44,
enableSorting: false,
enableHiding: false,
enableResizing: false,
},
{
accessorKey: "reference",
header: ({ column }) => (
<DataGridColumnHeader
title={content.columns.reference}
column={column}
/>
),
cell: ({ row }) => (
<span className="font-mono text-xs">{row.original.reference}</span>
),
size: 110,
},
{
accessorKey: "customer",
header: ({ column }) => (
<DataGridColumnHeader
title={content.columns.customer}
column={column}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage
src={row.original.avatar}
alt={row.original.customer}
/>
<AvatarFallback className="text-[10px]">
{row.original.initials}
</AvatarFallback>
</Avatar>
<span className="text-foreground truncate font-medium">
{row.original.customer}
</span>
</div>
),
size: 200,
},
{
accessorKey: "city",
header: ({ column }) => (
<DataGridColumnHeader title={content.columns.city} column={column} />
),
cell: ({ row }) => (
<span className="truncate">{row.original.city}</span>
),
size: 120,
},
{
accessorKey: "status",
header: ({ column }) => (
<DataGridColumnHeader
title={content.columns.status}
column={column}
/>
),
cell: ({ row }) => (
<Badge
variant={
row.original.status === "shipped"
? "success-light"
: "warning-light"
}
>
{content.status[row.original.status]}
</Badge>
),
size: 140,
},
{
accessorKey: "total",
header: ({ column }) => (
<DataGridColumnHeader title={content.columns.total} column={column} />
),
cell: ({ row }) => (
<span className="block text-end tabular-nums">
{money.format(row.original.total)}
</span>
),
size: 130,
meta: { headerClassName: "text-end [&>div]:justify-end" },
},
]
}, [content, locale])
const table = useTable({
features: dataGridFeatures,
columns,
data: orders,
getRowId: (row: IOrder) => row.id,
state: { sorting, pagination, rowSelection },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
})
return (
<DataGrid
table={table}
recordCount={orders.length}
/* The whole point of the example: one prop swaps every built-in
string, and the untouched keys keep their English defaults. */
i18n={content.i18n}
tableLayout={{ columnsPinnable: true, columnsMovable: true }}
>
<Card className="w-full gap-3 py-3.5">
<CardHeader className="items-center px-3.5">
<CardTitle>Orders</CardTitle>
<CardAction>
<ToggleGroup
variant="outline"
size="sm"
value={[locale]}
onValueChange={(next: string[]) =>
next[0] && setLocale(next[0] as Locale)
}
aria-label="Grid language"
>
{(Object.keys(localeContent) as Locale[]).map((value) => (
<ToggleGroupItem key={value} value={value}>
{localeContent[value].label}
</ToggleGroupItem>
))}
</ToggleGroup>
</CardAction>
</CardHeader>
<DataGridContainer className="border-y">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<CardFooter className="border-none bg-transparent! px-3.5 py-0">
<DataGridPagination />
</CardFooter>
</Card>
</DataGrid>
)
}
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { XIcon } from 'lucide-react'
interface IData {
id: string
name: string
avatar: string
email: string
company: string
status: "Active" | "Inactive" | "Pending"
balance: number
}
type StatusFilter = "all" | IData["status"]
const avatars = [
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
]
const names = [
"Alex Johnson",
"Sarah Chen",
"Michael Rodriguez",
"Emma Wilson",
"David Kim",
"Aron Thompson",
"James Brown",
"Maria Garcia",
"Nick Johnson",
"Liam Thompson",
]
const companies = [
"Apple",
"OpenAI",
"Meta",
"Tesla",
"SAP",
"Keenthemes",
"BBVA",
"Sony",
"LVMH",
"ENI",
]
const statuses: IData["status"][] = ["Active", "Inactive", "Pending"]
// Deterministic, so sorting and paging are reproducible across fetches. A
// non-round total makes the "1 - 5 of 487" info text read like a real API.
const TOTAL_SERVER_RECORDS = 487
const serverRecords: IData[] = Array.from(
{ length: TOTAL_SERVER_RECORDS },
(_, index) => {
const name = names[index % names.length]
return {
id: String(index + 1),
name,
avatar: avatars[index % avatars.length],
email: `${name.toLowerCase().replace(" ", ".")}${index + 1}@company.com`,
company: companies[(index * 3) % companies.length],
status: statuses[index % statuses.length],
balance: Math.round((1000 + ((index * 137.17) % 9000)) * 100) / 100,
}
}
)
/**
* The simulated server. Everything inside this function is what a real
* backend does with the query string of a paged endpoint: filter, sort, count,
* slice. Replace the whole body with a fetch to your own API and keep the
* return shape - one page of rows plus the total AFTER filtering.
*/
async function fetchServerPage(params: {
pageIndex: number
pageSize: number
sorting: SortingState
search: string
status: StatusFilter
}): Promise<{ rows: IData[]; total: number }> {
// Latency, so the built-in skeleton state is actually visible in the demo.
await new Promise((resolve) => setTimeout(resolve, 500))
const search = params.search.toLowerCase()
let rows = serverRecords
if (params.status !== "all") {
rows = rows.filter((record) => record.status === params.status)
}
if (search) {
rows = rows.filter((record) =>
[record.name, record.email, record.company].some((value) =>
value.toLowerCase().includes(search)
)
)
}
const sort = params.sorting[0]
if (sort) {
const direction = sort.desc ? -1 : 1
rows = [...rows].sort((a, b) => {
const left = a[sort.id as keyof IData]
const right = b[sort.id as keyof IData]
if (typeof left === "number" && typeof right === "number") {
return (left - right) * direction
}
return String(left).localeCompare(String(right)) * direction
})
}
const start = params.pageIndex * params.pageSize
return {
rows: rows.slice(start, start + params.pageSize),
total: rows.length,
}
}
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([])
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("all")
const [data, setData] = useState<IData[]>([])
const [total, setTotal] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const hasActiveFilters = searchInput.trim() !== "" || status !== "all"
const resetToFirstPage = () =>
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
// Debounce typing into one server query, and return to the first page: the
// old page index is meaningless against a different filtered set.
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setSearch(searchInput.trim())
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
)
}, 350)
return () => window.clearTimeout(timeoutId)
}, [searchInput])
// One fetch per settled query state. The request id guards against a slow
// response landing after a newer one and overwriting fresher rows.
const requestIdRef = useRef(0)
useEffect(() => {
const requestId = ++requestIdRef.current
setIsLoading(true)
fetchServerPage({
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
sorting,
search,
status,
}).then((response) => {
if (requestId !== requestIdRef.current) return
setData(response.rows)
setTotal(response.total)
setIsLoading(false)
})
}, [pagination.pageIndex, pagination.pageSize, sorting, search, status])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader title="User" column={column} />
),
cell: ({ row }) => (
<div className="flex items-center gap-3">
<Avatar className="size-7">
<AvatarImage src={row.original.avatar} alt={row.original.name} />
<AvatarFallback>
{row.original.name
.split(" ")
.map((namePart) => namePart[0])
.join("")}
</AvatarFallback>
</Avatar>
<div>
<div className="text-foreground font-medium">
{row.original.name}
</div>
<div className="text-muted-foreground text-xs">
{row.original.email}
</div>
</div>
</div>
),
minSize: 220,
meta: {
autoSize: true,
skeleton: (
<div className="flex items-center gap-3">
<Skeleton className="size-7 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3 w-36" />
</div>
</div>
),
},
enableSorting: true,
},
{
accessorKey: "company",
id: "company",
header: ({ column }) => (
<DataGridColumnHeader title="Company" column={column} />
),
cell: ({ row }) => (
<span className="text-foreground">{row.original.company}</span>
),
size: 130,
meta: {
skeleton: <Skeleton className="h-4 w-20" />,
},
enableSorting: true,
},
{
accessorKey: "status",
id: "status",
header: ({ column }) => (
<DataGridColumnHeader title="Status" column={column} />
),
cell: ({ row }) => {
const rowStatus = row.original.status
if (rowStatus === "Active") {
return <Badge variant="success-outline">Active</Badge>
}
if (rowStatus === "Inactive") {
return <Badge variant="info-outline">Inactive</Badge>
}
return <Badge variant="warning-outline">Pending</Badge>
},
size: 120,
meta: {
skeleton: <Skeleton className="h-5 w-16" />,
},
enableSorting: true,
},
{
accessorKey: "balance",
id: "balance",
header: ({ column }) => (
<DataGridColumnHeader title="Balance" column={column} />
),
cell: ({ row }) => (
<span className="tabular-nums">
$
{row.original.balance.toLocaleString("en-US", {
minimumFractionDigits: 2,
})}
</span>
),
size: 140,
meta: {
skeleton: <Skeleton className="h-4 w-24" />,
},
enableSorting: true,
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data,
// Server-side mode: `data` is exactly one page, so paging and sorting are
// the server's job instead of the row models'.
manualPagination: true,
manualSorting: true,
// The server-side total. REQUIRED alongside recordCount below: without it
// getPageCount() is derived from the one loaded page and the pagination
// buttons collapse to a single page, while the info text still claims the
// full count. recordCount only drives the "1 - 5 of N" text.
rowCount: total,
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
return (
<DataGrid
table={table}
recordCount={total}
isLoading={isLoading}
tableLayout={{ columnsResizable: true }}
>
<Card className="w-full gap-3 py-3.5">
<CardHeader className="items-center px-3.5">
<CardTitle className="flex items-center gap-2">
Users
<Badge variant="secondary" size="sm" className="tabular-nums">
{total}
</Badge>
</CardTitle>
<CardAction className="flex flex-wrap items-center justify-end gap-2">
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
aria-label="Search users"
placeholder="Search name, email, company..."
className="w-56"
/>
<Select
value={status}
/* The primitive types the callback value as nullable; a null
(nothing selected) means the unfiltered view. */
onValueChange={(next) => {
setStatus((next ?? "all") as StatusFilter)
resetToFirstPage()
}}
>
<SelectTrigger className="w-32" aria-label="Filter by status">
{/* Explicit label: the primitive can only resolve a value to
its item label after the popup has mounted the items, so a
fresh render would show the raw "all". */}
<SelectValue placeholder="Status">
{status === "all" ? "All statuses" : status}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="Inactive">Inactive</SelectItem>
<SelectItem value="Pending">Pending</SelectItem>
</SelectContent>
</Select>
{/* Present only while a filter is active, so the resting toolbar
stays quiet. Search is cleared through both halves at once:
waiting out the debounce would leave stale rows for 350ms. */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchInput("")
setSearch("")
setStatus("all")
resetToFirstPage()
}}
>
<XIcon className="size-4" />
Clear
</Button>
)}
</CardAction>
</CardHeader>
<CardContent className="border-t p-0">
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</CardContent>
<CardFooter className="border-none bg-transparent! px-3.5 py-0">
<DataGridPagination />
</CardFooter>
</Card>
</DataGrid>
)
}
"use client"
import { useMemo, useState } from "react"
import {
DataGrid,
DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
ColumnDef,
PaginationState,
SortingState,
useTable,
} from "@tanstack/react-table"
const users = [
{
id: "1",
name: "Alex Johnson",
email: "alex@example.com",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
initials: "AJ",
},
{
id: "2",
name: "Sarah Chen",
email: "sarah@example.com",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
initials: "SC",
},
{
id: "3",
name: "Michael Rodriguez",
email: "michael@example.com",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
initials: "MR",
},
{
id: "4",
name: "Emma Wilson",
email: "emma@example.com",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
initials: "EW",
},
{
id: "5",
name: "David Kim",
email: "david@example.com",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
initials: "DK",
},
{
id: "6",
name: "Aron Thompson",
email: "lisa@example.com",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
initials: "LT",
},
{
id: "7",
name: "James Brown",
email: "james@example.com",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
initials: "JB",
},
{
id: "8",
name: "Maria Garcia",
email: "maria@example.com",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
initials: "MG",
},
{
id: "9",
name: "Nick Johnson",
email: "nick@example.com",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
initials: "NJ",
},
{
id: "10",
name: "Liam Thompson",
email: "liam@example.com",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
initials: "LT",
},
]
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string // Emoji flags
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const demoData: IData[] = users.map((user, index) => ({
...user,
availability: (["online", "away", "busy", "offline"] as const)[index % 4],
status: (index % 2 === 0 ? "active" : "inactive") as "active" | "inactive",
flag: (["us", "gb", "ca", "au", "de", "my", "es", "jp", "fr", "it"] as const)[
index % 10
],
company: (
[
"Apple",
"OpenAI",
"Meta",
"Tesla",
"SAP",
"Keenthemes",
"BBVA",
"Sony",
"LVMH",
"ENI",
] as const
)[index % 10],
role: (
[
"CEO",
"CTO",
"Designer",
"Developer",
"Lawyer",
"Director",
"Product Manager",
"Marketing Lead",
"Data Scientist",
"Engineer",
] as const
)[index % 10],
joined: "Jan, 2024",
location: (
[
"United States",
"United Kingdom",
"Canada",
"Australia",
"Germany",
"Malaysia",
"Spain",
"Japan",
"France",
"Italy",
] as const
)[index % 10],
balance: 5143.03 + index * 100,
}))
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: true },
])
const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
() => [
{
accessorKey: "name",
header: "Name",
cell: (info) => <>{info.getValue() as string}</>,
size: 150,
meta: {
headerClassName: "",
cellClassName: "",
},
},
{
accessorKey: "email",
header: "Email",
cell: (info) => (
<div className="truncate">
<a
href={`mailto:${info.getValue()}`}
className="hover:text-primary truncate hover:underline"
>
{info.getValue() as string}
</a>
</div>
),
size: 150,
meta: {
headerClassName: "",
cellClassName: "",
},
},
{
accessorKey: "location",
header: "Location",
cell: ({ row }) => (
<div className="flex items-center gap-1.5">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<div className="text-foreground">{row.original.location}</div>
</div>
),
size: 175,
meta: {
headerClassName: "",
cellClassName: "",
},
},
{
accessorKey: "balance",
header: "Balance ($)",
cell: (info) => <>${(info.getValue() as number).toFixed(2)}</>,
size: 100,
meta: {
headerClassName: "text-right rtl:text-left",
cellClassName: "text-right rtl:text-left",
},
},
],
[]
)
const table = useTable({
features: dataGridFeatures,
columns,
data: demoData,
pageCount: Math.ceil((demoData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting,
},
onPaginationChange: setPagination,
onSortingChange: setSorting,
})
return (
<DataGrid table={table} recordCount={demoData?.length || 0}>
<div className="w-full space-y-2.5">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
)
}