Data List
A typed data table, card grid or board — selection, sortable columns, a standard row-actions column, loading, and empty and no-results states.
- Status
- Since
0.1.0- Accessibility pattern
- native table semantics
Last updated
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 | |
| Dax Shepard | dax@vega.dev | Engineer | $760 | |
| Eve Polastri | eve@vega.dev | Analyst | $1,530 |
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Data List before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify data-listInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/data-listRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/data-table-parts, @vegastack/load-more, @vegastack/table, @vegastack/truncated-text, @vegastack/button, @vegastack/empty, @vegastack/board, @vegastack/media-card, @vegastack/board-card, @vegastack/skeleton, @vegastack/thumbnail, @vegastack/use-drag-reorder, @vegastack/checkbox, @vegastack/action-bar, @vegastack/list-toolbar, @vegastack/display-menu.
Usage
Default grid cards also support callback-only onRowClick activation with Enter and Space. Nested selection and action controls keep their own behavior.
import { DataList, type DataListColumn } from "@/components/ui/data-list";
interface User {
id: string;
name: string;
role: string;
}
const columns: DataListColumn<User>[] = [
{ key: "name", header: "Name", sortable: true },
{ key: "role", header: "Role" },
];
<DataList columns={columns} data={users} getRowId={(u) => u.id} />;DataList<T> is generic over the row type, so render, getRowId, and the selection/sort callbacks
are all fully typed against your data.
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 | |
| Dax Shepard | dax@vega.dev | Engineer | $760 | |
| Eve Polastri | eve@vega.dev | Analyst | $1,530 |
In a ListToolbar. Pass a ListToolbar as toolbar and
DataList fills it: views with onViewChange become its Layout tool (icon buttons), and
onDensityChange becomes Display's Row height (a DisplayMenu is added when the toolbar has none). A toolbar that sets its own layout or row height keeps them. An empty list keeps
its toolbar but drops the column header; noResults takes onEditFilters for an "Edit filters"
button beside "Clear filters". While rows are selected the list keeps a spacer the bulk bar's height,
so the last row is never covered.
Anatomy
DataList is a single generic component composed on top of the design-system primitives — it renders a
Table using the shared @vegastack/data-table-parts
(a shared internal, installed with this component):
the sort header, the selection cells, the skeleton rows, the empty row and the column class rules
it has in common with DataGrid.
<DataList<T>
columns={[
{ key, header, render?, sortable?, align?, mono?, numeric?, nowrap?, minWidth?, mobile?, className?, headerClassName?, interactive? },
]}
data={rows}
getRowId={(row, index) => string}
selectable
selectedIds={selected} // controlled selection (Set<string>)
onSelectionChange={setSelected}
sort={sort} // controlled sort ({ key, direction } | null)
onSortChange={setSort}
loading={false}
empty={{ title: "No tasks yet" }}
/>;- Root — one
<div data-slot="data-list-root">stack in every configuration, holding the optional toolbar, the table, the status lines and the optional footer, so nothingDataListadds lands as a stray child of your own layout.ref,classNameand every<table>attribute go to the<table data-slot="data-list">inside it, which sits in the shared scroll viewport. - Selection column — when
selectable, a leading checkbox column with a tri-state select-all in the header (data-slot="checkbox"); each body row carriesdata-selectedwhen picked. - Sortable header — sortable columns render a ghost
Button(data-slot="data-table-sort") inside<th data-slot="data-list-head">, which carriesaria-sorton every sortable column ("none"included) anddata-sorted="asc|desc"; clicking cycles asc → desc → cleared. Its label wears the plain header's owntext-sm font-mediumintext-foreground, so sortable and static headers read as one row. - Body — one
<tr data-slot="data-list-row">per row; cells usecolumn.renderor fall back torow[column.key], and wrap by default (see Wrapping below). A selected row keeps a lightbg-muted/50wash through hover and press — light enough that asecondaryBadge inside it stays visible (the fullaccentis the badge's own fill). - Loading — skeleton rows (
data-slot="data-list-skeleton-row") replace the body whileloading; the table getsaria-busyand a live status while placeholder rows stayaria-hidden. - Empty — a single full-width row (
data-slot="data-list-empty-row") hosting the empty state (empty, or youremptyState). - Merged stack — when the container is too narrow, overflow columns render inside the first
cell as
<span data-slot="data-list-merged">, the sorted one markeddata-sorted. - Status lines — under the table, in
<div data-slot="data-list-status">: a column dropped withmobile: "hidden"is counted in<p data-slot="data-list-hidden-hint">, and a sort whose column is merged or hidden is stated in<p data-slot="data-list-sort-hint">(see Fitting the width).
Examples
Selection
Pass selectable to add the checkbox column. Selection is controllable — lift it with
selectedIds + onSelectionChange, or omit both for built-in uncontrolled state. The header checkbox is
indeterminate when only some rows are selected, checked when all are, and toggles every row on click.
| Status | |||||
|---|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | ||
| Bea Arthur | bea@vega.dev | Designer | $940 | ||
| Cole Train | cole@vega.dev | Manager | $2,150 | ||
| Dax Shepard | dax@vega.dev | Engineer | $760 | ||
| Eve Polastri | eve@vega.dev | Analyst | $1,530 |
Range and keyboard selection
Shift-click a checkbox to select — or clear — everything between it and the last checkbox toggled, in the order the rows read (a collapsed section's rows are stepped over). With focus anywhere in the list, ⌘/Ctrl+A selects every row, Esc clears the selection, and Space on a focused row link toggles that row. None of them fire from a text field (a toolbar search keeps its own select-all) or from a row's ⋯ menu, and a
⌘/Ctrl-click on a row still opens its link in a new tab.
| Name | Size | |
|---|---|---|
| Clients | — | |
| Projects | — | |
| Brief.pdf | 2.4 MB | |
| Kickoff notes.md | 12 KB | |
| Budget.xlsx | 88 KB | |
| Logo.svg | 6 KB |
Selection bar
selectionActions docks an ActionBar at the bottom of the screen, centred over the list, while
anything is selected: the count (selectionLabel), your actions — compose ActionBarButton — then any
selectionSecondaryActions, and last a "Clear selection" button. Your render gets the selected ids and a clear() to call after a bulk
action. On a phone the bar is capped at the screen width and its actions scroll sideways inside it.
While the bar is open the list ends with a spacer of the bar's height, its gap and
--dock-inset-bottom (a global audio player under it), so the last row and the loading tail stay
reachable above both.
| Name | Size | |
|---|---|---|
| Clients | — | |
| Projects | — | |
| Brief.pdf | 2.4 MB | |
| Kickoff notes.md | 12 KB | |
| Budget.xlsx | 88 KB | |
| Logo.svg | 6 KB |
Grid selection
The grid view carries the same checkbox on each card, above the card's link: shown on hover or focus, always on a touch screen, and on every card once anything is selected. A selected card keeps the list's selected wash. Ranges and the keyboard work the same way.
Drag into folders
Set onDropInto and rows (list) or cards (grid) can be dragged onto drop targets: rows
canDropOnRow accepts (folders), and any BreadcrumbDropTarget naming the same dragScope.
Dragging a selected row carries the whole selection, and its drag image reads "3 items"
(dragItemsLabel). canDropInto refuses a move — a folder into itself or its own descendant — and a
refused target shows data-drop-invalid in the destructive tint; an accepted one washes in the
primary tint (data-drop-over). A list row takes the drop in its middle half only — its top and
bottom quarters never nest, so a drag aimed between two rows cannot land inside one — while a grid
card takes it anywhere. onDropInto receives { ids, targetId } and may return a promise.
Drag is pointer only: keep a "Move…" row action as the keyboard and touch path.
canMoveItem(row) locks single rows — a view-only record (here "Budget.xlsx"). A row it answers
false for is no drag source and carries data-move-locked; a dragged selection leaves it behind,
and it still opens and can still take drops. On the board view it is the Board's own
canMoveItem: a locked card that neither drags nor lifts with Space.
Files dragged in from the desktop drop onto the same folder rows and cards with
onDropFilesOnRow(rowId, entries): the row washes as for a move, dropped folders keep their
structure in each entry's relativePath, and an outer useFileDrop surface (the canvas's own
upload drop) leaves that drop alone — the innermost target wins. onFilesOverRow reports the row
the files are over, and null when they leave, for naming the folder in your drop hint. Neither
needs onDropInto.
| Name | Size | |
|---|---|---|
| Clients | — | |
| Projects | — | |
| Brief.pdf | 2.4 MB | |
| Kickoff notes.md | 12 KB | |
| Budget.xlsx | 88 KB | |
| Logo.svg | 6 KB |
Drag a file onto a folder or onto Library.
Reorder by dragging
A hand-arranged order: set onReorderRow and list rows drag to a place between others. A drop on a
row's top or bottom quarter lands the dragged rows before or after it — its top or bottom half when
the row takes no drop inside — and a 2px seam in the drop-indicator blue (data-drop-edge="top" or
"bottom") marks the place while the pointer is there. A folder row canDropOnRow accepts keeps
its middle half for onDropInto, so one drag can either place or file. canReorderRow refuses a
place — folders stay before files here — and a refused edge shows no seam. onReorderRow receives
{ ids, targetId, edge: "before" | "after" } and may return a promise. It is the list view's; a
grid or board doesn't offer it. Drag is pointer only: keep Move up and Move down row actions as the
keyboard and touch path.
| Name | Size | Actions | |
|---|---|---|---|
| Clients | — | ||
| Projects | — | ||
| Brief.pdf | 2.4 MB | ||
| Kickoff notes.md | 12 KB | ||
| Budget.xlsx | 88 KB | ||
| Logo.svg | 6 KB |
Drag a row by its top or bottom edge to place it.
Sorting
Mark any column sortable to make its header interactive; it carries aria-sort,
data-sorted, and a button named Sort by <column>. onSortChange fires
with the next { key, direction } (or null when cleared). The click cycle is the column's
sortFirst (default "asc"; use "desc" for dates and amounts) → the other → none.
- Manual (default,
sortMode="manual"): you re-orderdata— the right choice for server-side sorting. - Client (
sortMode="client", or any column with its owncompare):DataListordersdataitself with the column'scompare(a, b)(ascending; descending is its reverse), else a default comparator overrow[key](numbers, dates, then numeric-aware strings, empties last). - Read-only (
sortwithoutonSortChange): the sort is owned elsewhere, such as a toolbar's Sort control. No header is a button; the sorted column shows a read-only ↑/↓ (data-slot="data-table-sort-indicator") and carriesaria-sort.
<DataList
sortMode="client"
columns={[
{ key: "name", header: "Name", sortable: true },
{
key: "due",
header: "Due",
sortable: true,
sortFirst: "desc",
compare: (a, b) => a.due.getTime() - b.due.getTime(),
},
{
key: "priority",
header: "Priority",
sortable: true,
compare: (a, b) => RANK[a.priority] - RANK[b.priority],
}, // a custom order
]}
data={rows}
/>Manual, controlled:
const [sort, setSort] = React.useState<SortState | null>(null);
<DataList
columns={columns}
data={sortRows(rows, sort)} // your comparator
getRowId={(r) => r.id}
sort={sort}
onSortChange={setSort}
/>;The indicator follows the standard sort-header pattern:
- Active column — its ↑ (ascending) or ↓ (descending) always shows, at foreground strength.
- Other sortable columns — the ⇅ is rendered but transparent, so the label never shifts; it
fades in muted (
text-muted-foreground) while the pointer hovers the header or the sort button has keyboard focus. Opacity only, no movement, and no fade under reduced motion. - Touch — a coarse pointer never hovers, so only the active arrow shows; tapping a sortable header still sorts.
- Unsortable columns — no icon and no hover affordance.
Active ascending (Name, top) and active descending (Amount, bottom). Hover or tab to another sortable header — Role, say — to see the inactive indicator; Email and Status are not sortable:
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 |
| Status | ||||
|---|---|---|---|---|
| Cole Train | cole@vega.dev | Manager | $2,150 | |
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 |
Client sorting, live — click a header to cycle it:
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 | |
| Dax Shepard | dax@vega.dev | Engineer | $760 | |
| Eve Polastri | eve@vega.dev | Analyst | $1,530 |
Row activation
Pass onRowClick to make rows activatable — for navigating to a detail page, opening a drawer, and
so on. DataList stays presentational: it only signals the activation; the host decides what it does.
A press anywhere on a grid card that is not one of its own controls (its checkbox, a menu, an
interactive column) activates it.
<DataList
columns={columns}
data={rows}
getRowId={(r) => r.id}
onRowClick={(row) => router.push(`/people/${row.id}`)}
/>| Status | ||||
|---|---|---|---|---|
| ada@vega.dev | Engineer | $1,280 | ||
| bea@vega.dev | Designer | $940 | ||
| cole@vega.dev | Manager | $2,150 | ||
| dax@vega.dev | Engineer | $760 | ||
| eve@vega.dev | Analyst | $1,530 |
Click a row, or Tab to its first cell and press Enter.
Opening a record in place. onRowOpen(rowId, { event, index }) is the row-open contract a
peek beside the list builds on, and wins over onRowClick. The whole row opens it — a plain click
anywhere on it, or Enter on its first cell, in the list, the grid and the board. With getRowHref
the row stays a real link: ⌘/Ctrl/Shift and middle clicks still open the full page where the
browser would, and only a plain click (or Enter) is taken over. activeRowId marks the open row
(data-peek-active, the accent wash, scrolled into view as it changes), and
actionsRef.current.getRowOrder() returns the row ids in the order they read on screen — sections
in order, a collapsed section's rows left out — so the peek's ↑/↓ walk the rows as shown.
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 | |
| Dax Shepard | dax@vega.dev | Engineer | $760 | |
| Eve Polastri | eve@vega.dev | Analyst | $1,530 |
Click a row to open it here; ⌘-click opens its link in a new tab.
Activation accessibility
Activatable rows are built to preserve native table semantics. The <tr> is not given
role="button" / tabIndex — overriding the implicit role="row" would make its <td>/<th>
children invalid cells for assistive tech and break row/column navigation. Instead:
- The
<tr>keepsrole="row"and gets a mouse-onlyonClick(a<tr>may carryonClickwith no role — that's valid) plusdata-clickablefor styling. - A real
<button data-slot="data-list-row-action">is injected into the first cell as the keyboard / screen-reader activation control — it is focusable and fires on Enter / Space, with the global 2px:focus-visibleoutline, all inside a valid<td>. - Clicks on the selection checkbox, the injected button, or any nested control are excluded from the
row's
onClick, so there's no double-activation.
If the first column already renders its own interactive control (a link or button), set
column.interactive on it so DataList skips the injected button (it never nests one interactive
element inside another) — keyboard activation then comes from your in-cell control.
const columns: DataListColumn<Person>[] = [
{
key: "name",
header: "Name",
interactive: true, // first column owns its focusable control → skip injected button
render: (p) => <a href={`/people/${p.id}`}>{p.name}</a>,
},
// …
];
<DataList
columns={columns}
data={rows}
getRowId={(p) => p.id}
onRowClick={open}
/>;| Name | Role | |
|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer |
| Bea Arthur | bea@vega.dev | Designer |
| Cole Train | cole@vega.dev | Manager |
| Dax Shepard | dax@vega.dev | Engineer |
Tab to the name link to activate by keyboard; click elsewhere on the row for mouse.
Density
density sets the list's row height on one scale shared with DataGrid: comfortable rows are at
least 40px, compact rows 32px. onDensityChange adds Row height to a ListToolbar toolbar's Display menu.
Compact is one line per row. Merged values join the primary value's line instead of stacking
under it (the line layout, whatever mergedLayout says), a plain primary value clamps to one
line, and a thumbnail steps down to 24px. A custom render reads the density from its third
argument (context.density) or, deeper down, useDataListDensity(); wrap a cell's second line in
DataListSecondary and compact leaves it out.
render: (task) => (
<div className="flex min-w-0 flex-col">
<DataListSecondary className="text-xs text-muted-foreground">
#{task.number} · {task.space}
</DataListSecondary>
<span className="truncate">{task.title}</span>
</div>
);| Name | Status | |||
|---|---|---|---|---|
ada@vega.devAda Lovelace | ada@vega.dev | Engineer | $1,280 | |
bea@vega.devBea Arthur | bea@vega.dev | Designer | $940 | |
cole@vega.devCole Train | cole@vega.dev | Manager | $2,150 | |
dax@vega.devDax Shepard | dax@vega.dev | Engineer | $760 | |
eve@vega.devEve Polastri | eve@vega.dev | Analyst | $1,530 |
Loading & Empty states
loading swaps the body for loadingRows skeleton rows (default 5). When data is empty and not loading,
DataList renders one of two standard Empty states — never hand-roll empty markup:
empty— there is nothing at all yet (no filters applied). The built-inEmptyshows an Inbox icon and "Nothing here" in the table, the grid and every board lane. Pass only the copy the page needs —{ icon, title, description, action }— for "No tasks yet" and a create action; a section's ownemptywins for its lane.emptyStatereplaces the whole state with your own node (preferempty).DataListEmptyStateis the same state outside a DataList.noResults— the list is empty because of a search or filter.trueshows the standard one: a SearchX icon, "No matches", a short description; pass{ onClear }to add a "Clear filters" button wired to theFilterRow'sonClear(title,descriptionandclearLabelrelabel it).NoResultsEmptyis the same state for a Board or a card grid.
<DataList
columns={columns}
data={rows}
noResults={hasQueryOrFilters && { onClear: clearAll }}
empty={{ icon: <ListTodo />, title: "No tasks yet" }}
/>| Status |
|---|
| Status | ||||
|---|---|---|---|---|
The noResults state, from a search with a Clear filters action:
| Status | ||||
|---|---|---|---|---|
Pass emptyState to fully replace the built-in node — for a filtered "no results" message with its own
icon and actions, for example. The list's own empty state is the only empty state inside a list:
pass empty (or emptyState) rather than putting a bordered Empty in or above the table, and give
it one call to action. The empty row keeps an 8px inset (p-2), so a custom node never touches the
header row above it.
| Status | ||||
|---|---|---|---|---|
Infinite loading (keyset)
A list fetched by cursor has no total and no page numbers. Pass loadMore and DataList loads the
next cursor page as the list nears its scroll container's edge, through the shared
LoadMore tail below the table (above footer): skeleton rows the
height of real ones are appended to the table while the page loads, a failed page shows an error
line with Try again, and — only when you pass endLabel — a caption once hasMore is false.
There is no button. While hasMore is true
the table reports aria-rowcount="-1", because the total is unknown.
loadMore.loading is a next-batch fetch; the list-level loading prop is the first-load skeleton.
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 |
<DataList
columns={columns}
data={rows}
getRowId={(row) => row.id}
loadMore={{
hasMore: nextCursor != null,
loading: isFetchingNextPage,
error: fetchError ? "Couldn't load more people." : undefined,
onLoadMore: fetchNextPage,
}}
/>Row actions
The standard: pass rowActions={(row) => RowAction[]} (with getRowLabel) and DataList adds the
actions column itself — always the last column, with a 32px ghost ⋯ trigger per row
(rowActionsLabel renames it). rowActionsColumn({ getRowLabel, actions }) is the same column for a
host that assembles columns itself. Either way the column is one ⋯ menu per row, named
for the row (Actions for Ada Lovelace). It is end-aligned, stays visible on narrow widths, and is
interactive, so opening the menu never activates the row. Each menu mounts on demand — a row holds
one plain button until its ⋯ is pointed at or pressed — so a long list pays for no menus up front. Each RowAction runs onSelect, or
renders as a real link through render={<a href="…" />}; destructive paints it in the destructive
ink; disabled keeps it in the menu — still reachable by the arrow keys — and disabledReason is
a tooltip on the item and its accessible description (never wrapped text in the menu). The menu is
at least 192px and as wide as its longest item, so labels never wrap; give items a leading muted
icon (Pencil for Edit, Trash2 for Delete). A row with exactly one action that has an icon gets an icon button with a
tooltip instead of a menu. The column header is Actions for assistive tech only. Put
{ type: "separator" } between groups — Copy link · Share ─ Copy summary ─ Delete — and leading,
trailing or doubled separators are dropped, so a conditional group can leave one behind safely. The
same entries drive the grid card's ⋯ and the board card's ⋯.
| Status | Actions | ||||
|---|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | ||
| Bea Arthur | bea@vega.dev | Designer | $940 | ||
| Cole Train | cole@vega.dev | Manager | $2,150 | ||
| Dax Shepard | dax@vega.dev | Engineer | $760 |
An action with items opens a submenu instead of running onSelect — Open · Copy link · Change
status › · Assign › ─ Remove. Its entries are RowActions too, so separators, links, disabled
reasons and destructive items work the same inside it.
Every item carries a leading icon (muted, per the icon rule) — Open (ArrowUpRight), Copy
link (Link), Change status › (the current status's icon), Assign › (UserRoundPlus),
Archive / Unarchive (Archive / ArchiveRestore), Cancel (CircleX, destructive). The menu
is at least 12rem wide so no label wraps. A status submenu lists each status with its coloured
StatusIcon, ticks the current one (checked) and shows its one-key shortcut (1–7 and 0) —
pressing the key while the submenu is open picks it.
| Actions | |||
|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | |
| Bea Arthur | bea@vega.dev | Designer | |
| Cole Train | cole@vega.dev | Manager |
For a submenu that is more than a fixed list — Assign › with a searched, paged member list — pass
submenu: a PanelSearch row, then a PanelList of DropdownMenuItems (it scrolls on its own, so
the search stays at the top), each person drawn as a
PersonOption — avatar, then name over email, stacked. Next
to a submenu, items are the list's static actions — Assign to me, Unassign — and render in a
PanelActions footer below the list, behind a hairline, as real menu items: give each an icon
(UserCheck, UserMinus) and disabled when it would change nothing. The arrow keys reach them
after the last person. A submenu popup is at least 18rem wide so a name and an email never
truncate.
| Status | Actions | ||||
|---|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | ||
| Bea Arthur | bea@vega.dev | Designer | $940 | ||
| Cole Train | cole@vega.dev | Manager | $2,150 | ||
| Dax Shepard | dax@vega.dev | Engineer | $760 |
RowActionsMenu (and RowActionMenuItems, for a host that owns its own menu) are exported from
data-table-parts; Board cards and SortableList rows use the same parts.
Rows as links
Row links and titles are never underlined, on hover or focus — the row's tint is the affordance.
getRowHref(row) makes each row a real link: the first cell's content becomes an <a> (or your
router's link through rowLinkRender), so Enter, a middle click and ⌘/Ctrl/Shift-clicks are the
browser's own. A click anywhere else on the row follows the same link with its modifiers; interactive
cells (a checkbox, a menu) keep their own behaviour. A row without an href falls back to
onRowClick. getRowLabel(row) names each selection checkbox ("Select Ada Lovelace").
highlightedIds (a Set of row ids) flashes those rows — the shortcut for rowProps's
highlighted. rowProps(row, index) passes data-* attributes and a class through to each row, and
highlighted flashes a row — one the user just created — in the accent wash, marked
data-highlighted. Clear it after a moment; the list's own data-slot, data-selected and
data-clickable always win over a passed attribute.
<DataList
columns={columns}
data={rows}
getRowId={(r) => r.id}
rowProps={(r) => ({ "data-id": r.id, highlighted: justAdded.has(r.id) })}
/>| Status | |||||
|---|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | ||
| Bea Arthur | bea@vega.dev | Designer | $940 | ||
| Cole Train | cole@vega.dev | Manager | $2,150 | ||
| Dax Shepard | dax@vega.dev | Engineer | $760 |
Highlighted rows, through highlightedIds:
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 | |
| Dax Shepard | dax@vega.dev | Engineer | $760 |
Sections
sections plus getRowSection split the rows into collapsible sections under the one header, in
the order you list them: each non-empty section is its own <tbody> headed by a SectionRow — a
th scope="rowgroup" with a disclosure button and the row count in muted tabular numerals (a
section's count wins, for a server total); above 99 it shows "99+" and says the number through
sectionCountLabel. Empty sections are omitted, rows in no listed section
follow without a header, and select-all covers every loaded row. The collapsed state is
groupState / defaultGroupState / onGroupStateChange, the same GroupState DataGrid's groups use.
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Cole Train | cole@vega.dev | Manager | $2,150 | |
| Eve Polastri | eve@vega.dev | Analyst | $1,530 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
Nested rows
getRowParentId nests a row under another, one level deep — a sub-task under its task. A row
whose parent is loaded shows right after it, its title stepped in, and in the parent's section
whatever its own; a row whose parent is not in data (filtered out, not loaded yet) stays where
it is. The list owns the order and the folding, the host owns the toggle: a cell's render receives
nesting (depth, children, expanded, setExpanded) in its context and puts the control
where it reads best — a sub-task count, say. Fold state is collapsedParents /
onCollapsedParentsChange (every parent starts expanded). A peek's walk (getRowOrder) and
shift-click ranges follow the rows as shown, so folded rows are skipped. List view only.
<DataList
columns={[
{
key: "title",
header: "Task",
interactive: true,
render: (task, _i, { nesting }) => (
<TaskTitle
task={task}
subtasks={
nesting?.children ? (
<SubtaskToggle
count={nesting.children}
expanded={nesting.expanded}
onToggle={() => nesting.setExpanded(!nesting.expanded)}
/>
) : null
}
/>
),
},
]}
data={tasks}
getRowId={(t) => t.id}
getRowParentId={(t) => t.parentId}
/>List
view="list" — the default — is the table on this page. Every other view below takes the same
data, sections, sort, filters, loadMore, loading, emptyState and noResults, so switching
views never changes what is shown, only how.
Thumbnails
A column's thumbnail returns the row's image URL, and the cell shows a 32px
Thumbnail before its value. thumbnailFallback is what a row with
no image shows — pass the app's brand mark. The grid view uses the same column as the card image.
<DataList
columns={[{ key: "name", header: "Family", thumbnail: (f) => f.imageUrl }]}
thumbnailFallback={<BrandMark />}
/>| Family | Products | Sub-families | Actions |
|---|---|---|---|
| 8 | 3 | ||
| Beacon Track | 5 | 1 | |
| 12 | 4 | ||
| 3 | 0 | ||
| Harbor Bollard | 6 | 2 | |
| 9 | 3 |
thumbnail can also return { src, placeholder?, srcSet?, sizes? }: the placeholder data URL
blurs under the image until it loads, in the row and on the grid card alike.
thumbnail: (f) => ({
src: f.image.url480,
placeholder: f.image.blur,
srcSet: f.image.srcset,
sizes: "48px",
});| Family | Products | Sub-families | Actions |
|---|---|---|---|
| 8 | 3 | ||
| Beacon Track | 5 | 1 | |
| 12 | 4 | ||
| 3 | 0 | ||
| Harbor Bollard | 6 | 2 | |
| 9 | 3 |
Grid
view="grid" renders one MediaCard per row in a responsive grid:
one column on a phone, two from a medium container, three from a wide one. The first column is the
card title, the other columns join into its meta line (a column's mergedRender is used there, so
give counts their noun), a thumbnail column is its image, getRowHref makes the whole card a
link, and rowActions is its ⋯ menu — on hover and focus, always on touch. Links never underline.
Grid and list
The same rows in both views. The first column is the record's title in each: the card's title in
the grid and the first cell in the list, at one weight (font-medium, RECORD_TITLE_CLASS), while
the meta — the card's meta line, the list's other cells and merged values — stays regular. A pill
rendered in the first column sits beside the title in both, and a long title truncates before it.
| Family | Products | Sub-families | Actions |
|---|---|---|---|
| 8 | 3 | ||
| Beacon Track | 5 | 1 | |
| 9 | 3 |
Grid with groups
With sections, each group renders as a heading and its count ("Indoor Luminaires · 4") above its
own grid.
Indoor Luminaires, 4 rows
Outdoor Luminaires, 2 rows
Grid lg
gridSize="lg" puts a 16:9 image on top of each card, in fewer, wider columns.
Dense grid
gridDensity="dense" fits more cards by width, for a file browser: at gridSize="lg", 1 column
below a 20rem container, 2 from 20rem, 3 from 42rem, 4 from 64rem and 5 from 80rem. The loading
skeleton follows the same columns, and at lg each skeleton card is the MediaCard lg shape — a
16:9 block and two text lines — so nothing jumps when the cards arrive.
Custom card
renderCard replaces the default card — compose a MediaCard with its badge (beside the
title) and timestamp slots, or anything else.
Layout
onViewChange gives a ListToolbar toolbar its Layout tool —
icon buttons at the end of the toolbar row, folded into Display on a narrow one — unless the
toolbar sets its own layout. Never put it in the PageHeader: the header's tabs hold the scope
("My | Team") only. views sets which layouts it offers, in order (default Grid | List). The
chosen view is remembered for the browser session under viewStorageKey (default: the path plus
the list's aria-label). defaultView is the page's first view before the user picks one —
"board" for a task list, "grid" for a catalogue — and the remembered choice wins after that.
Any other toolbar renders as it is, with no switch added.
<DataList
view={view}
onViewChange={setView}
toolbar={
<ListToolbar
search={search}
filterRow={<FilterRow facets={facets} onClear={clearAll} />}
/>
}
/>Indoor Luminaires, 4 rows
Outdoor Luminaires, 2 rows
Board
view="board" renders the sections as Board lanes — every lane shows,
empty or not — and each row as a BoardCard. The board owns the
card surface, its link and its ⋯ menu (rowActions, submenus included). By default the lanes fill
the viewport below the list and cards scroll inside each lane (boardHeight, default "fill").
Offer it with views={["list", "board"]}.
- Cards.
boardCard(row)maps a row to the card's fields —title,context,due,priority,assignee,done/onDoneChange,status(your status circle, the same control the list rows use), and the quieter slots —eyebrow,aside,chips,alert,footnote. Without it the title is the first column and the context line joins the other columns.renderCardreplaces the card entirely. - Moves.
onMove(row, fromSection, toSection, move)applies a drag or a keyboard move between lanes. The card moves at once; return a promise to gate it on the server — a rejection snaps it back with a toast. WithoutonMovethe board is read-only. - Lanes.
onAddToSection(sectionId)adds an "+ Add" button (its text isaddLabel) at each lane's foot so the host's create form opens with that status filled in. Every lane collapses from the icon button left of its header's ⋯ menu (collapsedSections/onCollapsedSectionsChangecontrol it); a section'sdefaultCollapsedstarts it as a read-only strip,droppable: falseandlockedReasonrefuse cards, andaddable: falsehides its add button.addPlacement="header"puts the add button in each lane's header beside ⋯ instead. A section'siconleads its lane title, itsactionsfill the lane's ⋯ menu, andboardTrailingrenders after the last lane inside the board's scroller. - Hidden lanes.
hiddenSectionslists the lanes the host hides (Cancelled, Duplicate) under "Hidden columns" after the last lane; choosing one callsonShowSection. A section markedhideableoffers "Hide column" in its ⋯ menu, which callsonHideSection.activeRowIdmarks the open card. - Per-lane data. A section's own
loading,loadMore(loaded as the lane's foot scrolls into view, with skeleton cards while it loads) andemptyStategive each lane its own first load, paging and empty state. An empty lane reads "Nothing here", and "Drop here" while dragging.
The simplest board: two columns, three sections and onMove, switched with the view toggle.
Loading: every lane shows skeleton cards while the list loads, and a section's loading does the
same for one lane.
On a phone (below 768px) the board shows one lane at a time at full width: swipe between lanes, or tap one in the lane strip above, which shows each lane's count. Hold a card for a quarter of a second to pick it up (a swipe still scrolls), and drag it to the screen's edge to turn to the next lane; the card's ⋯ menu is the one-tap path. The grid drops to one column and the list keeps its merged columns.
Fitting the width
DataList never forces a horizontal scroll. It measures its container and fits the columns into
it with the same responsive column revelation as DataGrid: every
column declares a pixel budget (minWidth, default 120) and a posture for when it no longer fits
(mobile, default "merge"). The first column and every "visible" column always stay, and their
budgets are reserved first, wherever they sit — a trailing Status or actions column is never
squeezed by an earlier column that took its room. The rest are kept left to right while their
budgets fit, and once one column overflows every column after it overflows too, so a narrow late
column never survives a wide earlier one.
mobile | When the column no longer fits |
|---|---|
"merge" | Default. Stacks under the primary (first) column's value, with its header as a screen-reader prefix. |
"visible" | Never hides — the column is essential; if the columns still do not fit, the table is strained (below). |
"hidden" | Drops the column; the table is described by a screen-reader-only "N columns hidden" line (nothing shows). |
const columns: DataListColumn<Person>[] = [
{ key: "name", header: "Name", minWidth: 160 }, // primary: always shown
{ key: "email", header: "Email" }, // merges when it no longer fits
{ key: "amount", header: "Amount", align: "end", mobile: "visible" }, // never hides
{ key: "notes", header: "Notes", mobile: "hidden" }, // dropped and counted
];A leading selection column is budgeted first (40px). "hidden" is the only posture that removes a
value, which is why it is opt-in and why it reports itself — use it only when the value is
recoverable elsewhere (a detail page, a drawer). The server render shows every column, because an
unmeasured table has no evidence that anything overflows; on the client the split is corrected in a
layout effect, before first paint.
A merged value loses the header above it, and the screen-reader prefix does not help a sighted
reader on a phone: a bare 4 under the name reads as nothing. Give the column mergedRender to
say what the value is where it lands — it replaces render (and the prefix) only in the merged
stack, so the wide table still shows 4 under its "To review" header:
{
key: "toReview",
header: "To review",
align: "end",
mergedRender: (m) => `${m.toReview} to review`,
}Several short facts read better on one line than stacked. mergedLayout="line" joins the merged
values into one compact meta line under the primary value, separated by a decorative dot — "Today ·
High · Arjun Mehta" — and truncates when the line runs out. Every value sits on the line's centre:
a chip, a person (its avatar hidden) and a time with its 24px tooltip target stay level with the
plain text and the dots, and a value that renders nothing, "" or "—" — bare or as the only
content of a plain element — drops with its dot. A figure — a column marked align: "end", mono
or numeric (a size, a count, an id, a date) — keeps its whole width on a tight line, and the text
values beside it truncate first: "Produ… · 1w ago · K Manoj… · 25 MB", never "w ag". Mark a time
column numeric so it stays whole. The default, "stack", is one value per line.
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 |
Fill and fit. The primary (first) column is the fill column: it takes the width the
others leave, its text wraps — anywhere, so one long unbroken name never widens the table — and a
plain value (no render, or a render that returns a string) clamps at two lines with an
ellipsis, the full value in its tooltip — or, when the first cell is the row's own link or button,
in its title, so no second control nests inside the row's. Every other column is a fit column: sized to its
content and kept on one line, and every header stays on one line too. A short value or label
never breaks.
If the table still overflows once revelation has run — a long unbroken value inside a custom
render of the first column, a one-line mono identifier there — it is squeezed
(data-squeezed on the <table>): the fill cell may then break anywhere instead of scrolling. Fit
cells and headers are never squeezed. If it still overflows, or the fit columns leave the fill
column narrower than its own minWidth, one more column merges into the primary cell, from the
right, until it fits. Only when nothing is left to merge — the primary and "visible" columns —
and it still overflows is it strained (data-strained): the fit cells release as well, and a
header wraps between its words, never inside one. The checks run before paint and are re-taken
only when the width, the columns or the data change.
The squeeze reaches inside a custom render too: the text in the fill cell wraps, whatever it says for
itself, so a whitespace-nowrap row breaks across lines (an auto-layout table cannot truncate a
one-line box, so its ellipsis was never available there). A truncate element keeps its promise of
one line instead: it is clamped to a single line (line-clamp-1) that ends in an ellipsis, so a
meta line of a fixed height never spills over the title beside it.
| Task | Due |
|---|---|
| Confirm the venue walkthrough#68↳ Prepare the Meridian Udaipur offsite and its supplier contracts | Fri |
| Send the rooming list#71↳ Prepare the Meridian Udaipur offsite and its supplier contracts | Mon |
Badge (and badgeVariants() on your own element, and
ToolCallChip), a Chip or Tag, a link-variant Button (or buttonVariants({ variant: "link" }))
and EditableCell's value.
Controls and fixed-size content are never wrapped by the squeeze, because their height is fixed and a label wrapped inside one would spill out of it. They are recognised by what reaches the DOM, whatever element carries it:
- every other
Button, and any element styled withbuttonVariants(),toggleVariants(),navigationMenuTriggerStyle(),tabsListVariants()orstepperNodeVariants(); - a native
button,input,selectortextarea, and an element with the rolebutton,checkbox,combobox,radio,slider,spinbutton,switchortab; - an
Avatarand aKbd.
These keep the one-line posture they give themselves (every VegaStack control sets one), and an
Avatar, a Kbd and a stepper node are held on one line and at full width. An unstyled button or
role="button" element has no fixed box, so it simply wraps with its cell. The text around a kept
part breaks instead, and the row grows taller. An icon cannot shrink either.
A kept part or a header word wider than the container is what nothing can fit. A header never
breaks inside a word, so keep header words short enough to sit side by side in the narrowest
container you support. Controls are never
wrapped, so keep the controls and fixed-size parts of every always-shown column small enough to fit,
side by side on one line, in the narrowest container you support; an outline Button whose label
alone is wider than the table leaves it nothing to squeeze, and the table scrolls sideways. Put
long text in a link-variant Button or a plain <a>, which wrap.
A stacked merged value always wraps (mergedLayout="line" is one line that truncates, squeezed or not). It does not inherit the first column's posture: under a mono or
align: "end" first column (both one line by default) every stacked value still breaks inside the
cell — at a space, or anywhere when a single word is wider than the cell — and wears its own
column's face, so prose under a mono invoice number is not set in mono. The first column's own
value keeps its one-line rule, so give a long mono identifier a minWidth that fits it.
| Invoice | Customer | Billing email | Status | Amount |
|---|---|---|---|---|
| INV-2026-00481 | Northwind Traders | accounts.payable@northwind-traders.example | $12,480 | |
| INV-2026-00482 | Contoso Pharmaceuticals | billing@contoso-pharmaceuticals.example | $940 | |
| INV-2026-00483 | Fabrikam | finance@fabrikam.example | $2,150 |
Dropped columns and the sort stay visible. A mobile: "hidden" column is counted under the
table. And when the active sort's column is merged or hidden — so its header, arrow and aria-sort
are gone — a second line says what the order is (Sorted by Amount, descending). DataList states
the order rather than forcing the sorted column back into the header row, which would re-open the
horizontal scroll. The table is described by both lines.
| Status | ||||
|---|---|---|---|---|
| Cole Train | cole@vega.dev | Manager | $2,150 | |
| Eve Polastri | eve@vega.dev | Analyst | $1,530 | |
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 |
Scope
DataList is the presentational data table — columns, render functions, row selection,
sortable-header signalling, loading, and empty state. It deliberately does not own
data-fetching or app-coupled data management. To make host composition ergonomic it exposes
presentational slots + callbacks (no app logic of its own): a toolbar slot (above the table),
a footer slot (below it), and onRowClick (activatable, keyboard-accessible rows). The host drops
its own search/filter and pagination controls into the slots and passes the already-filtered /
already-paged rows:
| Behaviour | Where it lives | Mount point |
|---|---|---|
| Search / filtering | Host owns the query + filtered data | toolbar slot |
| Pagination / page-size | Host owns paging + the current page; DataListPager is the control | footer slot |
| Keyset infinite loading | Host owns the cursor and the fetch; LoadMore loads on scroll | loadMore prop |
| Row activation (navigate / open) | Host's handler | onRowClick |
| Drag-and-drop reordering | The persisted order stays app-coupled; the mechanism is SortableList | compose it beside this table |
| Board / Kanban layout, grouping & collapsible groups | Board for Kanban; DataGrid for grouping | shipped |
| View persistence (URL / saved views) | Host owns the persisted view state | — |
// Host composes search + paging around the primitive
<DataList
columns={columns}
data={pageRows} // already filtered + paged by the host
toolbar={<SearchInput value={q} onValueChange={setQ} />}
footer={
<DataListPager
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
}
onRowClick={(row) => router.push(`/items/${row.id}`)}
/>Here the host owns a search box (in toolbar) and a DataListPager
(in footer), filtering and paging the data itself — DataList only renders the already-filtered,
already-paged rows and the two slots:
| Status | ||||
|---|---|---|---|---|
| Ada Lovelace | ada@vega.dev | Engineer | $1,280 | |
| Bea Arthur | bea@vega.dev | Designer | $940 | |
| Cole Train | cole@vega.dev | Manager | $2,150 |
1–3 of 5
This split mirrors the system's presentational / app-coupled rule — the primitive
stays presentational and reusable; the app wires the data and view state. Full platform parity —
grouping, inline editing, multi-key sort, column management, virtualization — is
DataGrid, and the Kanban tier is
Board; this primitive stays the presentational default.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
columns* | DataListColumn<T>[] | — | Column definitions, left to right. |
data* | T[] | — | Row data, in display order. Sorting is the parent's responsibility (see sort). |
actionsRef | React.Ref<DataListActions> | — | The list's imperative actions — getRowOrder(), the row ids in the order they read on
screen (sections in order, a collapsed section's rows left out in the list view), so a peek's
↑/↓ walk the rows as shown. |
activeRowId | string | null | — | The row whose record is open beside the list (a peek): it carries data-peek-active, takes
the accent wash (list rows, grid cards and board cards alike) and scrolls into view
(block: "nearest") when it changes. |
addLabel | string | "Add card" | Board view: the add button's label ("Add task"). |
addPlacement | "footer" | "header" | "footer" | Board view: where each lane's add button sits — after its last card, or an icon button in
the lane header beside the ⋯ menu (addLabel is then its accessible name and tooltip). |
boardCard | ((row: T) => Omit<BoardCardProps, "surface" | "href" | "linkRender">) | — | Board view: a row's BoardCard fields — title, context, due, priority, assignee,
done/onDoneChange, source. Without it the card's title is the first column and its
context line joins the other columns. |
boardHeight | "auto" | "fill" | (string & {}) | "fill" | Board view: the board's height — "fill" runs the lanes to the bottom of the viewport, with
cards scrolling inside each lane; "auto" or a CSS length otherwise. |
boardTrailing | React.ReactNode | — | Board view: content after the last lane, inside the board's sideways scroller — a list of hidden lanes, an "Add lane" button. |
canDropInto | ((move: DataListDropMove) => boolean) | — | Whether a drop is allowed — a folder cannot go into itself or its own descendants. An
invalid target shows data-drop-invalid and refuses the drop. A row can never take a drag
that carries it. |
canDropOnRow | ((row: T) => boolean) | — | Which rows are drop targets (a folder). Without it rows only drag, onto targets elsewhere. A list row takes the drop in its middle half — its top and bottom quarters never nest, so a drag aimed between two rows cannot land inside one — and a grid card anywhere on it. |
canMoveItem | ((row: T) => boolean) | undefined (every row can move) | Whether one row can move — a view-only record. A row answering false is no drag source
(rows and grid cards, so the pointer never picks it up), is left out of a dragged selection,
carries data-move-locked, and on the board view is a locked card (no pointer, touch or
Space pick-up, no "Draggable card" role description). It still opens, keeps its actions and
can still be a drop target. |
canReorderRow | ((move: DataListReorderMove) => boolean) | — | Whether a reorder is allowed (folders stay before files, say). A refused one shows no seam. A row can never be placed beside itself. |
clearSelectionLabel | string | "Clear selection" | The selection bar's clear button, as a screen reader hears it. |
collapsedParents | readonly string[] | — | The ids of the parents whose nested rows are folded, controlled. Pair with
onCollapsedParentsChange; without it every parent starts expanded. |
collapsedSections | readonly string[] | — | Board view: the collapsed lanes' section ids, controlled. Without it each section's
defaultCollapsed seeds the board's own state. |
cursorId | string | null | — | The cursor row's id, controlled: for a host that runs its own keys (or none) and still wants
the DataList's cursor cue and its mouse tracking. null shows no cursor. Pair it with
onCursorChange. Omit it to let the list keep its own. |
defaultGroupState | GroupState | {} | Initial collapsed/expanded state per section id, when uncontrolled. Sections are
expanded unless listed as "collapsed". |
defaultView | DataListView | "list" | The initial view when uncontrolled. |
density | DataListDensity | "comfortable" | Row density of the list view: comfortable (the table's own row height) or compact
(tighter rows and a shorter header, for scanning long lists). Grid and board ignore it. |
dragItemsLabel | ((count: number) => string) | (count) => (count === 1 ? "1 item" : `${count} items`) | The drag image's label for a drag that carries several rows (and for a single table row
with no getRowLabel; a grid card's drag image is the card itself). |
dragScope | string | — | Share the drag with drop targets outside the list (BreadcrumbDropTarget, a tree using
useDragInto) that name the same scope. |
empty | DataListEmpty | — | The copy of the design-system Empty shown when data is empty, not loading and nothing
is filtered — in the table, the grid and each board lane (an Inbox icon and "Nothing here"
by default). Pass only the text, icon or action this list needs. A section's own empty
wins for its lane. |
emptyState | React.ReactNode | — | Replace the whole empty state with your own node (table and grid). Prefer empty, which
keeps the standard Empty; never hand-roll empty markup. |
footer | React.ReactNode | — | Slot rendered below the table — where the host drops its own pagination,
load-more, or row-count footer (DataListPager is built for it). Renders
nothing when omitted (the paging *logic* lives in the host; this is just
the mount point). |
getRowHref | ((row: T) => string | undefined) | — | Make rows real links. The first cell's content becomes the link (<a>, or
rowLinkRender), so Enter, a middle click and every modifier click are the browser's own;
a click anywhere else on the row follows the same link, modifiers kept. Interactive cells
keep their own behaviour. A row without an href falls back to onRowClick. |
getRowId | ((row: T, index: number) => string) | (_, index) => String(index) | Extract a stable, unique id from a row. Used as the React key and as the selection identity. Defaults to the row index — pass a real id whenever rows can re-order or the dataset can change. |
getRowLabel | ((row: T) => string) | (row, index) => `row ${index + 1}` | A row's name, for its selection checkbox ("Select {label}"). |
getRowParentId | ((row: T) => string | null | undefined) | — | List view: nest a row under another — a sub-task under its task, one level deep. A row whose
parent is in data shows right after it (after its earlier siblings, in data order),
indented one step and in the parent's section whatever its own; a row whose parent is not in
data (filtered out, not loaded) stays where it is, top-level. A parent's rows fold with
collapsedParents; the host offers the toggle through the cell context's nesting. The
peek's order (getRowOrder) and shift-click ranges follow the rows as shown. The grid and
board views ignore it. |
getRowSection | ((row: T) => string) | — | The section id a row belongs to. Rows whose id is not in sections follow the listed
sections, without a header. |
gridDensity | "default" | "dense" | "default" | How many cards the grid fits by width. dense is a file browser's grid: at gridSize="lg",
1 column below a 20rem container, 2 from 20rem, 3 from 42rem, 4 from 64rem and 5 from 80rem
(default stops at 3); at the default size, 1, then 2 from 32rem, 3 from 48rem and 4 from
72rem. The loading skeleton follows the same columns, and at lg each skeleton is the
MediaCard lg shape: a 16:9 block and two text lines. |
gridSize | "default" | "lg" | "default" | The grid's card size: default (48px thumbnail, three columns on a wide container) or lg
(a 16:9 image on top). |
groupState | GroupState | — | Controlled collapsed/expanded state per section id. Pair with onGroupStateChange. |
hiddenSections | readonly BoardHiddenColumn[] | — | Board view: lanes the host has hidden, listed under "Hidden columns" after the last lane;
choosing one calls onShowSection. |
highlightedIds | ReadonlySet<string> | — | Row ids to flash in the accent wash — rows that were just created or changed (the same
highlighted state rowProps sets). Clear an id after a moment to return its row to rest. |
keyboardNavigation | boolean | false | Keyboard row navigation for a page's main list. J or ↓ moves the row cursor down, K or ↑
moves it up, Enter opens the cursor row (its row link, or onRowClick), and Escape clears
it. The cursor is the keyboard-focus cue, the hover wash plus the 2px start edge, and it
follows the mouse, so the keys carry on from the row under the pointer. The keys fire from
anywhere on the page except a text field, an open dialog or menu, or with ⌘/Ctrl/Alt held.
↑/↓ act only from the page body or inside the list, so other widgets keep their arrows. When
several mounted lists turn it on, the one mounted last owns the keys. List view only. |
loading | boolean | false | Show skeleton placeholder rows instead of data — the loading state. |
loadingRows | number | 5 | Number of skeleton rows to render while loading. |
loadMore | DataListLoadMoreProps | — | Keyset paging, loaded on scroll: the shared LoadMore tail below the table (above
footer) requests the next cursor page as the list nears its scroll container's edge, and
the table appends skeleton rows while loading. The table reports aria-rowcount="-1" while
hasMore, because the total is unknown. loading here is a next-batch fetch — the loaded
rows stay; the list-level loading prop is the first-load skeleton. A failed page shows its
error with Try again. endLabel is shown once hasMore is false (nothing by default). |
mergedLayout | "line" | "stack" | "stack" | How values folded into the first column are laid out on a narrow container: stack, one
value per line, or line, one compact meta line under the primary value with the values
joined by a dot ("Today · High · Arjun Mehta"). A line is one text line that truncates:
avatars inside it are hidden, and a value that renders nothing, "" or "—" is dropped with
its dot. It pairs well with mergedRender (return null to drop a value). |
noResults | boolean | DataListNoResults | — | The "no matches for these filters" state, used instead of emptyState while data is empty
because of a search or filter. true shows the standard one (a SearchX icon, "No matches", a
short description); pass an object to relabel it and wire "Clear filters" to the
FilterRow's onClear. |
onAddToSection | ((sectionId: string) => void) | — | Board view: show "+ Add" after each lane's last card and call this with the lane's section id, so the host's create form opens with that status filled in. |
onCollapsedParentsChange | ((ids: string[]) => void) | — | Called with the folded parents' ids when a parent folds or unfolds. |
onCollapsedSectionsChange | ((ids: string[]) => void) | — | Board view: called with the collapsed lanes' ids when the user collapses or expands one. |
onCursorChange | ((id: string | null) => void) | — | Called when the cursor moves, by a key or by the pointer moving onto a row, with that row's
id (null when Escape clears it). Setting it alone turns the mouse tracking on. |
onDensityChange | ((density: DataListDensity) => void) | — | Called with the density the user picks. Setting it adds a Row height choice to the toolbar's
DisplayMenu (or mounts one) when toolbar is a ListToolbar. Controlled: store the choice
and pass it back as density. |
onDropFilesOnRow | ((rowId: string, entries: DragIntoFileEntry[]) => void) | — | Desktop files dropped on a folder row or card (one canDropOnRow accepts): the row takes
them — the same wash as a row drag — and an outer useFileDrop surface does not. Folders
dropped keep their structure in each entry's relativePath. Works without onDropInto. |
onDropInto | ((move: DataListDropMove) => void | Promise<void>) | — | Rows (list) and cards (grid) can be dragged onto drop targets — rows canDropOnRow
accepts, and any BreadcrumbDropTarget (or other useDragInto) in the same dragScope.
Dragging a selected row carries the whole selection. Called for a valid drop; return a
promise for a server-gated move. The keyboard path is the host's own "Move…" action. |
onFilesOverRow | ((rowId: string | null) => void) | — | Called with the folder row desktop files are over, and null when they leave it — to name
the destination in the host's drop hint. |
onGroupStateChange | ((state: GroupState) => void) | — | Called with the next state when a section is collapsed or expanded. |
onHideSection | ((id: string) => void) | — | Board view: hide a lane, from "Hide column" in the ⋯ menu of a section marked hideable. |
onMove | ((row: T, fromSection: string, toSection: string, move: DragReorderMove) => void | Promise<void>) | — | Board view: a card was dragged (or moved from its menu) from one lane to another. Return a promise for a server-gated move; a rejection snaps the card back. Without it the board is read-only (its menus still work). |
onReorderRow | ((move: DataListReorderMove) => void | Promise<void>) | — | List view: rows drag to a place between other rows — a hand-arranged order. A drop on a row's
top or bottom quarter (half, when the row takes no drop inside) lands the dragged rows before
or after it, and a 2px line in the drop-indicator blue marks the seam while the pointer is
there. A row's middle stays an onDropInto drop when canDropOnRow accepts it. Called for an
allowed drop; return a promise for a server-gated move. The keyboard path is the host's own
Move up / Move down. Not offered on the grid or board views. |
onRowClick | ((row: T, index: number) => void) | — | Make rows activatable. When set, the row gets data-clickable and a
cursor-pointer, and a real, keyboard-focusable <button> is injected into
the **first** body cell as the accessible activation control — so the <tr>
keeps its native role="row" and the cells stay valid (no role="button"
on the row, which would break table semantics for assistive tech). Mouse
users get a row-wide onClick; keyboard / AT users tab to the first-cell
button and press Enter/Space. Activating a nested control (the selection
checkbox, a link, a button, …) is excluded — it keeps its own behaviour
without firing this. If the first column is interactive (its render
already returns a focusable control), set column.interactive on it so the
injected button is skipped for that cell — keyboard activation then comes
from a consumer-provided in-cell control. Purely *presentational*: the host
decides what activating a row does (navigate, open a drawer, …); DataList
still owns no data behaviour. |
onRowOpen | ((rowId: string, details: RowOpenDetails) => void) | — | Open a row's record in place — a peek beside the list. The whole row opens it: a plain
click anywhere on it (or Enter on its first cell), in the list, the grid and the board. With
getRowHref the row stays a real link, so ⌘/Ctrl/Shift and middle clicks still open the full
page where the browser would; only a plain click (or Enter) is taken over. Receives the row's
id (getRowId) and its index in data, with the event that opened it (null from the
keyboard cursor). Wins over onRowClick. Pair with activeRowId and actionsRef's
getRowOrder() to walk the rows from the peek. |
onSelectionChange | ((selectedIds: Set<string>) => void) | — | Called whenever the selection changes, with the next set of selected row ids. |
onShowSection | ((id: string) => void) | — | Board view: show a hidden lane again — the host puts its section back in sections. |
onSortChange | ((sort: SortState | null) => void) | — | Called when a sortable header is activated, with the next SortState
(or null when sorting is cleared). Cycles asc → desc → none per column. |
onViewChange | ((view: DataListView) => void) | — | Called with the view the user picks. Setting it gives a ListToolbar toolbar its Layout tool
(unless the host set layout), and the chosen view is remembered for the browser session. |
renderCard | ((row: T, index: number) => React.ReactNode) | — | Render a row as a card, for the grid and board views. The grid defaults to a MediaCard:
the first column is the title, the other columns join into its meta line, a thumbnail
column is its image, getRowHref its link and rowActions its ⋯ menu. The board defaults
to a BoardCard (see boardCard). On a board it is the card's content only — the board
owns the card surface, its link and its ⋯ menu. |
rowActions | ((row: T) => RowAction[]) | — | The standard row-actions column: when set, a trailing column (always last) renders a 32px
ghost ⋯ menu per row, end-aligned, never activating the row. Return [] for no menu. |
rowActionsLabel | ((label: string) => string) | (label) => `Actions for ${label}` | The ⋯ trigger's accessible name, from the row's label (getRowLabel). |
rowLinkRender | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | <a /> | The element a row link renders — a router link such as <Link />. It receives the href
and the cell content. |
rowProps | ((row: T, index: number) => DataListRowProps | undefined) | — | Per-row passthrough: data-* attributes, a class, and highlighted (flash a new row). The
list's own data-slot, data-selected and data-clickable always win over a passed one. |
sectionCountLabel | ((count: number) => string) | (n) => `${n} rows` | A section's count as a screen reader hears it. |
sections | DataListSection[] | — | Split the rows into collapsible sections, in this order, under the one header. Each
non-empty section is its own <tbody> headed by a SectionRow; empty sections are
omitted. Needs getRowSection. |
selectable | boolean | false | Render a leading checkbox column with per-row selection and a header select-all checkbox (tri-state when partially selected). |
selectedIds | Set<string> | — | Controlled set of selected row ids. Pair with onSelectionChange. Omit for
uncontrolled selection (the component tracks its own state). |
selectionActions | ((selectedIds: ReadonlySet<string>, clear: () => void) => React.ReactNode) | — | The actions for the current selection. When set, an ActionBar docks at the bottom of the
screen, centred over the list, while anything is selected: the count, these actions (compose
ActionBarButton), and "Clear selection". On a narrow screen the actions scroll sideways
instead of overflowing. clear empties the selection — call it after a bulk action. While
the bar is open the list ends with a spacer of the bar's height and gap plus
--dock-inset-bottom (a GlobalAudioPlayer under it), so the last row and the next page's skeleton stay
reachable. |
selectionBarLabel | string | "Selection actions" | The selection bar's accessible name. |
selectionLabel | ((count: number) => string) | (count) => `${count} selected` | The selection bar's status text. |
selectionMoreLabel | string | "More actions" | The selection bar's ⋯ menu trigger name, passed to ActionBar as moreLabel. |
selectionSecondaryActions | readonly ActionBarSecondaryAction[] | — | Secondary selection actions, passed to the bar's ActionBar — buttons on a wide screen, a ⋯
menu on a phone. |
sort | SortState | null | — | Controlled active sort. Pair with onSortChange. Omit for uncontrolled
sorting (the component tracks which header is active, but you must still
order data yourself in onSortChange). Passed WITHOUT onSortChange, the
sort is owned elsewhere (a toolbar's Sort control): headers are not buttons,
and the sorted column shows a read-only ↑/↓ and aria-sort. |
sortMode | "client" | "manual" | "manual" | Who orders the rows. "manual" only signals (onSortChange) and the parent sorts data;
"client" sorts data in place with each column's compare (or the default comparator).
A column with its own compare always sorts client-side. |
thumbnailFallback | React.ReactNode | <ImageIcon /> | What a thumbnail column or a card shows for a row with no image — the app's brand mark. |
toolbar | React.ReactNode | — | Slot rendered above the table — a ListToolbar (it then takes the Layout tool and Row
height from onViewChange and onDensityChange), or any toolbar of the host's own.
Renders nothing when omitted (per the G7 split, the search/filter *logic* lives in the host;
this is just the mount point). |
view | DataListView | "list" | The layout: "list" (the table), "grid" (a card per row, groups as headings over a
responsive grid) or "board" (sections as Board lanes). Items, groups, sort, filters,
paging, loading, empty and no-results states are the same in each. Controlled with
onViewChange. |
views | readonly DataListView[] | ["grid", "list"] | The views the Layout tool offers, in order. |
viewStorageKey | string | `data-list-view:` + the path + the list's aria-label | The session-storage key the chosen view is remembered under. |
Data attributes and CSS variables on DataList
| Attribute | Values |
|---|---|
data-clickable | "" |
data-collapsed | "" |
data-density | mirrors a prop or state value |
data-depth | mirrors a prop or state value |
data-highlighted | "" |
data-layout | mirrors a prop or state value |
data-peek-active | "" |
data-row-id | mirrors a prop or state value |
data-section | mirrors a prop or state value |
data-selected | "" |
data-selecting | "" |
data-slot | "data-list" | "data-list-card-select" | "data-list-compact-line" | "data-list-footer" | "data-list-grid" | "data-list-grid-item" | "data-list-grid-root" | "data-list-grid-section" | "data-list-grid-skeleton" | "data-list-grid-skeleton-card" | "data-list-head" | "data-list-hidden-hint" | "data-list-merged" | "data-list-more" | "data-list-root" | "data-list-row" | "data-list-row-action" | "data-list-section" | "data-list-sort-hint" | "data-list-status" | "data-list-thumbnail-cell" | "data-list-toolbar" |
data-sorted | mirrors a prop or state value |
data-squeezed | "" |
data-strained | "" |
data-view | mirrors a prop or state value |
Drop move
What onDropInto and canDropInto receive.
| Prop | Type | Default | Description |
|---|---|---|---|
ids* | string[] | — | The dragged row ids — one row, or the whole selection when a selected row was dragged. |
targetId* | string | — | The target's id: a row's id (getRowId), or the key a BreadcrumbDropTarget names. |
Reorder move
What onReorderRow and canReorderRow receive.
| Prop | Type | Default | Description |
|---|---|---|---|
edge* | "after" | "before" | — | Which side of the target row the rows land on. |
ids* | string[] | — | The dragged row ids — one row, or the whole selection when a selected row was dragged. |
targetId* | string | — | The target's id: a row's id (getRowId), or the key a BreadcrumbDropTarget names. |
Row props
What rowProps returns for a row, alongside any data-* attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Extra classes merged onto the row. |
highlighted | boolean | false | Wash the row in the accent tint and mark it data-highlighted — a row the user just created
or that just changed. The wash eases in (instantly under reduced motion); clear the flag after
a moment to return the row to rest. Selection's wash, when both apply, wins. |
Column
column.render is invoked as a plain function inside DataList's own
render, not mounted as a component — so hooks called directly in its body
become DataList's hooks and corrupt hook order the moment the loading or
empty branch flips. When a cell needs hooks, return a component element and
put the hooks in that component:
// ✅ hooks live in RelativeCell, mounted as an element
{ key: "updated", header: "Updated", render: (row) => <RelativeCell date={row.updatedAt} /> }
// ❌ a hook called directly here corrupts DataList's hook order
{ key: "updated", header: "Updated", render: (row) => useRelative(row.updatedAt) }render also receives an optional third argument, DataListCellContext —
{ rowId, columnKey, selected } — so a cell can react to its own row's
selection without threading state through the row type. Existing two-argument
render functions keep working unchanged.
| Prop | Type | Default | Description |
|---|---|---|---|
header* | React.ReactNode | — | Header label. A string or any node for custom header layouts. |
key* | string | — | Stable identifier — the React key, the sort key, the visibility key. |
align | "center" | "end" | "start" | "start" | Horizontal alignment of the header and cells. |
cellClassName | ((row: T, index: number) => string | undefined) | — | Per-cell class hook, called for every body cell in this column and merged
after className. Use for value-dependent cell styling (a negative-amount
tint, a stale-row wash) without a custom render. |
className | string | — | Extra className applied to every body cell in this column. |
compare | ((a: T, b: T) => number) | — | The comparator for this column, ascending. Setting it sorts client-side whatever sortMode
is; descending is its reverse. Without it, client sorting compares row[key] (numbers,
dates, then strings with a numeric-aware localeCompare), empty values last. |
headerClassName | string | — | Extra className applied to the header cell. |
interactive | boolean | false | Marks this column's cells as containing their own interactive content
(a link, button, menu, …). When the **first** column is interactive and
onRowClick is set, DataList skips auto-injecting its first-cell row
activation button into that cell — so it never nests an interactive element
inside the row-activation control. Set this on the first column whenever its
render returns something focusable/clickable. |
mergedRender | ((row: T, index: number, cell: DataListCellContext) => React.ReactNode) | — | What this column shows when a narrow container folds it into the first column's cell. A
value lifted out of its column loses the header above it, so a bare 4 reads as nothing:
return the value with its context instead (4 to review). Defaults to render (or the raw
value), with the header kept as a screen-reader-only prefix; when this is set the prefix is
dropped, because the value now carries its own. |
minWidth | number | 120 | Pixels this column needs before the responsive revelation shows it.
Columns that no longer fit hide right-to-left; mobile overrides. |
mobile | DataTableColumnMobile | "merge" | Responsive posture when the column no longer fits: visible never hides;
merge stacks the value into the primary (first) column's cell; hidden
drops it, which is counted and reported so the loss is never silent.
merge is the default because narrowing a viewport must never lose data
silently. |
mono | boolean | false | Render this column's BODY cells in the mono face (font-mono text-sm + tabular-nums) — for
codes and identifiers (SKUs, ticket ids, hashes). Numbers are not codes: a count, an amount
or a date column uses align: "end" and the regular face with tabular-nums. The header
cell stays in the sans face either way. |
nowrap | boolean | true, except for the primary column, which wraps unless it is `align="end"` or `mono` | Keep this column's cells on one line instead of wrapping. The primary
(first) column is the FILL column: it takes the width the others leave and
its text wraps (anywhere, so one unbroken value never widens the table).
Every other column is a FIT column: sized to its content and kept on one
line, so a short value or label never breaks. false on a fit column lets
it wrap between words instead (never inside one); true on the primary
column keeps a code or figure on one line until the table is squeezed. |
numeric | boolean | false | Set this column's BODY values in tabular figures, in the regular face — for a count, an amount,
a size or a date, so their digits line up down the column (mono already does for codes).
On a DataList's line meta line a numeric value is a figure: it keeps its whole width and the
text values beside it truncate first. |
render | ((row: T, index: number, cell: DataListCellContext) => React.ReactNode) | — | Cell renderer. When omitted, the value at row[key] is rendered directly
(the column key is read as a property of the row). Provide render for
formatted, composed, or computed cells. Receives an optional third
DataListCellContext argument (row id, column key, selection state).
Invoked as a **plain function inside DataList's own render**, not mounted
as a component — hooks called directly in its body would become DataList's
hooks and corrupt hook order when the loading/empty branch flips. Return a
component element (<MyCell row={row} />) when a cell needs hooks. |
sortable | boolean | false | Allow the user to sort by this column by clicking its header; the header shows a sort
indicator (↑/↓ while sorted; otherwise a ⇅ that fades in on hover or focus). The parent receives the next
SortState via onSortChange. With sortMode="client", or a compare on the column,
DataList orders data itself; otherwise the parent re-orders data. |
sortFirst | SortDirection | "asc" | The direction the first click sorts by — "desc" for dates and amounts where newest or
largest first is the useful order. The cycle is first → the other → none. |
thumbnail | ((row: T) => DataListThumbnail | null | undefined) | — | Show a 32px Thumbnail before this column's value (usually the first column), from the
row's image URL — or { src, placeholder?, srcSet?, sizes? } for a blurred preview until it
loads and a srcset. The grid view uses it as the card's image. A row with no image shows the
list's thumbnailFallback. |
Wrapping, numbers and the mono face
The first column wraps; every other column stays on one line, sized to its content (see Fill and
fit, above). Upstream's TableCell is whitespace-nowrap, so DataList spells the posture out per
cell rather than relying on an inherited default. In the first column two shapes are unreadable
when broken, so they stay on one line there too, until the table is squeezed:
align: "end"— the numeric convention. Give a number columnclassName: "tabular-nums"too, so its digits line up in the regular font.mono: true— for codes and IDs only (an order reference, a ticket key): renders values in the mono face (font-mono text-sm tabular-nums) and pins them to one line.monostyles body cells only: the header is a label and stays in the sans face. A count, a date or an amount is a number, not an identifier — usealign: "end"withtabular-numsinstead.
nowrap overrides the default in either direction: nowrap: true pins the first column to one
line, and nowrap: false lets another column wrap between words (never inside one), sharing the
leftover width with the first column instead of being sized to its content.
const columns: DataListColumn<Invoice>[] = [
{ key: "subject", header: "Subject" }, // the fill column: wraps, clamps at two lines
{ key: "ref", header: "Ref", mono: true }, // an identifier: one line, mono
{ key: "amount", header: "Amount", align: "end", className: "tabular-nums" }, // a number: one line
{ key: "note", header: "Note", nowrap: false }, // a fit column that wraps between words
];Sort types
SortDirection is a string-union alias and SortState is the small object you pass to sort /
receive from onSortChange, so they are documented as hand-written tables:
| Prop | Type | Default | Description |
|---|---|---|---|
key | string | — | key of the column being sorted. |
direction | "asc" | "desc" | — | Sort direction (SortDirection). |
SortDirection is "asc" | "desc". onSortChange receives SortState | null — null means the sort
was cleared (the third click on a header).
Row actions
rowActionsColumn(options) returns the trailing actions column; RowActionsMenu is the ⋯ menu it
renders, and RowAction is one entry of either.
| Prop | Type | Default | Description |
|---|---|---|---|
actions* | (row: T) => RowAction[] | — | The row's actions, in menu order. An empty list renders no menu. |
getRowLabel* | (row: T) => string | — | The row's name, for the menu trigger ("Actions for {label}"). |
actionsLabel | ((label: string) => string) | (label) => `Actions for ${label}` | The trigger's accessible name, from the row's label. |
headerLabel | string | "Actions" | The column's header, read by assistive tech only. |
key | string | "actions" | The column key. |
| Prop | Type | Default | Description |
|---|---|---|---|
actions* | RowAction[] | — | The actions, in menu order. Renders nothing when empty. |
label* | string | — | The row's name, used in the trigger's accessible name. |
actionsLabel | ((label: string) => string) | (label) => `Actions for ${label}` | The trigger's accessible name, from the row's label. |
Data attributes and CSS variables on RowActionsMenu
| Attribute | Values |
|---|---|
data-slot | "row-actions-menu-action" |
| Prop | Type | Default | Description |
|---|---|---|---|
label* | string | — | The item's text, and its accessible name. |
checked | boolean | — | Tick the item as the current choice (the current status in "Change status ›"). A ticked item
still runs onSelect. |
destructive | boolean | false | Paint the item in the destructive ink (delete, archive). |
disabled | boolean | false | Keep the item in the menu but unavailable. It stays reachable by arrow keys. |
disabledReason | string | — | Why a disabled item is unavailable — a tooltip on the item, read as its description. |
icon | React.ReactNode | — | A leading icon. A row with exactly one action that has an icon shows it as an icon button with a tooltip instead of a menu. |
items | RowAction[] | — | Nested actions: the item opens a submenu ("Change status ›", "Assign ›") listing these
entries instead of running onSelect. Separators and further nesting work the same way. |
onSelect | (() => void) | — | Run the action. Omit for a link action that passes render. |
render | React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | — | Render the item as another element — <a href="…" /> or a router Link — so the action
is a real link that opens in a new tab on ⌘-click. |
separatorBefore | boolean | false | Draw a separator above this item — to set a destructive action apart from the rest. |
shortcut | string | — | A one-key shortcut shown at the item's end. Pressing the key while its menu is open picks it (the Status menu's 1–7 and 0). |
submenu | React.ReactNode | — | Custom submenu content (a searched, paged list such as "Assign › people"): compose it from a
PanelSearch row and a PanelList of DropdownMenuItems. Alongside it, items are the
list's static actions ("Assign to me", "Unassign") and render in a PanelActions footer
below it — give each an icon. |
type | "item" | "item" | An item (the default). { type: "separator" } is the other kind of entry. |
RowAction is a RowActionItem or { type: "separator" }.
Accessibility
- Renders a real semantic
<table>with<thead>/<tbody>and<th>header cells, so screen readers announce row/column structure natively. - Sortable headers expose
aria-sort(ascending/descending/none) and are real<button>s named Sort by <column> — keyboard Enter / Space activate them, with the global:focus-visibletint; keyboard focus also reveals an inactive column's indicator. - The select-all and per-row checkboxes have explicit
aria-labels (e.g. Select all rows, Select row 1) and are fully keyboard operable. - While loading, the table has
aria-busy="true"and is described by a politerole="status"node. Skeleton rows are decorative (aria-hidden); the empty state carries its meaning in the title/description text, never by icon or color alone. - Columns merged into the first cell keep their header as a visually hidden prefix
(Email: ada@vega.dev), so a value lifted out of its column is never read without context. A
column dropped with
mobile: "hidden"is counted in a visible line that the table'saria-describedbypoints at, so the loss is heard with the table. When the sorted column is merged or hidden, the "Sorted by …" line is announced the same way. - Activatable rows (
onRowClick) keep native table semantics: the<tr>is never givenrole="button"/tabIndex(which would invalidate its cells). A real<button>injected into the first cell is the focusable keyboard / screen-reader activation; the row'sonClickis mouse-only convenience. - The selection bar is a Base UI toolbar named "Selection actions": one tab stop, arrow keys between
its actions, and a polite announcement of the count as it changes. While nothing is selected it is
inert, so its controls leave the tab order. - Dragging rows is pointer only; the keyboard path for a move is the host's own "Move…" action.
keyboardNavigationadds a row cursor for a page's main list: J / ↓ and K / ↑ move it, Enter opens the cursor row through its own link or row button, and Esc clears it. The cursor wears the keyboard-focus cue (the hover wash and the 2px start edge) and follows the mouse, so the keys carry on from the row under the pointer. The keys stand down in text fields, in an open dialog or menu, and with ⌘/Ctrl/Alt held; the arrows act only from the page body or inside the list. A host with its own keys passescursorIdandonCursorChangefor the same cue and mouse tracking.
| Key | Action |
|---|---|
| Tab | Move focus between sortable headers, row checkboxes, and (when activatable) each row's first-cell action button. |
| Enter / Space | Activate the focused sort header, toggle the focused checkbox, or activate the focused row. |
| Shift + click / Shift+Space on a checkbox | Select or clear every row from the last toggled one to this one. |
| Space on a row link | Toggle that row's selection (selectable). |
| ⌘/Ctrl+A | Select every row, while focus is in the list and not in a text field (selectable). |
| Esc | Clear the selection (selectable). |
| J / ↓, K / ↑ | Move the row cursor down or up (keyboardNavigation). |
| Enter with a row cursor | Open the cursor row (keyboardNavigation). |
| Esc with a row cursor | Clear the row cursor (keyboardNavigation). |
| Contract | States tested |
|---|---|
| Behaviour | default, active, checked, disabled, empty, filtering, indeterminate, loading, open, read-only, selected, sectioned, collapsed-section, row-actions, row-links, range-selected, selection-bar, dragging, drop-over, drop-invalid, grid-selected, grid-dense |
| Accessibility | busy, described, disabled, labeled, live, status-announcement, semantic-html, keyboard-selection, browser-accessibility-test |
| Visual | default, hover, loading, selected, empty |