Skip to content

Data grid component, and table demos for more React Aria features - #221

Open
deebov wants to merge 17 commits into
v9from
table-react-aria-demos
Open

deebov wants to merge 17 commits into
v9from
table-react-aria-demos

Conversation

@deebov

@deebov deebov commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Adds a data grid component modelled on the MUI X Data Grid, built on our Table, and table demos for React Aria features we didn't use yet. Table gains the pieces both need, including virtualization.

Table

New parts and options, all opt-in. The 26 existing table stories are pixel-identical before and after.

  • Table.ResizableContainer and allowsResizing on Table.Head. Dragging the divider resizes the column in the DOM and gives React Aria the new width once, on release; React Aria's own resizer re-rendered every row on each pointer move. Its resizer still handles keyboard resizing.
  • Table.Virtualizer: renders only the rows and columns in view, with stickyColumns and stickyFooter. It wraps React Aria's Virtualizer with a TableLayout subclass. Virtualized tables look the same as full ones, down to the narrower gap after the checkbox column.
  • Table.DropIndicator and a drag handle column for drag and drop, Table.LoadMoreItem for infinite scroll, Table.Footer, and wrapperClassName for a fixed-height scroll container.
  • selectionColumnProps and selectionCellProps style the checkbox column; align, contentTrailing and isResizerFocusable on Table.Head; ref on Table.Head and Table.ResizableContainer; dependencies reach the column collections.
  • Two fixes that help every table:
    • TableContext had a new value on every render, which re-rendered every row and cell React Aria builds behind the scenes.
    • Parts no longer rebuild their class names on every React Aria render.
  • New demos: resizable columns, column visibility, column header menu, expandable rows, drag and drop, infinite scroll, bulk actions, row actions (opens a slideout), disabled rows, editable cells, footer row, and a live table that inserts rows as they arrive.
  • The new parts sit after Table.Cell in table.tsx, so the main parts stay at the top.

Data grid

components/application/data-grid/. It copies MUI's features, not its look.

  • The grid is a TableCard, and its header, rows and cells are Table.Head, Table.Row and Table.Cell, with Table's sizes, so it looks like Table.
  • The rest uses our components:
    • The toolbar is laid out like the search and filters bar of Table's examples, and its menus and panels use Dropdown.
    • The column menu button is the chevron from Table's column menu example.
    • The footer is laid out like PaginationCardMinimal.
    • The loading and empty states use LoadingIndicator and EmptyState.

It adds the data layer on top: columns described as data (field, type, valueGetter, valueFormatter, renderCell, editable, …), and:

  • Sorting: click a header to sort ascending, then descending, then off.
  • Filtering: a filter panel with operators per column type (text, number, date, boolean, single select) and AND/OR logic, plus a quick filter in the toolbar.
  • Column menu: sort, pin left or right, filter, aggregate, group by, hide, manage columns and resize.
  • Columns: resizable, hideable and pinnable columns, a sticky header, and a columns panel.
  • Editing: double-click a cell, or press Enter, F2 or start typing. The editor fills the cell. onRowUpdate saves the row, or reverts it by throwing.
  • Row grouping and aggregation: sum, average, min, max and size, on group rows and in a sticky totals row. initialState.expandedGroups expands groups when the grid mounts.
  • Selection: checkbox selection, including select all across pages, and disableRowSelectionOnClick.
  • Pagination with a page size select, and the number of selected rows.
  • Densities: compact and standard, Table's sm and md sizes.
  • Loading and empty states: a loading indicator in place of the rows, and an empty state with a button that clears the filters.
  • Toolbar: columns, filters, density, export (CSV or print, only the selected rows when there are any) and quick search.
  • Virtualization: virtualized renders only the rows and columns in view, so pagination={false} can scroll through thousands of rows.
  • Keyboard: follows MUI. Arrow keys move between cells and headers, Enter on a header sorts, and Ctrl/Cmd+Enter or Alt+↓ opens the column menu.

