List
DataView's table and list renderer — two presentations behind one component, with virtualization, grouping, and row selection.
1<DataView2 data={data}3 fields={fields}4 defaultSort={{ name: "name", order: "asc" }}5>6 <DataView.Toolbar>7 <DataView.Filters />8 <DataView.DisplayControls />9 </DataView.Toolbar>10 <DataView.List variant="table" columns={tableColumns} />11</DataView>
Overview
DataView.List draws the row model as a grid. It ships two presentations behind one renderer — variant="table" for column headers and aligned tracks, variant="list" for card-style rows — and both read the same fields, filters, and display properties declared on the root.
Anatomy
1import { DataView, DataViewListColumn } from "@raystack/apsara";23<DataView data={data} fields={fields} defaultSort={defaultSort}>4 <DataView.Toolbar>5 <DataView.Search />6 <DataView.Filters />7 <DataView.DisplayControls />8 </DataView.Toolbar>910 <DataView.List variant="table" columns={columns} />11</DataView>
Usage
One renderer, two presentations. variant decides which; everything else applies to both.
Variant
variant="table" is the default: a header row, aligned column tracks, and real table semantics. variant="list" drops the header and renders card-style rows — the default 1fr middle column with auto end columns gives you the familiar justify-between layout.
1<DataView2 data={data}3 fields={fields}4 defaultSort={{ name: "name", order: "asc" }}5>6 <DataView.Toolbar>7 <DataView.Filters />8 </DataView.Toolbar>9 <DataView.List variant="list" columns={listColumns} />10</DataView>
Columns
columns is the presentation half of the fields and columns split. Each entry maps an accessorKey to a width and a cell renderer.
1const columns: DataViewListColumn<Person>[] = [2 { accessorKey: "name", width: "1fr", cell: ({ row }) => <Text>{row.original.name}</Text> },3 { accessorKey: "team", width: "auto", cell: ({ row }) => <Badge>{row.original.team}</Badge> },4];
An accessor with no matching entry in the root's fields is an unmanaged display column. DataView.List always renders it, it never appears in Display Properties, and it can't be filtered, sorted, or grouped. Checkboxes, row actions, and drag handles all work this way.
Virtualization
For large datasets, pass virtualized. The parent must have a fixed height — only the rows in view are rendered. Rows auto-measure after paint, so variable-height content (avatars, wrapped text, badges) just works. estimatedRowHeight is an optional hint used only until the first measurement.
1/* Parent container must have a fixed height. */2<div style={{ height: 400 }}>3 <DataView4 data={data}5 fields={fields}6 defaultSort={{ name: "name", order: "asc" }}7 >8 <DataView.Toolbar>9 <DataView.Filters />10 <DataView.DisplayControls />11 </DataView.Toolbar>12 <DataView.List13 variant="table"14 columns={tableColumns}15 virtualized
Grouping
Group rows by any groupable field. stickyGroupHeader pins the active group label directly under the column headers while you scroll past that group's rows. Pick a different field from DisplayControls → Grouping at runtime — the wire format stays group_by: string[].
1/* Initial `group_by` is supplied via `query`. The user can pick a2 different group from DisplayControls — same wire format either way.3 The active group header sticks under the column header as the user4 scrolls past it. */5<DataView6 data={data}7 fields={fields}8 defaultSort={{ name: "name", order: "asc" }}9 query={{ group_by: ["team"] }}10>11 <DataView.Toolbar>12 <DataView.Filters />13 <DataView.DisplayControls />14 </DataView.Toolbar>15 <DataView.List variant="table" columns={tableColumns} stickyGroupHeader />
In virtualized mode, a single sticky-anchor element swaps its content as the user scrolls past each group's offset. The natural group header at the active offset is hidden so the anchor doesn't double-render the label, and the lookup uses binary search plus requestAnimationFrame so the cost stays flat regardless of group count.
1/* Virtualized + grouped + sticky. A single sticky-anchor element shows2 the active group's label; its content swaps as the user scrolls past3 each group's offset. The natural group header at the active offset is4 hidden so the anchor doesn't double-render the label. */5<div style={{ height: 360 }}>6 <DataView7 data={data} // ~1500 rows8 fields={fields}9 defaultSort={{ name: "name", order: "asc" }}10 query={{ group_by: ["team"] }}11 >12 <DataView.Toolbar>13 <DataView.Filters />14 <DataView.DisplayControls />15 </DataView.Toolbar>
Loading
While isLoading is true, DataView.List renders loadingRowCount skeleton rows at the tail. Behaviour is identical in virtualized and non-virtualized mode, and during initial load (skeletons fill the row pane) as well as during paginated load-more (skeletons render below the last loaded row).
1/* `DataView.List` renders `loadingRowCount` skeleton rows while2 `isLoading` is true. Existing rows render alongside skeletons in3 server mode (load-more). */4<DataView5 data={loadingRows}6 fields={fields}7 defaultSort={{ name: "name", order: "asc" }}8 isLoading={isLoading}9 loadingRowCount={4}10>11 <DataView.Toolbar>12 <DataView.Filters />13 </DataView.Toolbar>14 <DataView.List variant="table" columns={tableColumns} />15</DataView>
In server mode, infinite scroll triggers via a single sentinel and an IntersectionObserver — there are no scroll-distance knobs to tune. While isLoading is true the sentinel is suppressed so your onLoadMore isn't fired again during a fetch.
Row selection
DataView doesn't ship a selection toolbar, but the underlying TanStack table instance is exposed via useDataView(), so you own the affordance: add a checkbox column to the renderer and float a FloatingActions bar over the view while rows are selected.
1import {2 Button,3 Checkbox,4 Chip,5 DataView,6 FloatingActions,7 useDataView,8} from "@raystack/apsara";9import { Frame } from "lucide-react";1011const selectionColumn: DataViewListColumn<Person> = {12 accessorKey: "select",13 width: 48,14 header: ({ table }) => (15 <Checkbox
- Pass
getRowIdwhenever the data can change under you. Without it, selection falls back to positional keys ('0','1', and'<group>.<index>'inside a group section). Those keys come from thedataarray rather than the visible order, so client-side sort, filter, and search are safe, and a static dataset needs nothing. What they can't survive is the identity behind a position changing: a refetch or a server-mode sort returning rows in a new order moves the selection to whatever now sits at that index, and toggling Grouping re-keys the rows and drops it. - Selection state lives on the table instance. Read it with
table.getSelectedRowModel(), clear it withtable.resetRowSelection(), and mirror it outside the tree withonRowSelectionChangeon the root. - The TanStack row selection API works as documented —
row.getIsSelected(),row.toggleSelected(),row.getIsSomeSelected(),table.getIsAllRowsSelected(),getIsSomeRowsSelected(),toggleAllRowsSelected(),setRowSelection(),resetRowSelection(). The header helpers are computed over the filtered rows, so select-all tracks what the user can see rather than the whole dataset. - Skip the two handler getters from that guide.
row.getToggleSelectedHandler()andtable.getToggleAllRowsSelectedHandler()adapt a native<input type="checkbox" onChange>and readevent.target.checked. Apsara'sCheckboxreports a boolean throughonCheckedChange, so the table-level getter throws onundefined.checkedand the row-level one only works through its "no value means invert" fallback. CalltoggleSelectedandtoggleAllRowsSelectedwith the boolean instead. - Position the bar through
FloatingActions. It defaults tovariant="floating"—position: fixed, bottom-center — so the call site needs no positioning CSS. To scope the bar to the view instead of the viewport, give an ancestortransform,filter, orcontain: paintso it becomes the containing block. Addpadding-bottomviaclassNames.rootif rows would otherwise sit behind it. - Stop propagation in the checkbox's
onClickwhen the root has anonRowClick, otherwise ticking a checkbox also activates the row. - Grouping works alongside selection. Group header rows are keyed in their own id space and are not selectable, so
rowSelectionholds one key per data row and select-all covers the visible data rows without flagging the bands. Read the count offgetSelectedRowModel().flatRows, not.rows— the latter only walks selected top-level rows, which is empty while grouping puts every data row one level down.
API Reference
The renderer, and its column shape.
DataView.List
Prop
Type
Column
Prop
Type
Slots
Every rendered part carries a stable data-slot attribute for styling and testing. Toolbar, filter, and display-control slots are on the DataView page.
| Slot | Element |
|---|---|
data-view-list | Scroll container |
data-view-list-grid | The grid carrying the table/list role |
data-view-list-header | Header row group |
data-view-list-header-row | Header row |
data-view-list-header-cell | Header cell |
data-view-list-body | Row container |
data-view-list-row | Data row |
data-view-list-cell | Cell (data and loader rows) |
data-view-list-group-header | Group header (incl. the sticky anchor) |
data-view-list-loader-row | Skeleton row while isLoading |
data-view-list-sentinel | Infinite-scroll sentinel |
Header cells, body cells, and loader cells also carry data-column="{accessorKey}", so a single column can be targeted without a per-column classNames entry:
1/* Right-align every cell in the "amount" column, header included */2[data-slot="data-view-list-cell"][data-column="amount"],3[data-slot="data-view-list-header-cell"][data-column="amount"] {4 text-align: right;5}
Accessibility
variant="table"renders real table semantics:role="table"on the grid withrowgroup,row,columnheader, andcellon its parts.variant="list"usesrole="list"withlistitemrows instead.- When
onRowClickis set, each row getstabIndex={0}and activates with Enter or Space, matching a native button. Rows keep their structural role (roworlistitem) so cells stay associated with their row, and key presses bubbling up from interactive children are ignored so they don't also trigger row activation. - Skeleton loader rows are marked
aria-busy="true"; the infinite-scroll sentinel and the duplicate sticky group-header anchor arearia-hiddenso screen readers don't announce them.