List

DataView's table and list renderer — two presentations behind one component, with virtualization, grouping, and row selection.

1<DataView
2 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";
2
3<DataView data={data} fields={fields} defaultSort={defaultSort}>
4 <DataView.Toolbar>
5 <DataView.Search />
6 <DataView.Filters />
7 <DataView.DisplayControls />
8 </DataView.Toolbar>
9
10 <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<DataView
2 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 <DataView
4 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.List
13 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 a
2 different group from DisplayControls — same wire format either way.
3 The active group header sticks under the column header as the user
4 scrolls past it. */
5<DataView
6 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 shows
2 the active group's label; its content swaps as the user scrolls past
3 each group's offset. The natural group header at the active offset is
4 hidden so the anchor doesn't double-render the label. */
5<div style={{ height: 360 }}>
6 <DataView
7 data={data} // ~1500 rows
8 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 while
2 `isLoading` is true. Existing rows render alongside skeletons in
3 server mode (load-more). */
4<DataView
5 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";
10
11const selectionColumn: DataViewListColumn<Person> = {
12 accessorKey: "select",
13 width: 48,
14 header: ({ table }) => (
15 <Checkbox
  • Pass getRowId whenever 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 the data array 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 with table.resetRowSelection(), and mirror it outside the tree with onRowSelectionChange on the root.
  • The TanStack row selection API works as documentedrow.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() and table.getToggleAllRowsSelectedHandler() adapt a native <input type="checkbox" onChange> and read event.target.checked. Apsara's Checkbox reports a boolean through onCheckedChange, so the table-level getter throws on undefined.checked and the row-level one only works through its "no value means invert" fallback. Call toggleSelected and toggleAllRowsSelected with the boolean instead.
  • Position the bar through FloatingActions. It defaults to variant="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 ancestor transform, filter, or contain: paint so it becomes the containing block. Add padding-bottom via classNames.root if rows would otherwise sit behind it.
  • Stop propagation in the checkbox's onClick when the root has an onRowClick, 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 rowSelection holds one key per data row and select-all covers the visible data rows without flagging the bands. Read the count off getSelectedRowModel().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.

SlotElement
data-view-listScroll container
data-view-list-gridThe grid carrying the table/list role
data-view-list-headerHeader row group
data-view-list-header-rowHeader row
data-view-list-header-cellHeader cell
data-view-list-bodyRow container
data-view-list-rowData row
data-view-list-cellCell (data and loader rows)
data-view-list-group-headerGroup header (incl. the sticky anchor)
data-view-list-loader-rowSkeleton row while isLoading
data-view-list-sentinelInfinite-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 with rowgroup, row, columnheader, and cell on its parts. variant="list" uses role="list" with listitem rows instead.
  • When onRowClick is set, each row gets tabIndex={0} and activates with Enter or Space, matching a native button. Rows keep their structural role (row or listitem) 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 are aria-hidden so screen readers don't announce them.