The demos reproduce MUI's examples with a seeded commodity dataset (commodity-data.ts, commodity-columns.tsx). More demos show cell editing (with an onRowUpdate that rejects an empty name), live data (prices that update every second), and virtualization, one of them with 10,000 rows.

Not reproduced from MUI

Multi-column sorting, column reordering, clipboard copy and paste, Excel export, pivoting, tree data, master-detail rows, row pinning and header filters.

Performance

Production build, input to next paint, median ms (click / select / edit / sort). Measured before the grid took Table's sizes. Its rows are taller now, so fewer of them render when virtualized:

Not virtualized virtualized
25 rows per page 72 / 64–80 / 40–48 / 144–160 32 / 32–40 / 16–24 / 112
100 rows per page 176 / 184–192 / 112 / 464 24–32 / 40–48 / 16–24 / 80–104
10,000 rows, no pages — 48 / 120–150 / 24–32 / 370–440

Without virtualization, React Aria re-renders every cell on the page when the focus or the selection changes, so the cost grows with the page size. Virtualized, about 200 cells are in the DOM at any page size. Scrolling holds 60fps in both modes.

Known limits of virtualization

  • React Aria still builds every row in memory, so loading grows with the row count: 10,000 rows take about 1.4 s of script. Larger datasets need pages or server-side data.
  • On the server and while it hydrates, a virtualized grid only gets its first 50 rows. With all 10,000, the docs page took minutes to render on the server. The other rows follow right after hydration.
  • React Aria's layout only sticks columns to the left. Right-pinned columns and the sticky totals row override its inline positions with CSS, and the layout subclass relies on protected TableLayout methods, so a React Aria upgrade could break them.
  • Dragging a divider updates React Aria's state once per frame (about 10 ms) instead of only on release.
  • Cell boxes are reused while scrolling, so state inside a cell's content can show up in another row.

Verification

  • bun run test (tsc, ESLint, Prettier) passes.
  • A Playwright suite covering each feature above passes against Storybook. It also passes virtualized, apart from DOM row counts and columns that aren't rendered yet. After the switch to Table's look, its results changed only where the look did, such as the page text and the row heights.
  • The 26 table stories are pixel-identical to before the Table changes, and resizing works by drag and keyboard.
  • The demo snippets shown in the docs type-check and lint when copied into a project.

Breaking changes

None. Every Table change is additive.

Website PR with the docs page and the registry: a-peak-works/untitledui-react#363

deebov added 17 commits October 8, 2026 15:05
…s for more React Aria features

- Table.ResizableContainer and `allowsResizing` on Table.Head, with a wide hit area on the divider
- Table.DropIndicator for drag and drop, and a drag handle column
- Table.LoadMoreItem for infinite scroll, and `wrapperClassName` for a fixed-height scroll container
- New demos: resizable columns, column visibility, column header menu, expandable rows,
  drag and drop, infinite scroll, bulk actions, row actions, disabled rows, editable cells,
  footer row and a live table that inserts rows as they arrive
Built on React Aria's table. Columns are described as data (`field`, `type`, `valueGetter`,
`valueFormatter`, `renderCell`, `editable`, ...), and the grid handles the rest:

- sorting, a filter panel with operators per column type, and a quick filter
- column menu: sort, pin left or right, filter, aggregate, group by, hide, manage and resize columns
- resizable, hideable and pinnable columns, with sticky headers
- cell editing (double-click, Enter, F2 or typing), with `onRowUpdate` to save or revert
- row grouping and aggregation (sum, average, min, max, size) with a totals row
- checkbox selection across pages, pagination, three densities and a loading state
- toolbar with columns, filters, density, CSV export, print and quick search
- keyboard support following MUI: Enter sorts a header, Ctrl+Enter opens its menu

Demos reproduce the MUI examples with a seeded commodity dataset.
…ogress bars with ProgressBar

React Aria keeps column widths in the table's state, so its resizer re-rendered every row and cell on each pointer
move. Dragging a divider now sets the width on the header directly and hands it to React Aria once, on release.
React Aria's resizer still handles keyboard resizing from the column menu.

