DataGrid
A powerful data grid component with sorting, filtering, and custom cells. (Work in Progress)
Import
JSX
import DataGrid from '@box-kite/react/components/dataGrid';Keyboard and roles
This is the APG grid pattern, over a virtualized body — the case most grids skip. The scrolling element is a
role="grid" of rowgroups, rows and gridcells, and it tells assistive technology the size of the whole grid rather than the size of the window it renders: aria-rowcount counts every row, aria-rowindex numbers each one where it really is, and a jump to a row nobody has scrolled to brings it into view first. You write none of that.Focus lives on the cells, and the grid is one tab stop
One cell is in the tab order at a time, so Tab enters the grid in a single press and the next one leaves it — APG's rule, since a grid is a composite widget. The arrow keys move that cell; a sortable header sorts on Enter; a cell that holds a control hands the keyboard over on Enter or F2, where Tab then walks that cell's own controls, and Escape takes it back. Nothing the grid draws is a tab stop of its own, so a wide grid costs one press to cross rather than one per checkbox.
Space selects the row, and Tab can walk the cells if you ask
Space on any cell selects its row — or all of them, from the header cell the select-all box sits in — which is how a grid is selected without a mouse now that the checkboxes are reached through their cells. For a data-entry screen that wants the spreadsheet reading instead,
def.tabNavigation="cells" makes Tab walk to the next cell and on into the rows after it, the way a spreadsheet does; the cost is that Tab is then no longer the way out of the grid.Give it a title
A grid is not named by the rows in it. Pass
def.title and the grid points aria-labelledby at it.Everything the grid draws for itself has a name
The column chooser, the select-all box, each row’s checkbox and expander, each column’s menu and each filter input are named after what they act on — “Select row 4”, “Filter Country”, “Column options for Age” — rather than after the icon drawn on them.
A column can be resized without a mouse
Each resizer is a
role="separator" — APG’s window splitter — with the column’s width in pixels on aria-valuenow, so a screen reader reads the new width out as it changes.Full Featured DataGrid
All Features Demo
8 of 8 shown
Row number
Personal Info
Contact
Country
Gender
City
First Name
Last Name
Age
Email
Phone
Select...
Select...
Rows: 1000
Selected: 0
JSX
// A def that never changes: the quick filters read their state from context, not from def.
const def = {
title: 'All Features Demo',
topBar: true,
bottomBar: true,
globalFilter: true,
topBarContent: <CustomTopBarFilter />,
rowSelection: { pinned: true },
showRowNumber: { pinned: true },
rowHeight: 40,
visibleRowsCount: 8,
columns: [...],
};
const filters = useMemo(() => {
const result = [];
if (gender) result.push((row) => row.gender === gender);
if (age === 'under30') result.push((row) => row.age < 30);
return result;
}, [gender, age]);
// The control answers on the click; the grid re-filters in a render React may interrupt.
const deferredFilters = useDeferredValue(filters);
<DataGrid data={allData} filters={deferredFilters} def={def} />On a phone, a list
A table does not fit a phone, and shrinking one only makes it unreadable. Below
def.list.breakpoint (576px by default) of its own width the grid draws each row as a list item instead: a title with one value opposite it, a line above or under it, and a few labelled values. Pressing an item opens the whole row in a sheet, one row at a time, with a pager. Sort, filters and the search move into a toolbar of bottom sheets. It is the same grid with the same rows, filters, sort, selection and edits, drawn a different way. layout: 'table' or 'list' chooses one for good.A column says where it goes in an item
list: 'title' | 'kicker' | 'subtitle' | 'end' | 'field' | 'detail' | 'hidden' on a column. Left alone, the first column is the title, the first end-aligned one sits opposite it, the next four are fields (def.list.fields) and the rest wait for the record view. A column's own Cell draws its value in both layouts, so a badge is a badge in each.Filters are a sheet, and the button says what you will get
Each filterable column gets a section with a control a finger can use: a full-width field, the phone's own number picker, chips for a few options, a searchable checklist for many. A filter applies as you set it, so the main button already reads “Show 42 rows”. The filters in force sit under the search as chips, each one removable.
Selecting is a mode
A checkbox on every item is a target a thumb hits by accident, so
rowSelection adds a Select button: while it is on, a press ticks the row instead of opening it, and a bar pinned to the bottom of the screen counts what is ticked.A list, not a restyled grid
The markup is a
<ul> of items with a <dl> in each, so every value keeps its label for a screen reader, and each item says where it is with aria-posinset/aria-setsize. The list grows twenty items per “Show more”, a button rather than an endless scroll, so the totals and the rest of the page stay reachable.The same grid, at a phone's width
JSX
<DataGrid
data={people}
def={{
title: 'People',
globalFilter: true,
rowSelection: true,
// 'auto' is the default: a list below 576px of the grid's own width.
layout: 'auto',
columns: [
{
key: 'first_name',
header: 'Name',
list: 'title',
Cell: ({ cell }) => `${cell.row.data.first_name} ${cell.row.data.last_name}`,
},
{ key: 'job_title', header: 'Job', list: 'kicker' },
{ key: 'email', header: 'Email', list: 'subtitle' },
{ key: 'salary', header: 'Salary', align: 'end', list: 'end', filterable: { type: 'number' } },
{ key: 'country', header: 'Country', filterable: { type: 'multiselect' } },
{ key: 'gender', header: 'Gender', filterable: { type: 'multiselect' } },
{ key: 'age', header: 'Age', filterable: { type: 'number' } },
{ key: 'city', header: 'City' },
{ key: 'phone_number', header: 'Phone', list: 'detail' },
],
}}
/>Basic DataGrid
JSX
<DataGrid
data={data}
def={{
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'last_name', header: 'Last name' },
{
key: 'age',
header: 'Age',
width: 90,
align: 'right',
Cell: ({ cell }) => {
// better to define this function outside to avoid re-creation on each render
return (
<Flex
bgColor="violet-50"
height="fit"
width="fit"
ai="center"
jc="center"
overflow="hidden"
className="parent"
theme={{ dark: { bgColor: 'violet-600' } }}
>
<Box
px={4}
textOverflow="ellipsis"
overflow="hidden"
textWrap="nowrap"
color="violet-700"
fontWeight={600}
hoverGroup={{ parent: { rotate: 180 } }}
theme={{ dark: { color: 'violet-300' } }}
>
{cell.row.data.age}
</Box>
</Flex>
);
},
},
{ key: 'email', header: 'Email', width: 300 },
{ key: 'street_address' },
{ key: 'city' },
{ key: 'country' },
{ key: 'favorite_color' },
{ key: 'gender' },
{ key: 'ssn' },
{ key: 'birthdate' },
{ key: 'phone_number' },
{ key: 'username' },
{ key: 'credit_card_number' },
{ key: 'salary' },
{ key: 'company_name' },
{ key: 'language' },
{ key: 'currency_code' },
],
rowHeight: 40,
visibleRowsCount: 5,
}}
/>DataGrid with Global Filter and Column Filters
JSX
<DataGrid
data={data}
def={{
columns: [
{ key: 'first_name', header: 'First name', filterable: true },
{ key: 'last_name', header: 'Last name', filterable: true },
{ key: 'age', header: 'Age', width: 120, align: 'right', filterable: { type: 'number' } },
{ key: 'email', header: 'Email', width: 300, filterable: true },
{ key: 'country', filterable: { type: 'multiselect' } },
{ key: 'gender', filterable: { type: 'multiselect' } },
],
rowHeight: 40,
visibleRowsCount: 8,
topBar: true,
bottomBar: true,
globalFilter: true,
}}
/>Grouped Columns with Row Selection
JSX
<DataGrid
data={data}
def={{
columns: [
{
key: 'person',
header: 'Person',
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'last_name', header: 'Last name' },
],
},
{
key: 'contact',
header: 'Contact',
columns: [
{ key: 'email', header: 'Email', width: 300 },
{ key: 'phone_number', header: 'Phone' },
],
},
{ key: 'country' },
{ key: 'city' },
],
rowSelection: { pinned: true },
showRowNumber: { pinned: true },
}}
/>Aggregation and totals
A column with an
aggregate totals itself — over the rows under each group row, and over the whole grid when def.footer is on. Five built-ins (sum, avg, min, max, count) or a function of your own, which is handed the column's values and the rows they came from. It covers the rows the filters left, so filtering the grid changes the totals; formatting is an AggregateCell, the aggregate's twin of Cell. Open the Country column's menu and choose Group By to put the same numbers on group rows.Per-group totals and a pinned grand total
JSX
<DataGrid
data={data}
def={{
// Grand totals pinned under the rows. The label goes in the first
// column that is not aggregating; `footer: { label }` replaces it.
footer: true,
columns: [
{ key: 'country' },
{ key: 'company_name', header: 'Company' },
// 'count' counts rows, so a blank cell is still a row.
{ key: 'username', header: 'People', aggregate: 'count' },
{ key: 'age', header: 'Avg age', aggregate: 'avg' },
{
key: 'salary',
header: 'Payroll',
aggregate: 'sum',
// The aggregate's own renderer — 'sum' answers a number, this formats it.
AggregateCell: ({ cell }) => (
<Box px={3} textWrap="nowrap">
{cell.value === null ? '—' : `$${Math.round(cell.value as number).toLocaleString('en-US')}`}
</Box>
),
},
],
}}
/>Export to Excel and CSV
def.export puts the two buttons in the top bar; a ref on the grid gives you exportXlsx and exportCsv to call from a toolbar of your own. The file is what the grid is showing — the visible columns in their pinned order, the rows the filters and the sort left, the group rows and their totals — and the workbook is a real .xlsx: a bold header on a frozen row, column widths, values that keep their type, and the grouping as Excel's own outline levels, so a collapsed group opens collapsed. Both writers are behind a dynamic import, so a grid nobody exports from carries none of that code. There is no ExcelJS to install and nothing to configure.Two buttons, and a workbook with its groups intact
JSX
<DataGrid<Person>
data={data}
def={{
topBar: true,
title: 'Payroll',
// `true` is both formats. `{ csv: false }` or `{ xlsx: false }` drops one,
// and `fileName` names the file — the title, otherwise.
export: { fileName: 'payroll' },
footer: true,
columns: [
{ key: 'country' },
// An export runs no React, so a column drawn by a `Cell` says what it writes.
{ key: 'company_name', header: 'Company', exportValue: (row) => row.company_name.toUpperCase() },
{ key: 'username', header: 'People', aggregate: 'count' },
// The number format the cell wears in the workbook.
{ key: 'salary', header: 'Payroll', aggregate: 'sum', exportFormat: '$#,##0' },
],
}}
/>Row Detail — Orders with Items
JSX
// A detail row already arrives joined to the row that opened it: they share a surface and a
// 2px accent runs down the inline start of both (isExpandedFirstLeaf on the row, detailRow.content
// under it). A custom tree only has to re-colour that block. 'orders-datagrid' does the surfaces;
// 'subgrid' strips the chrome off the grid inside the panel. Both names need the same .d.ts
// augmentation every Box.components() entry does; the Theme Setup page shows it.
Box.components({
'orders-datagrid': {
extends: 'datagrid',
children: {
body: {
children: {
// isExpanded is on every cell of the open row; isExpandedFirstLeaf / isExpandedLastLeaf
// name its two ends, which is where the default accent lives. A neutral, not a hue: this
// surface can cover half the grid, and the 2px accent is where colour belongs.
cell: {
variants: {
isExpanded: {
bgColor: 'slate-100',
group: { 'grid-row/hover': { bgColor: 'slate-200' } },
},
},
},
detailRow: { styles: { bgColor: 'slate-100', borderColor: 'slate-300' } },
},
},
},
},
// A grid inside a row is a list, not a second card: no border, no radius, no shadow and no
// surface of its own, so the drawer it sits in stays the only panel on screen.
subgrid: {
extends: 'datagrid',
styles: { b: 0, borderRadius: 0, shadow: 'none', bgColor: 'transparent' },
children: {
header: {
styles: { bgColor: 'transparent' },
children: {
cell: {
styles: {
bgColor: 'transparent',
minHeight: 0,
py: 2,
fontSize: 11,
letterSpacing: 0.4,
textTransform: 'uppercase',
// gray-500 is what the outer header uses, but that is measured on gray-50: on the
// drawer's tint the same pair is 4.33:1, so the nested header goes one step darker.
color: 'gray-600',
borderColor: 'gray-300',
},
},
},
},
body: {
children: {
cell: { styles: { bgColor: 'transparent', fontSize: 13, borderColor: 'gray-200' } },
},
},
},
},
});
<DataGrid
component="orders-datagrid"
data={orders}
def={{
rowKey: 'orderId',
topBar: true,
bottomBar: true,
title: 'Orders',
columns: [
{ key: 'orderId', header: 'Order #', width: 100, flexible: false },
{ key: 'customer', header: 'Customer' },
{ key: 'date', header: 'Date', width: 120 },
{ key: 'status', header: 'Status', width: 120 },
{ key: 'total', header: 'Total', width: 100, align: 'right' },
],
rowDetail: {
content: (order) => (
<Box px={5} py={4}>
{/* ps={3} lines the caption up with the first column's text, which a cell pads by 3. */}
<Flex ai="center" gap={2} mb={3} ps={3}>
<Box fontSize={11} fontWeight={600} letterSpacing={0.4} textTransform="uppercase" color="gray-600">
Items
</Box>
<Box fontSize={11} fontWeight={600} px={1.5} borderRadius={4} bgColor="indigo-100" color="indigo-700">
{order.items.length}
</Box>
</Flex>
<DataGrid
component="subgrid"
data={order.items}
def={{
columns: [
{ key: 'product', header: 'Product' },
{ key: 'qty', header: 'Qty', width: 80, align: 'right' },
{ key: 'price', header: 'Price', width: 100, align: 'right' },
],
visibleRowsCount: 'all',
rowHeight: 36,
// Three rows need no column menu and no resizing.
contextMenu: false,
resizable: false,
}}
/>
</Box>
),
pinned: true,
expandOnRowClick: true,
},
}}
/>Grouped rows, declared rather than clicked
Group By in a column's menu has always grouped the rows;
def.groupBy is where the grid starts. Name the columns to group by, outermost first, and the grid opens grouped — which is also the only way a page can server-render a grouped grid, or a test can see one. def.groupDefaultExpanded says which of those groups start open: true for all of them, a number for how many levels down. The menu takes it from there, and a group somebody opens or shuts stays where they left it.Declared grouping
JSX
<DataGrid
data={people}
def={{
// Outermost first. A grouped column is hidden, the way the menu hides one.
groupBy: ['country'],
// `true` opens every group; a number is how many levels down to open.
groupDefaultExpanded: 1,
footer: true,
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'last_name', header: 'Last name' },
{ key: 'country', header: 'Country' },
{ key: 'salary', header: 'Salary', aggregate: 'avg', align: 'end' },
],
}}
/>Rows that hold rows
def.treeData is a grid whose rows are a tree: a file system, an org chart, a bill of materials. The shape comes out of the data in one of two ways — nested, where a row carries its children (childrenKey, or getChildren to work them out), or flat, where every row carries the path to itself (pathKey/getPath), which is the shape an export or a WHERE path LIKE hands you. The grid does the rest: one column grows a chevron and an indent, and the whole thing reports itself as a role="treegrid" — which is what makes a row's aria-level and aria-expanded mean something, since the rows under a row are its siblings in the markup rather than its children.A filter keeps the path to a match
Filtering a tree row by row would hide every match that lives three folders down. A row survives when it matches or something under it does, so what is left is the matches and the way to them — and a row that matches on its own keeps none of the children that did not.
A shut row costs nothing
Nothing under a shut row is built at all — no models, no cells — so ten thousand rows nobody has opened cost the page its top level. Opening one builds that level and no more.
Ticking a parent is a decision, so it is opt-in
selection: 'cascade' makes a checkbox take everything under the row with it, and a row whose children are partly ticked reads as indeterminate. Without it a checkbox is the row it is on, which is what every other row in the grid does.Tree data
JSX
<DataGrid
data={files}
def={{
rowKey: 'id',
rowSelection: true,
globalFilter: true,
topBar: true,
// The tree is nested in the data. A flat list says `pathKey: 'path'` instead.
treeData: {
childrenKey: 'children',
// Which column carries the chevrons. Default: the first column of your own.
column: 'name',
// `true` for every row, a number for a depth, or a list of keys.
defaultExpanded: 1,
// Ticking a folder ticks everything in it.
selection: 'cascade',
},
columns: [
{ key: 'name', header: 'Name', width: 320 },
{ key: 'size', header: 'Size', align: 'end' },
{ key: 'modified', header: 'Modified' },
],
}}
/>Server-Side Pagination & Filters
JSX
const [data, setData] = useState([]);
const [page, setPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const pageSize = 8;
// onServerStateChange fires on every page/sort/filter change, with what moved beside the snapshot:
// (state, { reason }), where reason is 'page' | 'page-size' | 'sort' | 'filter' | 'clear'.
// state = { page, pageSize, sortColumn, sortDirection, globalFilterValue, columnFilters }
// columnFilters example: { first_name: { type: 'text', value: 'Jo' }, age: { type: 'number', operator: 'gte', value: 30 }, country: { type: 'multiselect', values: ['Brazil'] } }
const fetchData = useCallback((state) => {
setLoading(true);
api.getUsers({
Page: state.page, PageSize: state.pageSize,
SortBy: state.sortColumn, SortDir: state.sortDirection,
Search: state.globalFilterValue,
Filters: state.columnFilters,
}).then((res) => {
setData(res.Items);
setTotalCount(res.TotalCount);
setPage(res.Page);
setLoading(false);
});
}, []);
useEffect(() => { fetchData({ page: 1, pageSize }); }, []);
<DataGrid
data={data}
loading={loading}
page={page}
onServerStateChange={fetchData}
def={{
columns: [
{ key: 'first_name', header: 'First Name', filterable: true },
{ key: 'age', header: 'Age', filterable: { type: 'number', placeholder: 'Min age' } },
// In server-side mode the grid only has the current page, so provide
// all possible options explicitly (fetch them from your API once).
{ key: 'country', filterable: { type: 'multiselect', options: countryOptions } },
...
],
globalFilter: true,
visibleRowsCount: pageSize,
topBar: true, bottomBar: true,
title: 'Server-Side Pagination & Filters',
pagination: { totalCount },
}}
/>A million rows, fetched a block at a time
def.dataSource is the other way round from everything above: instead of handing the grid rows, you hand it a function and it asks. Sorting, filtering and paging stop being work the browser does over an array and become part of a request — so the grid can stand in front of a table nobody could send to a browser at all. Rows the server has not answered for yet are drawn as skeletons and are still counted, so the scrollbar and aria-rowcount describe the whole result set from the first block.A block is the unit of everything
One request, one wait, one failure.
blockSize is how many rows each asks for (the page size when def.pagination is beside it, 100 otherwise), and maxBlocks is how many are kept before the ones furthest from the viewport are dropped and fetched again on the way back.A late answer to an old question is thrown away
Every request carries an
AbortSignal that fires when the sort or the filters change underneath it — hand it to fetch and a superseded request costs the network nothing. A reply that arrives anyway is dropped rather than written over the newer one.What the grid cannot see is refresh()
The grid invalidates its own cache whenever it changes the query. For the half it cannot see — a filter of the page’s own, a row somebody saved — call
refresh() on the grid’s ref.Server row model
JSX
// One object, stable across renders: the grid reads it when it asks for a block.
const dataSource = useMemo(() => ({
blockSize: 100,
async getRows({ startRow, endRow, page, pageSize, sort, globalFilter, columnFilters, signal }) {
const res = await fetch('/api/people?' + new URLSearchParams({
offset: String(startRow),
limit: String(endRow - startRow),
sort: sort ? `${sort.columnKey}:${sort.direction}` : '',
q: globalFilter,
}), { signal });
const body = await res.json();
// `totalCount` sizes the scrollbar. Omit it and the grid follows the rows instead,
// treating a block shorter than it asked for as the end of the data.
return { rows: body.items, totalCount: body.total };
},
}), []);
<DataGrid
def={{
dataSource,
title: 'One million people',
topBar: true,
globalFilter: true,
visibleRowsCount: 12,
columns: [
{ key: 'id', header: '#', sortable: false, width: 110, align: 'end' },
{ key: 'name', header: 'Name', width: 240 },
{ key: 'country', header: 'Country', width: 160 },
{ key: 'joined', header: 'Joined', width: 140 },
{ key: 'score', header: 'Score', width: 120, align: 'end' },
],
}}
/>A group the server counts, and children it fetches when you open one
Set
grouping: true on the datasource and Group By comes back in the column menu. Every request then carries groupBy — the columns being grouped by, outermost first — and groupKeys, the group it is inside: [] asks for the top level, ['Japan'] for what is inside Japan. Shorter than groupBy and the rows wanted are group rows, one per distinct value, with groupCounts beside them; the same length and they are that group's own rows. Nothing is fetched until a chevron is pressed, so a million rows under eight groups cost the page eight rows.A group row is a row, and it brings its own totals
The server answers with rows of the same shape carrying the level's column — a group row for Japan is
{ country: "Japan", score: 61_500_000 }. A column with an aggregate reads its total off that row rather than adding up rows the browser has not got, so the numbers are the server's arithmetic, over the whole group.A level is its own cache, and closing a group frees it
Each open group holds blocks, a count and a failure of its own, and a group that is shut is disposed of with everything under it. Eviction keeps the chain above a level in play, so scrolling deep inside a group never drops the row it hangs off.
What the grid does not hold, it does not claim
A group whose rows have never been fetched shows no select-all checkbox, and an export writes the rows in hand. A grid that offered either would be promising something only the server can answer.
Server-side grouping
JSX
const dataSource = useMemo(() => ({
// Without this, Group By is not offered at all: a `getRows` that ignored
// `groupKeys` would answer a group level with leaf rows and nothing could tell.
grouping: true,
async getRows({ startRow, endRow, groupBy, groupKeys, sort, signal }) {
const res = await fetch('/api/people?' + new URLSearchParams({
offset: String(startRow),
limit: String(endRow - startRow),
groupBy: groupBy.join(','), // ['country'] — what to GROUP BY
groupKeys: groupKeys.join(','), // [] for the groups, ['Japan'] for what is in one
}), { signal });
const body = await res.json();
// A group level answers group rows plus how many leaf rows each holds. A leaf
// level answers rows, and needs neither — it is the same shape as every other block.
return { rows: body.items, totalCount: body.total, groupCounts: body.counts };
},
}), []);
<DataGrid
def={{
dataSource,
columns: [
{ key: 'name', header: 'Name' },
{ key: 'country', header: 'Country' },
{ key: 'score', header: 'Score', aggregate: 'sum', align: 'end' },
],
}}
/>A tree the server holds, one level per chevron
Put
def.treeData beside the datasource and the tree stops being something the browser holds at all. Every request then carries treeKeys — the row whose children are wanted, named by its key and its ancestors', outermost first, with [] for the top — and hasChildren is how a row says there is something under it to ask for. It is the same mechanism as server-side grouping: a level per open path, with its own blocks, its own count and the same eviction, which keeps the chain above a level in play. A folder nobody has opened costs the page nothing, however deep it goes.The path is keys, so the server names a node the way it named it
treeKeys is built from def.rowKey rather than from the values on screen — the ids the server sent come back to it verbatim. A row three deep asks with all three, so a query can be written against the whole path or against the last segment alone.A chevron is the server's answer, not a guess
hasChildren names a field or works the answer out. Without one no row grows a chevron: a grid that offered one on every row would promise a level that may not exist, and a grid that fetched to find out would defeat the whole thing.What the grid does not hold, it does not claim
selection: 'cascade' is not offered — a tick cannot take rows nobody has fetched — and an export writes the rows in hand, at the depth they are drawn.Lazy tree
JSX
const dataSource = useMemo(() => ({
async getRows({ startRow, endRow, treeKeys, sort, signal }) {
const res = await fetch('/api/files?' + new URLSearchParams({
offset: String(startRow),
limit: String(endRow - startRow),
// [] is the top of the tree, ['app'] what is in that folder,
// ['app', 'app/m3'] what is in the one under that.
path: treeKeys.join('/'),
}), { signal });
const body = await res.json();
return { rows: body.items, totalCount: body.total };
},
}), []);
<DataGrid
def={{
dataSource,
rowKey: 'id',
// The tree is the server's. `hasChildren` is the one thing a row's own
// values cannot say, since what is under it has never been in the browser.
treeData: { hasChildren: 'folder', column: 'name' },
columns: [
{ key: 'name', header: 'Name' },
{ key: 'size', header: 'Size (KB)', align: 'end' },
{ key: 'modified', header: 'Modified' },
],
}}
/>A cell you can type into
column.editable is the whole opt-in, and def.onCellEdit is the one function that both judges an edit and is told about it — answer nothing to accept the value, or a string to refuse it. Which editor a cell opens is read off the value in it: a number gets a numeric field, a boolean a checkbox, everything else a text field, so a column of numbers has a numeric keypad on a phone without being told. editor names one of the four (text, number, checkbox, select) or configures it, and EditCell is a control of your own bound to cell.draft.A press chooses the cell, a double press opens it
A press makes a cell the current one and marks it, wherever the arrows would carry on from — and a double press opens the editor, the way a spreadsheet reads one. A cell that cannot be edited takes both as focus and nothing else, and a widget inside a cell keeps its own presses: a double press on a chevron, or on a link in a
Cell of your own, belongs to the widget rather than the editor.The keyboard is APG's, and Tab is the one that matters
Enter or F2 opens the editor, Escape throws the draft away, Enter commits and hands the keyboard back to the cell. A printable character opens the editor on that character, replacing the value the way a spreadsheet does — and Tab commits and opens the next editable cell, along the row and on into the rows after it, so a row of corrections is one gesture.
A select editor opens its list with the editor
A
select cell opens its list as soon as the editor does, highlighting the value already in it, so choosing one is the single gesture it looks like — the list is the whole control, and waiting for a second press to show it asks for a gesture nobody meant. Escape closes the list and a second Escape leaves the editor. A printable character is not a value a list can hold, so on a select or a checkbox it opens the editor on the value already there; Dropdown takes the same behaviour as defaultOpen.A validator can be async, and a late answer is dropped
def.onCellEdit may return a promise, so a uniqueness check against a server is the same function. The editor waits; an answer to an edit the user has since abandoned is dropped rather than applied to whatever they are editing now. A refused value keeps the editor open with the message in a role="alert" — in the top layer, since a bubble inside the scroller would be clipped away on the last row.The grid does not own your data, so it holds the edit
An accepted value is kept as an edit over the rows the grid was given, and is what every cell, every
Cell renderer and every export reads from then on — which is what lets a grid over a def.dataSource be edited at all. onCellEditsChange reports the whole list, oldest first, which is the stream an undo is built out of, and clearEdits() on the grid's ref says your own data has caught up. An edit never re-sorts or re-filters the grid: a row jumping out from under the pointer as it is typed into is not an edit anybody asked for.Cell editing
JSX
<DataGrid
data={people}
def={{
rowKey: 'id',
// Both the validation and the notification. Nothing accepts, a string refuses.
onCellEdit: ({ columnKey, value }) => {
if (columnKey === 'first_name' && !String(value).trim()) return 'A name is required';
if (columnKey === 'salary' && Number(value) < 0) return 'A salary cannot be negative';
},
columns: [
{ key: 'first_name', header: 'First name', editable: true },
// No `editor`: the value is a number, so the field is a numeric one.
{ key: 'salary', header: 'Salary', editable: true, align: 'end' },
{ key: 'country', header: 'Country', editable: true, editor: { type: 'select', options: countryOptions } },
],
}}
/>A block of cells, and Ctrl+C
Every grid marks the cell its arrows carry on from — whether the pointer or the keyboard put it there, and it stays marked once the grid loses focus, because otherwise a copy has nothing to act on.
Ctrl+C on it copies that cell. def.rangeSelection is the rest: drag across cells, or hold Shift with the arrow keys, and the block that is marked is what Ctrl+C writes — tab-separated, which is what Excel, Sheets and Numbers paste as columns.The mark is the model's, not a focus ring
A
:focus-visible ring never matches a pointer, so a clicked cell drew nothing — and any ring made of focus goes out the moment the grid loses it. The current cell is state here instead: it survives a press, a blur and a scroll, and it is the corner a Shift+arrow grows the block from.A block is selected; one cell is a cursor
The cells in a block report
aria-selected and the grid is aria-multiselectable. A lone current cell reports neither: it has chosen nothing, and saying "selected" on every arrow key is an announcement about nothing. The block is tinted around the current cell, which keeps its ring — the way a sheet says where typing would land.A copied figure is the one a file would carry
The clipboard is an export: a column's
exportValue is used where it has one, an accepted edit wins over the row, and a group row copies its own value in the column that spans it. Ctrl+C rides the browser's own copy event, so text you have selected on the page still belongs to the page.Marking cells is a drag, so text selection is not
A drag can mark cells or select text, never both, so a grid with
rangeSelection on gives text selection up — an open editor hands it back for the value being typed. A touch is left alone entirely: that press is how the grid scrolls.Range selection and copy
JSX
<DataGrid
data={people}
def={{
rowKey: 'id',
// Drag, or Shift with the arrow keys. Ctrl+C copies the block as TSV.
rangeSelection: true,
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'country', header: 'Country' },
{ key: 'age', header: 'Age', align: 'end' },
{ key: 'salary', header: 'Salary', align: 'end' },
],
}}
onRangeChange={(range) => setSummary(range?.values())}
/>Ctrl+V, one judgement per cell
A block of cells goes back the other way too.
Ctrl+V fills from the current cell, or the block that is marked where one is, and every cell it covers goes through the same def.onCellEdit an editor would — so a paste needs no second validator, no second event and no second way of writing a value. onPaste reports the lot once.The bigger of the block and the clipboard wins, on each axis
A block bigger than the clipboard is tiled with it, the way a spreadsheet fills a column from one cell; a clipboard bigger than the block spills past it. Both stop at the edge of the grid. One rule, and the degenerate case falls out of it: a paste onto the current cell alone starts from a block of one.
A refusal skips its own cell and nothing else
A paste is many independent judgements, and stopping at the first bad value would leave the block half written with no way back. The cells that were refused wear the red ring and report
aria-invalid — there is no editor open on any of them to show a message in — and onPaste carries each one with what it was refused with.The clipboard carries no types, so the cell says how to read it
A number column parses the text and refuses what is not a number, a checkbox takes
true/false (and 1/0), and a select keeps to its own options and holds the option's value rather than its spelling. A value that did not change is not an edit at all, so pasting a column back over itself costs nothing.A paste inside an open editor belongs to the editor
That one is a value being typed, not a block being filled — so the grid hands it back rather than filling the cells under it. Everything a paste accepts lands in the same edit stream a typed value does, with a
reason of paste.Paste into a block
JSX
<DataGrid
data={people}
def={{
rowKey: 'id',
rangeSelection: true,
columns: [
{ key: 'first_name', header: 'First name', editable: true },
{ key: 'country', header: 'Country', editable: true },
{ key: 'age', header: 'Age', align: 'end', editable: true },
{ key: 'salary', header: 'Salary', align: 'end', editable: true },
],
// The same function a typed value is judged by. Answer a string to refuse the cell.
onCellEdit: ({ columnKey, value }) =>
columnKey === 'salary' && Number(value) < 0 ? 'Salary cannot be negative' : undefined,
}}
onPaste={({ applied, rejected, skipped }) => setReport({ applied, rejected, skipped })}
/>Disable Sorting and Resizing
JSX
<DataGrid
data={data}
def={{
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'last_name', header: 'Last name' },
{ key: 'age', header: 'Age', width: 100, sortable: true }, // Override: sortable
{ key: 'email', header: 'Email', width: 300, resizable: true }, // Override: resizable
{ key: 'country' },
{ key: 'city' },
],
rowHeight: 40,
visibleRowsCount: 5,
sortable: false, // Disable sorting globally
resizable: false, // Disable resizing globally
}}
/>Resizer Style
JSX
// 'hover' — resizer appears only when hovering the header cell
<DataGrid
data={data}
def={{
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'last_name', header: 'Last name' },
{ key: 'age', header: 'Age', width: 100 },
{ key: 'email', header: 'Email', width: 300 },
{ key: 'country' },
{ key: 'city' },
],
rowHeight: 40,
visibleRowsCount: 5,
resizerStyle: 'hover', // 'visible' | 'hover' | 'hidden'
}}
/>Resize Mode
JSX
// 'smooth' (default): width updates batched to one per animation frame (~60fps, ~1 frame
// behind the cursor). 'instant': width updates synchronously on every pointer move, so the
// column tracks the cursor with no added latency. Drag a column edge and toggle to compare.
<DataGrid
data={data}
def={{
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'last_name', header: 'Last name' },
{ key: 'age', header: 'Age', width: 100 },
{ key: 'email', header: 'Email', width: 300 },
{ key: 'country' },
{ key: 'city' },
],
rowHeight: 40,
visibleRowsCount: 5,
resizeMode: 'smooth', // or 'instant'
}}
/>Header Hover
JSX
// 'static' (default): each sortable header cell paints its own inset pill on hover.
// 'sliding': one pill for the whole header that flows from cell to cell, the edge it
// moves towards leading and the other catching up. Move along the headers to compare.
<DataGrid
data={data}
def={{
columns: [
{ key: 'first_name', header: 'First name' },
{ key: 'last_name', header: 'Last name' },
{ key: 'age', header: 'Age', width: 100 },
{ key: 'email', header: 'Email', width: 300 },
{ key: 'country' },
{ key: 'city' },
],
rowHeight: 40,
visibleRowsCount: 5,
headerHover: 'sliding', // or 'static'
}}
/>Localization
JSX
// Every word the grid writes is in def.localeText — any subset, the rest stays English.
// A value with a number or a name in it is a function, so a language can order its own words.
const de: Partial<DataGridLocaleText> = {
searchPlaceholder: 'Suchen...',
filterPlaceholder: 'Filtern...',
clearFilters: 'Filter zurücksetzen',
rowCount: (filtered, total) => (filtered === total ? `${total} Zeilen` : `${filtered} von ${total} Zeilen`),
selectedCount: (selected) => `Ausgewählt: ${selected}`,
selectAllRows: 'Alle Zeilen auswählen',
selectRow: (row) => `Zeile ${row} auswählen`,
sortAscending: 'Aufsteigend sortieren',
sortDescending: 'Absteigend sortieren',
columnOptions: (column) => `Spaltenoptionen für ${column}`,
// …and the rest of DataGridLocaleText
};
<DataGrid data={data} def={{ columns, globalFilter: true, topBar: true, bottomBar: true, rowSelection: true, localeText: de }} />DataGrid props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
component | keyof ComponentsAndVariants | — | Component name for style resolution. Default: 'datagrid'. Set to a custom name to use a different component style tree. |
data | TRow[] | — | The rows. Virtualization means the length is not what is rendered — ten thousand is fine. Optional only because def.dataSource replaces it: with one set, this is not read at all. |
defrequired | GridDefinition<TRow> | — | Everything about the grid that is not the rows: the columns, the bars, the title, the row height. |
loading | boolean | — | Show the loading sweep over the rows. The grid keeps whatever it is already showing underneath. |
onSelectedRowKeysChange | ChangeHandler<Key[], DataGridSelectionReason> | — | Fires with every selected row key and why the selection changed. |
onSelectionChange | (event: SelectionChangeEvent<TRow>) => void | — | The older callback, whose event carries the keys *acted on* beside the selection. Both fire. |
globalFilterValue | string | — | Controlled global filter value |
onGlobalFilterChange | ChangeHandler<string, DataGridFilterReason> | — | Fires with the global filter's new text, and whether it was typed or emptied. |
columnFilters | ColumnFilters<TRow> | — | Controlled column filters |
onColumnFiltersChange | ChangeHandler<ColumnFilters<TRow>, DataGridFilterReason> | — | Fires with every column filter still set, and whether one was set or cleared. |
filters | ((row: TRow) => boolean)[] | — | External predicate filters applied before global/column filters. Memoize with useMemo for performance. |
expandedRowKeys | Key[] | — | Controlled expanded detail row keys |
onExpandedRowKeysChange | ChangeHandler<Key[], DataGridExpandReason> | — | Fires with every expanded detail row key, and whether one was opened or shut. |
expandedTreeKeys | Key[] | — | Controlled open rows of a def.treeData tree. A separate list from expandedRowKeys, which is the detail panels: a row can have both, and they open different things. |
onExpandedTreeKeysChange | ChangeHandler<Key[], DataGridExpandReason> | — | Fires with every open tree row's key, and whether one was opened or shut. |
page | number | — | Controlled current page (1-indexed). Used with pagination. |
pageSize | number | — | Controlled page size. Used with pagination and pageSizeOptions. |
onPaginationChange | ChangeHandler<DataGridPagination, DataGridChangeReason> | — | Fires with both pager values and why they changed — the page and the size move together, and a filter or a sort is what sends the grid back to the first page. |
onPageChange | (page: number, pageSize: number) => void | — | The older page callback, whose second argument is the page size rather than the reason. Both fire. |
onPageSizeChange | (pageSize: number) => void | — | The older page-size callback. Both fire. |
onCellEditsChange | ChangeHandler<CellEdit<TRow>[], DataGridEditReason> | — | Fires with every edit the grid has accepted, oldest first — the stream an undo is built out of. It is the whole list rather than the one that changed, so a host keeps no history of its own, and it empties when clearEdits() says the host has saved them. |
onRangeChange | ChangeHandler<CellRange | undefined, DataGridRangeReason> | — | Fires with the block of cells that is marked, or undefined once the grid has given it up. It reports the single current cell too, which is the degenerate range and what Ctrl+C acts on — so a status bar summing the selection is range?.values(), and a reason of 'select' is the press or the arrow key that collapsed it back to one. |
onPaste | (paste: DataGridPaste<TRow>) => void | — | Fires once with everything a Ctrl+V did: the cells that were written, the cells def.onCellEdit refused and the message each was refused with, and how many the block covered that nothing could be written to. It fires after the values have landed, so range.values() reads what the grid now shows. |
onSortingChange | ChangeHandler<DataGridSort | undefined, DataGridSortReason> | — | Fires with the column and direction the grid is sorted by, or undefined once the sort is cleared. |
onSortChange | (columnKey: Key | undefined, direction: SortDirection | undefined) => void | — | The older sort callback, whose two arguments are the column and the direction. Both fire. |
onServerStateChange | ChangeHandler<ServerState<TRow>, DataGridChangeReason> | — | Fires on any server-relevant state change with the full snapshot for an API call, and what moved. |
DataGrid keyboard
| Key | Result |
|---|---|
Tab | Enters the grid at one cell, not at every cell. Shift+Tab leaves it. |
Right / Left | One cell along the row. At either end focus stays where it is. |
Down / Up | One row, keeping the column: through a group row or a detail panel with fewer cells, and through a grouped header whose cells cover several columns each. |
Home / End | The first or last cell of the row. |
Ctrl + Home / End | The first or last cell of the whole grid, scrolling there if it has not been rendered yet. |
PageDown / PageUp | A screenful of rows at a time. |
Enter / Space | Sorts, on a sortable column header. On an editable cell, opens its editor. Anywhere else, steps into the cell's own control. |
F2 | Steps into the cell's control even on a header, where Enter is spoken for by the sort; on an editable cell it opens the editor, as Enter does. |
Escape | Hands the keyboard back from that control to the cell; from an editor it also throws the draft away. |
On a tree's own column
| Key | Result |
|---|---|
Right / Left | Opens a shut row and shuts an open one; on a row that is already shut, Left steps out to its parent. Both follow the reading order, so they swap in a right-to-left grid, and on every other column they are the ordinary move along the row. |
On an editable cell
| Key | Result |
|---|---|
Any printable character | Opens the editor on that character, replacing the value the way a spreadsheet does. |
In an editor
| Key | Result |
|---|---|
Enter | Commits and hands the keyboard back to the cell. A value def.onCellEdit refuses keeps the editor open, with the message in a role="alert" and aria-invalid on the field. |
Tab / Shift+Tab | Commits and opens the next or previous editable cell, along the row and on into the ones after it. With no next one it leaves the grid, as Tab does. |
On a column resizer
| Key | Result |
|---|---|
Tab / F2 | Reaches the resizer of the header cell focus is on. Escape hands the keyboard back to the cell. |
Right / Left | Moves the separator 16px, which widens or narrows the column exactly as dragging it would. |
Home / End | The narrowest the grid allows, or as wide as the grid itself. |
DataGrid accessibility
Implements the WAI-ARIA APG pattern.
role="grid"witharia-rowcount/aria-colcountover the whole data set andaria-rowindex/aria-colindexon what is rendered, so virtualization does not renumber the grid.- With
def.treeDatait is arole="treegrid"instead, and its rows carryaria-level,aria-posinset,aria-setsizeand — valid only inside a treegrid —aria-expanded. The rows under a row are its siblings in the same rowgroup rather than its children, so those four are all a screen reader has to go on. - One cell is the tab stop, as APG's grid pattern specifies: the grid is entered once and the arrow keys move inside it, rather than every cell being a stop of its own.
- A grid is not named by the rows in it — pass
def.titleand the grid pointsaria-labelledbyat it. Everything the grid draws for itself is named after what it acts on ("Select row 4", "Filter Country"), and a selection change is announced through arole="status"region. - Each column resizer is a
role="separator"— APG's window splitter — carrying the column's width onaria-valuenow, so the new width is read out as it changes.
Swept with axe on every release, in 8 states:
DataGrid, DataGrid (editing, with a refused value), DataGrid (paginated), DataGrid (virtualized, grouped, selectable), DataGrid (grouped, expanded), DataGrid (tree data), DataGrid (list layout, grouped, selecting), DataGrid (list layout, record view). No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.DataGrid style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.datagridvariants: listdatagrid.contentdatagrid.loaderdatagrid.loader.trackdatagrid.loader.track.bardatagrid.errordatagrid.error.messagedatagrid.error.retrydatagrid.topBardatagrid.topBar.globalFilterdatagrid.topBar.globalFilter.statsdatagrid.topBar.columnGroupsdatagrid.topBar.columnGroups.icondatagrid.topBar.columnGroups.separatordatagrid.topBar.columnGroups.itemdatagrid.topBar.columnGroups.item.icondatagrid.topBar.columnVisibilitydatagrid.topBar.columnVisibility.badgevariants: emptydatagrid.topBar.columnVisibility.paneldatagrid.topBar.columnVisibility.searchdatagrid.topBar.columnVisibility.searchIcondatagrid.topBar.columnVisibility.searchInputdatagrid.topBar.columnVisibility.toolbardatagrid.topBar.columnVisibility.countdatagrid.topBar.columnVisibility.actiondatagrid.topBar.columnVisibility.listdatagrid.topBar.columnVisibility.sectionvariants: divideddatagrid.topBar.columnVisibility.groupdatagrid.topBar.columnVisibility.itemdatagrid.topBar.columnVisibility.emptydatagrid.topBar.exportdatagrid.topBar.export.buttondatagrid.filterdatagrid.filter.rowdatagrid.filter.cellvariants: isPinned, isFirstStartPinned, isLastStartPinned, isFirstEndPinned, isLastEndPinneddatagrid.filter.cell.inputdatagrid.headerdatagrid.header.hovervariants: visible, backward, enteringdatagrid.header.cellvariants: isPinned, isFirstStartPinned, isLastStartPinned, isFirstEndPinned, isLastEndPinned, isSortable, hasHoverPill, isPillBeforeResizer, isPillAfterResizer, isRowSelection, isRowNumber, isFirstLeaf, isLastLeaf, isEmptyCelldatagrid.header.cell.contextMenudatagrid.header.cell.contextMenu.icondatagrid.header.cell.contextMenu.tooltipvariants: closeddatagrid.header.cell.contextMenu.tooltip.itemdatagrid.header.cell.contextMenu.tooltip.item.icondatagrid.header.cell.contextMenu.tooltip.item.separatordatagrid.header.cell.resizerdatagrid.bodydatagrid.body.cellvariants: isPinned, isFirstStartPinned, isLastStartPinned, isFirstEndPinned, isLastEndPinned, isInRange, isCurrentCell, isEditing, isInvalid, isRowNumber, isRowSelection, isFirstLeaf, isLastLeaf, isEmptyCell, isRowDetail, isExpanded, isExpandedFirstLeaf, isExpandedLastLeafdatagrid.body.cell.textdatagrid.body.cell.editorvariants: isPendingdatagrid.body.cell.errordatagrid.body.cell.placeholderdatagrid.body.cell.placeholder.bardatagrid.body.cell.treedatagrid.body.cell.tree.togglevariants: isExpandeddatagrid.body.cell.tree.spacerdatagrid.body.cell.rowDetailvariants: isExpandeddatagrid.body.detailRowdatagrid.body.detailRow.contentdatagrid.body.rowdatagrid.body.groupRowdatagrid.body.groupRow.expandButtondatagrid.body.groupRow.aggregatedatagrid.body.emptydatagrid.footerdatagrid.footer.celldatagrid.footer.labeldatagrid.emptyColumnsdatagrid.bottomBardatagrid.bottomBar.infodatagrid.bottomBar.clearFiltersdatagrid.bottomBar.selectiondatagrid.bottomBar.actionvariants: presseddatagrid.bottomBar.paginationdatagrid.bottomBar.pagination.buttondatagrid.bottomBar.pagination.infodatagrid.bottomBar.pagination.pageSizedatagrid.listdatagrid.list.toolbardatagrid.list.toolbar.countdatagrid.list.toolbar.searchdatagrid.list.toolbar.buttonvariants: activedatagrid.list.toolbar.chipdatagrid.list.toggledatagrid.list.detaildatagrid.list.buttondatagrid.list.selectionBardatagrid.list.sheetdatagrid.list.sheet.linedatagrid.list.sheet.inputdatagrid.list.sheet.chipdatagrid.list.sheet.buttondatagrid.list.sheet.primary