Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
Since
0.1.0
Accessibility pattern
native table semantics

Last updated

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$760
Eve Polastrieve@vega.devAnalystActive$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-list

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/data-list

Run 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.

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$760
Eve Polastrieve@vega.devAnalystActive$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 — 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"
DataListEmptyState — data-slot="data-list-empty"
DataListSecondary — data-slot="data-list-secondary"
NoResultsEmpty — data-slot="data-list-no-results"
<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 nothing DataList adds lands as a stray child of your own layout. ref, className and 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 carries data-selected when picked.
  • Sortable header — sortable columns render a ghost Button (data-slot="data-table-sort") inside <th data-slot="data-list-head">, which carries aria-sort on every sortable column ("none" included) and data-sorted="asc|desc"; clicking cycles asc → desc → cleared. Its label wears the plain header's own text-sm font-medium in text-foreground, so sortable and static headers read as one row.
  • Body — one <tr data-slot="data-list-row"> per row; cells use column.render or fall back to row[column.key], and wrap by default (see Wrapping below). A selected row keeps a light bg-muted/50 wash through hover and press — light enough that a secondary Badge inside it stays visible (the full accent is the badge's own fill).
  • Loading — skeleton rows (data-slot="data-list-skeleton-row") replace the body while loading; the table gets aria-busy and a live status while placeholder rows stay aria-hidden.
  • Empty — a single full-width row (data-slot="data-list-empty-row") hosting the empty state (empty, or your emptyState).
  • 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 marked data-sorted.
  • Status lines — under the table, in <div data-slot="data-list-status">: a column dropped with mobile: "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.

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$760
Eve Polastrieve@vega.devAnalystActive$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.

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.

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.

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.

NameSizeActions
Clients—
Projects—
Brief.pdf2.4 MB
Kickoff notes.md12 KB
Budget.xlsx88 KB
Logo.svg6 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-order data — the right choice for server-side sorting.
  • Client (sortMode="client", or any column with its own compare): DataList orders data itself with the column's compare(a, b) (ascending; descending is its reverse), else a default comparator over row[key] (numbers, dates, then numeric-aware strings, empties last).
  • Read-only (sort without onSortChange): 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 carries aria-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:

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
EmailStatus
Cole Traincole@vega.devManagerActive$2,150
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940

Client sorting, live — click a header to cycle it:

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$760
Eve Polastrieve@vega.devAnalystActive$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}`)}
/>
EmailStatus
ada@vega.devEngineerActive$1,280
bea@vega.devDesignerInvited$940
cole@vega.devManagerActive$2,150
dax@vega.devEngineerSuspended$760
eve@vega.devAnalystActive$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.

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$760
Eve Polastrieve@vega.devAnalystActive$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> keeps role="row" and gets a mouse-only onClick (a <tr> may carry onClick with no role — that's valid) plus data-clickable for 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-visible outline, 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}
/>;
NameEmailRole
Ada Lovelaceada@vega.devEngineer
Bea Arthurbea@vega.devDesigner
Cole Traincole@vega.devManager
Dax Sheparddax@vega.devEngineer

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>
);
NameEmailStatus
ada@vega.devAda Lovelace
ada@vega.devEngineerActive$1,280
bea@vega.devBea Arthur
bea@vega.devDesignerInvited$940
cole@vega.devCole Train
cole@vega.devManagerActive$2,150
dax@vega.devDax Shepard
dax@vega.devEngineerSuspended$760
eve@vega.devEve Polastri
eve@vega.devAnalystActive$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-in Empty shows 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 own empty wins for its lane. emptyState replaces the whole state with your own node (prefer empty). DataListEmptyState is the same state outside a DataList.
  • noResults — the list is empty because of a search or filter. true shows the standard one: a SearchX icon, "No matches", a short description; pass { onClear } to add a "Clear filters" button wired to the FilterRow's onClear (title, description and clearLabel relabel it). NoResultsEmpty is 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" }}
/>
Loading rows
EmailStatus
No people yet

The noResults state, from a search with a Clear filters action:

No matches
Nothing matches these filters. Try a different search or clear the filters.

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.

No people match your filters
Try clearing the search or adjusting the filters above.

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.

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$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 ⋯.

EmailStatusActions
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$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.

EmailActions
Ada Lovelaceada@vega.devEngineer
Bea Arthurbea@vega.devDesigner
Cole Traincole@vega.devManager

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.

EmailStatusActions
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$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.

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) })}
/>
EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$760

Highlighted rows, through highlightedIds:

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150
Dax Sheparddax@vega.devEngineerSuspended$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.

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Cole Traincole@vega.devManagerActive$2,150
Eve Polastrieve@vega.devAnalystActive$1,530
Bea Arthurbea@vega.devDesignerInvited$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 />}
/>

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",
});

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.

Aurora Downlight2 missing specs
8 products · 3 sub-families
Beacon Track
5 products · 1 sub-family
Cove Linear
12 products · 4 sub-families
Drift Pendant1 missing spec
3 products · 0 sub-families
Harbor Bollard
6 products · 2 sub-families
Summit Flood4 missing specs
9 products · 3 sub-families

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.

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

Aurora Downlight2 missing specs
8 products · 3 sub-families
Beacon Track
5 products · 1 sub-family
Cove Linear
12 products · 4 sub-families
Drift Pendant1 missing spec
3 products · 0 sub-families

Outdoor Luminaires, 2 rows

Harbor Bollard
6 products · 2 sub-families
Summit Flood4 missing specs
9 products · 3 sub-families

Grid lg

gridSize="lg" puts a 16:9 image on top of each card, in fewer, wider columns.

Aurora Downlight2 missing specs
8 products · 3 sub-families
Beacon Track
5 products · 1 sub-family
Cove Linear
12 products · 4 sub-families
Drift Pendant1 missing spec
3 products · 0 sub-families
Harbor Bollard
6 products · 2 sub-families
Summit Flood4 missing specs
9 products · 3 sub-families

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.

Aurora Downlight2 missing specs
8 products · 3 sub-families
Beacon Track
5 products · 1 sub-family
Cove Linear
12 products · 4 sub-families
Drift Pendant1 missing spec
3 products · 0 sub-families
Harbor Bollard
6 products · 2 sub-families
Summit Flood4 missing specs
9 products · 3 sub-families

Custom card

renderCard replaces the default card — compose a MediaCard with its badge (beside the title) and timestamp slots, or anything else.

Aurora Downlight2 missing specs
8 products2h ago
5 products2h ago
12 products2h ago
Drift Pendant1 missing spec
3 products2h ago
6 products2h ago
Summit Flood4 missing specs
9 products2h ago

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

Aurora Downlight2 missing specs
8 products · 3 sub-families
Beacon Track
5 products · 1 sub-family
Cove Linear
12 products · 4 sub-families
Drift Pendant1 missing spec
3 products · 0 sub-families

Outdoor Luminaires, 2 rows

Harbor Bollard
6 products · 2 sub-families
Summit Flood4 missing specs
9 products · 3 sub-families

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. renderCard replaces 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. Without onMove the board is read-only.
  • Lanes. onAddToSection(sectionId) adds an "+ Add" button (its text is addLabel) 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 / onCollapsedSectionsChange control it); a section's defaultCollapsed starts it as a read-only strip, droppable: false and lockedReason refuse cards, and addable: false hides its add button. addPlacement="header" puts the add button in each lane's header beside ⋯ instead. A section's icon leads its lane title, its actions fill the lane's ⋯ menu, and boardTrailing renders after the last lane inside the board's scroller.
  • Hidden lanes. hiddenSections lists the lanes the host hides (Cancelled, Duplicate) under "Hidden columns" after the last lane; choosing one calls onShowSection. A section marked hideable offers "Hide column" in its ⋯ menu, which calls onHideSection. activeRowId marks 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) and emptyState give each lane its own first load, paging and empty state. An empty lane reads "Nothing here", and "Drop here" while dragging.
To do
Send the revised lighting schedule
Harbour Tower · Acme Build
PSPriya ShahOverdue 1dUrgent
Confirm the fixture count for level 3
Harbour Tower · Acme Build
MKManoj KumarDue todayHigh
In progress
Draft the lobby pendant quote
Riverside · Northwind
ALAlex LeeDue in 3d
In review
Nothing here
Done
Share the photometric report
Riverside · Northwind
ALAlex LeeOverdue 3d

The simplest board: two columns, three sections and onMove, switched with the view toggle.

Open
Send quote to Arora Builders
Today
Photograph Cove samples
Fri
In progress
Review Summit spec sheet
Tomorrow
Done
Close March PO
Mon

Loading: every lane shows skeleton cards while the list loads, and a section's loading does the same for one lane.

To do
Send the revised lighting schedule
Harbour Tower
Confirm the fixture count for level 3
Harbour Tower
In progress
In review
Nothing here
Done
Share the photometric report
Riverside

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.

mobileWhen 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.

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$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.

TaskDue
Confirm the venue walkthrough#68↳ Prepare the Meridian Udaipur offsite and its supplier contractsFri
Send the rooming list#71↳ Prepare the Meridian Udaipur offsite and its supplier contractsMon
Parts that are text grow taller to hold a wrapped label: a 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 with buttonVariants(), toggleVariants(), navigationMenuTriggerStyle(), tabsListVariants() or stepperNodeVariants();
  • a native button, input, select or textarea, and an element with the role button, checkbox, combobox, radio, slider, spinbutton, switch or tab;
  • an Avatar and a Kbd.

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.

InvoiceCustomerBilling emailStatusAmount
INV-2026-00481Northwind Tradersaccounts.payable@northwind-traders.exampleActive$12,480
INV-2026-00482Contoso Pharmaceuticalsbilling@contoso-pharmaceuticals.exampleInvited$940
INV-2026-00483Fabrikamfinance@fabrikam.exampleSuspended$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.

EmailStatus
Cole Traincole@vega.devManagerActive$2,150
Eve Polastrieve@vega.devAnalystActive$1,530
Ada Lovelaceada@vega.devEngineerActive$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:

BehaviourWhere it livesMount point
Search / filteringHost owns the query + filtered datatoolbar slot
Pagination / page-sizeHost owns paging + the current page; DataListPager is the controlfooter slot
Keyset infinite loadingHost owns the cursor and the fetch; LoadMore loads on scrollloadMore prop
Row activation (navigate / open)Host's handleronRowClick
Drag-and-drop reorderingThe persisted order stays app-coupled; the mechanism is SortableListcompose it beside this table
Board / Kanban layout, grouping & collapsible groupsBoard for Kanban; DataGrid for groupingshipped
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:

EmailStatus
Ada Lovelaceada@vega.devEngineerActive$1,280
Bea Arthurbea@vega.devDesignerInvited$940
Cole Traincole@vega.devManagerActive$2,150

1–3 of 5

Rows per page

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

PropTypeDefaultDescription
columns*DataListColumn<T>[]—Column definitions, left to right.
data*T[]—Row data, in display order. Sorting is the parent's responsibility (see sort).
actionsRefReact.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.
activeRowIdstring | 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.
addLabelstring"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.
boardTrailingReact.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.
clearSelectionLabelstring"Clear selection"The selection bar's clear button, as a screen reader hears it.
collapsedParentsreadonly string[]—The ids of the parents whose nested rows are folded, controlled. Pair with onCollapsedParentsChange; without it every parent starts expanded.
collapsedSectionsreadonly string[]—Board view: the collapsed lanes' section ids, controlled. Without it each section's defaultCollapsed seeds the board's own state.
cursorIdstring | 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.
defaultGroupStateGroupState{}Initial collapsed/expanded state per section id, when uncontrolled. Sections are expanded unless listed as "collapsed".
defaultViewDataListView"list"The initial view when uncontrolled.
densityDataListDensity"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).
dragScopestring—Share the drag with drop targets outside the list (BreadcrumbDropTarget, a tree using useDragInto) that name the same scope.
emptyDataListEmpty—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.
emptyStateReact.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.
footerReact.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).
groupStateGroupState—Controlled collapsed/expanded state per section id. Pair with onGroupStateChange.
hiddenSectionsreadonly BoardHiddenColumn[]—Board view: lanes the host has hidden, listed under "Hidden columns" after the last lane; choosing one calls onShowSection.
highlightedIdsReadonlySet<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.
keyboardNavigationbooleanfalseKeyboard 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.
loadingbooleanfalseShow skeleton placeholder rows instead of data — the loading state.
loadingRowsnumber5Number of skeleton rows to render while loading.
loadMoreDataListLoadMoreProps—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).
noResultsboolean | 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).
rowLinkRenderReact.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.
sectionsDataListSection[]—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.
selectablebooleanfalseRender a leading checkbox column with per-row selection and a header select-all checkbox (tri-state when partially selected).
selectedIdsSet<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.
selectionBarLabelstring"Selection actions"The selection bar's accessible name.
selectionLabel((count: number) => string)(count) => `${count} selected`The selection bar's status text.
selectionMoreLabelstring"More actions"The selection bar's ⋯ menu trigger name, passed to ActionBar as moreLabel.
selectionSecondaryActionsreadonly ActionBarSecondaryAction[]—Secondary selection actions, passed to the bar's ActionBar — buttons on a wide screen, a ⋯ menu on a phone.
sortSortState | 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.
thumbnailFallbackReact.ReactNode<ImageIcon />What a thumbnail column or a card shows for a row with no image — the app's brand mark.
toolbarReact.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).
viewDataListView"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.
viewsreadonly DataListView[]["grid", "list"]The views the Layout tool offers, in order.
viewStorageKeystring`data-list-view:` + the path + the list's aria-labelThe session-storage key the chosen view is remembered under.

Data attributes and CSS variables on DataList

AttributeValues
data-clickable""
data-collapsed""
data-densitymirrors a prop or state value
data-depthmirrors a prop or state value
data-highlighted""
data-layoutmirrors a prop or state value
data-peek-active""
data-row-idmirrors a prop or state value
data-sectionmirrors 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-sortedmirrors a prop or state value
data-squeezed""
data-strained""
data-viewmirrors a prop or state value

Drop move

What onDropInto and canDropInto receive.

PropTypeDefaultDescription
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.

PropTypeDefaultDescription
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.

PropTypeDefaultDescription
classNamestring—Extra classes merged onto the row.
highlightedbooleanfalseWash 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.

PropTypeDefaultDescription
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.
classNamestring—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.
headerClassNamestring—Extra className applied to the header cell.
interactivebooleanfalseMarks 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.
minWidthnumber120Pixels this column needs before the responsive revelation shows it. Columns that no longer fit hide right-to-left; mobile overrides.
mobileDataTableColumnMobile"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.
monobooleanfalseRender 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.
nowrapbooleantrue, 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.
numericbooleanfalseSet 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.
sortablebooleanfalseAllow 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.
sortFirstSortDirection"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 column className: "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. mono styles 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 — use align: "end" with tabular-nums instead.

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:

PropTypeDefaultDescription
keystring—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.

PropTypeDefaultDescription
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.
headerLabelstring"Actions"The column's header, read by assistive tech only.
keystring"actions"The column key.
PropTypeDefaultDescription
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

AttributeValues
data-slot"row-actions-menu-action"
PropTypeDefaultDescription
label*string—The item's text, and its accessible name.
checkedboolean—Tick the item as the current choice (the current status in "Change status ›"). A ticked item still runs onSelect.
destructivebooleanfalsePaint the item in the destructive ink (delete, archive).
disabledbooleanfalseKeep the item in the menu but unavailable. It stays reachable by arrow keys.
disabledReasonstring—Why a disabled item is unavailable — a tooltip on the item, read as its description.
iconReact.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.
itemsRowAction[]—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.
renderReact.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.
separatorBeforebooleanfalseDraw a separator above this item — to set a destructive action apart from the rest.
shortcutstring—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).
submenuReact.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-visible tint; 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 polite role="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's aria-describedby points 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 given role="button"/tabIndex (which would invalidate its cells). A real <button> injected into the first cell is the focusable keyboard / screen-reader activation; the row's onClick is 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.
  • keyboardNavigation adds 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 passes cursorId and onCursorChange for the same cue and mouse tracking.
KeyAction
TabMove focus between sortable headers, row checkboxes, and (when activatable) each row's first-cell action button.
Enter / SpaceActivate the focused sort header, toggle the focused checkbox, or activate the focused row.
Shift + click / Shift+Space on a checkboxSelect or clear every row from the last toggled one to this one.
Space on a row linkToggle that row's selection (selectable).
⌘/Ctrl+ASelect every row, while focus is in the list and not in a text field (selectable).
EscClear the selection (selectable).
J / ↓, K / ↑Move the row cursor down or up (keyboardNavigation).
Enter with a row cursorOpen the cursor row (keyboardNavigation).
Esc with a row cursorClear the row cursor (keyboardNavigation).
ContractStates tested
Behaviourdefault, 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
Accessibilitybusy, described, disabled, labeled, live, status-announcement, semantic-html, keyboard-selection, browser-accessibility-test
Visualdefault, hover, loading, selected, empty

Do / Don't

Do
Pass a stable getRowId (a real record id) so selection survives sorting and data refreshes.
Don't
Rely on the default index-based row id when rows can re-order — selection will jump to the wrong rows.

On this page