Data List
A generic, typed data table — configurable columns, row selection, sortable headers, plus loading and empty 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
Add Data List from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/data-listThe same command installs the registry items it composes: @vegastack/data-table-parts, @vegastack/table.
Usage
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 |
Anatomy
DataList is a single generic component composed on top of the design-system primitives — it renders a
Table using the shared
data-table-parts: 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?, nowrap?, 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}
emptyState={/* optional override */}
/>;- Root — a
<table data-slot="data-list">inside the shared scroll viewport, which takes a tab stop (and itsaria-label) only while the table can actually scroll. - 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. - 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). - 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 theemptyState.
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 |
Sorting
Mark a column sortable to make its header interactive. Sorting is controlled by intent: onSortChange
fires with the next { key, direction } (or null when cleared) and you re-order data — the same API
works for client-side and server-side sorting. The header reflects the active sort with an arrow icon,
aria-sort, and data-sorted.
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}
/>;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.
<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.
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 a:focus-visiblering, 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.
Loading & Empty states
loading swaps the body for loadingRows skeleton rows (default 5). When data is empty and not loading,
DataList renders a built-in Empty — override it with the emptyState prop.
| Status |
|---|
| Status | ||||
|---|---|---|---|---|
No dataThere are no records to display. | ||||
Pass emptyState to fully replace the built-in node — for a filtered "no results" message with its own
icon and actions, for example:
| Status | ||||
|---|---|---|---|---|
No people match your filtersTry clearing the search or adjusting the filters above. | ||||
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 / load-more | Host owns paging + the current page | footer slot |
| 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={<Pagination page={page} onPageChange={setPage} />}
onRowClick={(row) => router.push(`/items/${row.id}`)}
/>Here the host owns a search box (in toolbar) and a pager (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 |
5 results
This split mirrors the design system's G7 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). |
containerProps | React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> | — | Props (including ref) forwarded to the scroll container element
(data-slot="table-container", which owns overflow-x-auto). This is the
attachment point for sticky headers, fixed-height viewports, and
virtualization — the <table> itself cannot own a scroll viewport. Use the
ref to measure or drive the scroll viewport (e.g. a virtualizer's
getScrollElement). |
density | "compact" | "default" | 'default' | Row density. default keeps py-2 cells; compact tightens to py-1
(~32px rows) for data-heavy screens. |
emptyState | React.ReactNode | — | Content shown when data is empty and not loading. Defaults to a built-in
Empty. Pass a node to fully customise it. |
footer | React.ReactNode | — | Slot rendered below the table — where the host drops its own pagination, load-more, or row-count footer. Renders nothing when omitted (the paging *logic* lives in the host; this is just the mount point). |
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. |
grid | boolean | false | Draw the full spreadsheet grid — a hairline on every cell's trailing edge in addition to the row rules (Wave 2, the Attio data-table voice). Off by default: simple tables keep row rules only. |
headerTone | "ink" | "muted" | 'muted' | Header voice. muted (default) keeps the 12/500 text-label-sm
muted-foreground headers; ink switches to 14/500 foreground headers — the
denser "spreadsheet" read for data-heavy screens. |
loading | boolean | false | Show skeleton placeholder rows instead of data — the loading state. |
loadingRows | number | 5 | Number of skeleton rows to render while loading. |
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. |
onSelectionChange | ((selectedIds: Set<string>) => void) | — | Called whenever the selection changes, with the next set of selected row ids. |
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. |
scrollLabel | string | the table's `aria-label` | Accessible name for the scroll viewport that wraps the <table>. Defaults
to the table's own aria-label. When a name is available the viewport is
exposed as role="region"; pass one whenever the table can scroll, so the
region a keyboard user lands on announces what it holds. |
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). |
sort | SortState | — | 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). |
toolbar | React.ReactNode | — | Slot rendered above the table — where the host drops its own search input, filter bar, or bulk actions. Renders nothing when omitted (per the G7 split, the search/filter *logic* lives in the host; this is just the mount point). |
Data attributes and CSS variables on DataList
| Attribute | Values |
|---|---|
data-clickable | "" |
data-selected | "" |
data-slot | "data-list" | "data-list-footer" | "data-list-head" | "data-list-root" | "data-list-row" | "data-list-row-action" | "data-list-toolbar" |
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. |
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. |
mono | boolean | false | Render this column's values in the mono numeral face (text-code +
tabular-nums), so figures line up down the column. |
nowrap | boolean | true for `align="end"` and `mono` columns, false otherwise | Keep this column's cells on one line instead of wrapping. Cells wrap by default (D18) — scrolling is reserved for tables that are genuinely wide, not forced by one long value. Figures and mono values are the exception and opt IN automatically. |
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. Sorting is
controlled — the parent receives the next SortState via
onSortChange and re-orders data itself. |
Wrapping and the mono face
Cells wrap by default (D18): a long value breaks inside its own column instead of forcing the whole table to scroll. Two column shapes are unreadable when broken, so they opt back out automatically:
align: "end"— the numeric convention.mono: true— renders values in the mono numeral face (font-mono text-code tabular-nums) so figures line up down the column, and pins them to one line.
nowrap overrides the inference in either direction: nowrap: true pins a prose column, and
nowrap: false lets an end-aligned column wrap. The floor a wrapping column may shrink to is
--table-cell-min-width on the underlying Table.
const columns: DataListColumn<Invoice>[] = [
{ key: "ref", header: "Ref", mono: true }, // one line, mono numerals
{ key: "subject", header: "Subject" }, // wraps
{ key: "amount", header: "Amount", align: "end" }, // one line
{ key: "note", header: "Note", align: "end", nowrap: false }, // end-aligned but wrapping
];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).
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 — keyboard Enter / Space activate them, with a:focus-visiblering. - 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. - 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.
| 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. |
| Contract | States tested |
|---|---|
| Behaviour | default, active, checked, disabled, empty, filtering, indeterminate, loading, open, read-only, selected |
| Accessibility | busy, described, disabled, labeled, live, status-announcement, semantic-html |
| Visual | default, hover, loading, selected, empty |