Pinned columns read their offsets from CSS variables that a ResizeObserver updates, so measuring them while a
column resizes no longer re-renders the grid either. The dividers use logical positions, so they sit on the right
side in RTL.

The demo's "Filled quantity" column uses our ProgressBar instead of its own colored bars.
An arbitrary ease-[...] class also emits an animation-timing-function rule from the animate plugin, which the
inlined plugins of the v0 previews can't reproduce. The transition reads --tw-ease, so the curve is unchanged.
Moves createFilterItem to data-grid-utils and stops exporting densityOptions, so the toolbar file passes the
react/only-export-components rule that Vite projects enable.
Table gains generic hooks the grid needs, and the grid's header, rows and cells become Table.Head, Table.Row and
Table.Cell: static class names, selectionColumnProps/selectionCellProps, forwarded dependencies, align and
contentTrailing on Table.Head, isResizerFocusable, refs on Table.Head and Table.ResizableContainer, and the
DOM-based column resizer (for every resizable table).
Table.Virtualizer renders only the rows and columns in view, with React Aria's Virtualizer and a TableLayout
subclass that adds sticky columns (left, and right with the grid's CSS) and an optional sticky footer. Table's
parts get div styles when virtualized. The grid takes a virtualized prop; the 10,000-row demo scrolls without
pages.
… Aria

TableContext got a new value on every render, which re-rendered every Table.Row and Table.Cell in React Aria's
collection: with 10,000 rows, selecting one took seconds. The grid also stops controlling expandedKeys, which made
React Aria copy the whole collection on every render; group ids include the field instead, so regrouping starts
collapsed.
The new parts (Table.ResizableContainer, Table.Footer, Table.DropIndicator and Table.LoadMoreItem) move after
Table.Cell, and the compound object lists the original parts first. Code is unchanged.
Resizing, the footer, drop indicators, load-more rows and virtualization move after Table.Cell. Code is unchanged.
The data grid's header, rows and cells become Table.Head, Table.Row and Table.Cell, Table gains the hooks the grid
needs and Table.Virtualizer, and the grid takes a virtualized prop.

# Conflicts:
#	components/application/table/table.tsx
With 9 rows and 5 per page, the second page had 4 rows and the grid got shorter.
The grid tinted selected rows with the brand color, copied from MUI. Selected rows and pinned cells now use Table's bg-secondary, and rows keep Table's transition.
The features stay. Their look now comes from Table and our components:

- Rows, headers, cells and the checkbox column use Table's sizes. The densities are Table's sm and md sizes (compact and standard).
- The grid is a TableCard.Root. Headers use Table.Head's label, without the aggregation function under it or the filter icon next to it.
- The column menu button is the chevron from Table's column menu example, always shown, after the sort indicator.
- The toolbar is laid out like the search and filters bar of Table's examples, and its panels use Dropdown.Popover.
- The footer is laid out like PaginationCardMinimal, with a badge for the selected rows.
- Loading shows LoadingIndicator, and an empty grid shows EmptyState, with a button that clears the filters.
- The commodity columns are wider, so their headers fit with Table's padding.

Table: contentTrailing sits right after the sort indicator, and a virtualized table pulls the first column after the checkboxes closer, like a full table does.
… expanded

- initialState.expandedGroups expands groups of the initial grouping by their label. React Aria keeps the expansion, so it applies on mount.
- The row grouping demo is taller, and its first group starts expanded. The 10,000-row demo is taller too.
- New editing demo: every editor type, and an onRowUpdate that rejects an empty name, which reverts the edit.
- New live data demo: prices update every second, with a pause button, like the table's live demo. Only the changed rows get new objects.
The docs snippet of the demo then passes Prettier when it's copied into a project.
React Aria builds a node for every row and cell it gets, also on the server. A virtualized grid with 10,000 rows took minutes to render the docs page on the server. It now gets its first 50 rows on the server and while it hydrates, and every row right after.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant