Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Data List

A generic, typed data table — configurable columns, row selection, sortable headers, plus loading and empty 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

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-list

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

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

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 — data-slot="data-list" | "data-list-footer" | "data-list-head" | "data-list-root" | "data-list-row" | "data-list-row-action" | "data-list-toolbar"
<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 its aria-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 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.
  • 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).
  • 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 emptyState.

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

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}`)}
/>
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.

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 a :focus-visible ring, 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.

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.

Loading rows
EmailStatus
EmailStatus

No data

There 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:

EmailStatus

No people match your filters

Try 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:

BehaviourWhere it livesMount point
Search / filteringHost owns the query + filtered datatoolbar slot
Pagination / page-size / load-moreHost owns paging + the current pagefooter slot
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={<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:

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

5 results

Page 1 of 2

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

PropTypeDefaultDescription
columns*DataListColumn<T>[]Column definitions, left to right.
data*T[]Row data, in display order. Sorting is the parent's responsibility (see sort).
containerPropsReact.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.
emptyStateReact.ReactNodeContent shown when data is empty and not loading. Defaults to a built-in Empty. Pass a node to fully customise it.
footerReact.ReactNodeSlot 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.
gridbooleanfalseDraw 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.
loadingbooleanfalseShow skeleton placeholder rows instead of data — the loading state.
loadingRowsnumber5Number 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.
scrollLabelstringthe 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.
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).
sortSortStateControlled 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).
toolbarReact.ReactNodeSlot 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

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

PropTypeDefaultDescription
header*React.ReactNodeHeader label. A string or any node for custom header layouts.
key*stringStable 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.
classNamestringExtra className applied to every body cell in this column.
headerClassNamestringExtra 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.
monobooleanfalseRender this column's values in the mono numeral face (text-code + tabular-nums), so figures line up down the column.
nowrapbooleantrue for `align="end"` and `mono` columns, false otherwiseKeep 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.
sortablebooleanfalseAllow 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:

PropTypeDefaultDescription
keystringkey of the column being sorted.
direction"asc" | "desc"Sort direction (SortDirection).

SortDirection is "asc" | "desc". onSortChange receives SortState | nullnull 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-visible ring.
  • 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.
  • 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.
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.
ContractStates tested
Behaviourdefault, active, checked, disabled, empty, filtering, indeterminate, loading, open, read-only, selected
Accessibilitybusy, described, disabled, labeled, live, status-announcement, semantic-html
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