# VirtualList

**When to use:** Efficiently render large lists or tables — thousands of rows rendered with a virtualised scroll window so only visible rows are in the DOM. Supports column definitions, row selection, multi-select, column sorting, column resizing, column visibility toggling, and infinite scroll.

**Keywords:** data table, virtualized table, sort, sorting, order by, ascending, descending, column header click, resize, resizable, drag column edge, column width, autofit, double scrollbar, nested scroll, window scrolling, spreadsheet, data entry grid, editable grid

**Import:** `import { VirtualList } from '@ahrowe/ui'`  
**Types:** `import type { VirtualListProps, VirtualListColumn, VirtualListHandle, VirtualListSort } from '@ahrowe/ui'`

```tsx
import { VirtualList } from '@ahrowe/ui';
import type { VirtualListColumn } from '@ahrowe/ui';

type User = { id: string; name: string; email: string; role: string };

// Simple list mode (no columns)
<VirtualList
  items={users}
  renderRow={(user) => (
    <div className="row">{user.name} — {user.email}</div>
  )}
  height={500}
/>

// Table mode with column definitions
const columns: VirtualListColumn<User>[] = [
  {
    key: 'name',
    label: 'Name',
    width: { type: 'flex', weight: 2 },
    renderCell: (user) => <strong>{user.name}</strong>,
  },
  {
    key: 'email',
    label: 'Email',
    width: { type: 'flex' },
    renderCell: (user) => user.email,
  },
  {
    key: 'role',
    label: 'Role',
    width: { type: 'fixed', px: 120 },
    renderCell: (user) => user.role,
    defaultHidden: true,
  },
];

<VirtualList
  items={users}
  columns={columns}
  getItemKey={(user) => user.id}
  selectedKey={selectedId}
  onRowClick={(user) => setSelectedId(user.id)}
  height={600}
/>

// Infinite scroll
<VirtualList
  items={items}
  columns={columns}
  onLoadMore={hasMore ? async () => { await fetchNextPage(); } : undefined}
  isLoading={isInitialLoading}
  height="100%"
/>

// Multi-select
<VirtualList
  items={items}
  columns={columns}
  multiSelect
  selectedKeys={selectedKeys}
  onSelectionChange={(keys) => setSelectedKeys(keys)}
  height={500}
/>
```

**Column width types:**

```ts
{ type: 'fixed'; px: number }          // fixed pixel width
{ type: 'flex';  weight?: number }     // flex, weight defaults to 1
{ type: 'fit' }                        // shrink to content
```

**Column definition (`VirtualListColumn<T>`):**

| Field | Type | Description |
|-------|------|-------------|
| `key` | `string` | Stable column id |
| `label` | `ReactNode` | Header cell content (also the default gear-menu entry) |
| `width` | `ColumnWidthSpec` | One of the width types above |
| `renderCell` | `(item, rowIndex) => ReactNode` | Cell content for this column |
| `defaultHidden` | `boolean` | Start hidden; the user can reveal it from the gear menu |
| `toggleLabel` | `ReactNode` | Text shown for this column in the gear menu, overriding `label`. Use for columns with an empty header (e.g. an actions column) so their menu entry isn't blank |
| `lockVisible` | `boolean` | Column can't be hidden — always visible (overrides `defaultHidden` and any controlled/persisted state), shown checked and disabled in the gear menu |
| `hideFromToggle` | `boolean` | Omit the column from the gear menu entirely (no entry). Visibility otherwise follows `defaultHidden`/controlled/persisted state; combine with `lockVisible` to keep it always visible and out of the menu. The gear button hides automatically when no column is left to toggle |
| `sortValue` | `(item) => string \| number \| boolean \| Date \| null \| undefined` | Makes the column sortable **and** lets the list sort `items` itself by this value |
| `compare` | `(a, b) => number` | Ascending comparator for an order `sortValue` can't express. Also makes the column sortable, and wins over `sortValue` |
| `sortable` | `boolean` | Makes the header clickable without the list reordering anything: the sort is only reported through `onSortChange`. For server-side / paginated sorting. Ignored when `sortValue` or `compare` is set |
| `minWidth` | `number` | Floor this column can be dragged to, in px (default `48`) |
| `resizable` | `boolean` | Set `false` to opt this column out while the list has `resizableColumns` |

```tsx
// An actions column: no header, always visible, labelled "Actions" in the gear menu
const columns: VirtualListColumn<User>[] = [
  { key: 'name', label: 'Name', width: { type: 'flex' }, renderCell: (u) => u.name },
  {
    key: 'actions',
    label: '',                 // empty header
    toggleLabel: 'Actions',    // reads as "Actions" in the gear menu
    lockVisible: true,         // can't be hidden
    width: { type: 'fixed', px: 80 },
    renderCell: (u) => <RowActions user={u} />,
  },
];
```

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `items` | `T[]` | Data array (required) |
| `renderRow` | `(item, index) => ReactNode` | Row render fn — list mode (no columns) |
| `columns` | `VirtualListColumn<T>[]` | Column definitions — enables table/header mode |
| `height` | `number \| string` | Scroll viewport height (default `'100%'`). Ignored with `scrollParent` |
| `scrollParent` | `'nearest' \| 'window' \| HTMLElement \| (() => HTMLElement \| null)` | Drop the list's own scrollbar and virtualize against this scrollport instead — see **No scroll of its own** |
| `estimatedRowHeight` | `number` | Row height assumed before anything has been measured (default `40`). Once rows have been measured, their average is used for the ones that haven't, so this only has to be roughly right |
| `overscan` | `number` | Extra rows rendered outside the viewport (default `3`) |
| `rowGap` | `number \| string` | Gap between rows — number = px; string = any CSS length (`'1em'`, `'var(--spacing-s)'`). Default `0` |
| `rowPadding` | `number \| string` | Padding on every row — number = px; string = any CSS padding value (`'8px 12px'`, `'var(--spacing-s)'`) |
| `showDivider` | `boolean` | Row divider lines (default `true`) |
| `rowHover` | `boolean` | Highlight rows on hover (default `true`). Set `false` when the row content brings its own hover affordance (e.g. a `Card` per row) so the two don't fight |
| `getItemKey` | `(item, index) => string \| number` | Stable key per item |
| `selectedKey` | `string \| number \| null` | Controlled single-select key |
| `onRowClick` | `(item, index) => void` | Row click handler, also fired by `Enter` on the active row |
| `keyboardNavigation` | `boolean` | Arrow-key navigation over the rows (default `true`). See **Keyboard** |
| `tabAcrossRows` | `boolean` | Let `Tab` carry on into the next row instead of leaving the list at the end of the current one (default `false`). See **Keyboard** |
| `multiSelect` | `boolean` | Enable checkbox multi-select column |
| `selectedKeys` | `Set<string \| number>` | Controlled multi-select keys |
| `onSelectionChange` | `(keys) => void` | Multi-select change callback |
| `sort` | `VirtualListSort \| null` | Controlled sort. `null` is source order |
| `defaultSort` | `VirtualListSort \| null` | Initial sort, uncontrolled (default `null`) |
| `onSortChange` | `(sort) => void` | Fires on every header click with the next step of the cycle |
| `resizableColumns` | `boolean` | Let the user drag column edges to resize (see **Column resizing**) |
| `columnWidths` | `Record<string, number>` | Controlled column widths in px, keyed by column key |
| `onColumnWidthsChange` | `(widths) => void` | Called with the full width map after every resize or auto-fit |
| `showColumnToggle` | `boolean` | Show gear button to show/hide columns (default `true` when columns present) |
| `visibleColumnKeys` | `string[]` | Controlled visible column keys |
| `onVisibleColumnsChange` | `(keys) => void` | Column visibility change callback |
| `persistColumnsKey` | `string` | Save column visibility to `localStorage` under this key and restore it on the next mount of a list using the same key. Uncontrolled mode only (ignored alongside `visibleColumnKeys`); columns added since the settings were saved fall back to their `defaultHidden` |
| `onLoadMore` | `() => Promise<void>` | Triggered near the bottom, and repeatedly while the rows don't fill the viewport — append items in the handler |
| `loadMoreThreshold` | `number` | Distance from bottom that triggers `onLoadMore` (default `100`) |
| `isLoading` | `boolean` | Replaces list body with a full-height spinner |
| `ariaRoles` | `{ container: string; row: string }` | Replaces the roles put on the container and rows (default `list`/`listitem`, or `grid`/`row` with columns). Pass `'none'` for both when the rows carry their own semantics, as [Tree](Tree.md) does when windowing `treeitem` rows |

Drag-and-drop reordering adds `reorderable`, `treeReorder`, `dragHandle`, and their callbacks — see **Row reordering** below.

**Sorting:** a column becomes sortable as soon as it carries `sortValue`, `compare` or `sortable`. Its header turns into a button that cycles ascending, descending, unsorted, and carries `aria-sort` for screen readers.

Who reorders the data is the column's decision:

- **`sortValue` / `compare`**: the list sorts `items` itself. For data you hold in full, in memory.
- **`sortable`**: the list only reports the click through `onSortChange` and never touches `items`. Use this whenever the rows are paginated or fetched per sort (`onLoadMore`), where sorting the rows loaded so far would silently order part of the data.

```tsx
// Sorted by the list
const columns: VirtualListColumn<User>[] = [
  { key: 'name', label: 'Name', width: { type: 'flex' }, renderCell: (u) => u.name, sortValue: (u) => u.name },
  { key: 'joined', label: 'Joined', width: { type: 'fit' }, renderCell: (u) => u.joined, sortValue: (u) => new Date(u.joined) },
];

<VirtualList items={users} columns={columns} defaultSort={{ key: 'name', direction: 'asc' }} height={500} />

// Sorted by the server: the header only reports, the query does the work
const serverColumns: VirtualListColumn<User>[] = [
  { key: 'name', label: 'Name', width: { type: 'flex' }, renderCell: (u) => u.name, sortable: true },
];

const [sort, setSort] = useState<VirtualListSort | null>(null);

<VirtualList
  items={page}
  columns={serverColumns}
  sort={sort}
  onSortChange={(next) => { setSort(next); refetch({ sort: next }); }}
  height={500}
/>
```

`null` and `undefined` (and `NaN`) `sortValue`s always sort last, in both directions, so empty cells stay at the bottom of the table. Strings are compared in the app's `locale` (see [ConfigProvider.md](ConfigProvider.md)) with numeric ordering, so `Order 2` comes before `Order 10`, and case and accents don't split otherwise-equal values. Rows that compare equal keep their original order. A `compare` function is used as given and negated for descending, so the empty-values rule doesn't apply to it.

**Sorting and row reordering are mutually exclusive.** While a sort is active, `reorderable` and `treeReorder` are switched off (with a console warning), because a drop position would be overwritten by the next sort pass. Clear the sort to drag rows again.

**Column resizing:** set `resizableColumns` and every column gets a grab strip on its trailing edge.

- **Drag** it to resize. The column follows the pointer exactly, and `Escape` mid-drag cancels back to the width it started at.
- **Double-click** it to fit the column to its widest cell. Only rendered rows can be measured, so this fits what is on screen, not the whole data set.
- **Keyboard**: `Alt` plus the arrow keys on the focused header cell, 10px a press (`Alt+Shift` for 1px). `Alt+Home` goes to the column's minimum, `Alt+End` auto-fits. The strip itself is a mouse affordance and is not a tab stop.
- **Undo it**: once anything has been resized, the gear menu grows a **Reset column widths** entry, and the same thing is on the imperative handle as `resetColumnWidths()`. Both also clear the saved widths.

Its text, like every other string the table renders of its own, is set through `labels` (see below): `labels={{ resetColumnWidths: 'Spaltenbreiten zurücksetzen' }}`.

```tsx
<VirtualList
  items={rows}
  columns={columns}
  resizableColumns
  persistColumnsKey="invoices"   // widths are saved with the column visibility
  height={500}
/>
```

**The first resize freezes the layout.** Every visible column is pinned to the width it already has, so nothing moves at that moment, and from then on the table is exact pixels rather than `flex`/`fit` tracks. This is what makes a drag follow the cursor one to one: with `fr` tracks the other columns would absorb the difference, and the dragged edge would stop moving the moment they hit their minimum. The table may then be wider than its container, in which case it scrolls horizontally.

Two consequences worth knowing:

- A `fit` column stops re-measuring once the layout is frozen. Its width is the user's from then on, which is the point: measurement only ever grows a column, so it would undo every drag that made one narrower.
- A column revealed from the gear menu after the freeze starts at 160px, since there is nothing measured to go on. Double-click its handle to fit it.

Widths ride along with `persistColumnsKey`, in the same `localStorage` entry as the visible columns. Entries saved before this existed still load. For full control, drive `columnWidths` and `onColumnWidthsChange` yourself: as with any controlled prop, a width the consumer doesn't accept snaps back once the drag ends.

Resizing is a pointer and keyboard interaction. There is no touch gesture for it, because a horizontal drag at a row edge belongs to scrolling; double-click auto-fit works everywhere.

**Keyboard:** the table takes **two tab stops** in total, whatever its size: the rows, then the header. (The rows come first because the element carrying their stop is the header's ancestor, and a focusable ancestor always precedes its descendants.)

Rows, with the list focused:

| Key | Does |
|-----|------|
| `↑` `↓` | Move the active row, scrolling it into view only if it isn't already |
| `Home` `End` | First or last row |
| `PageUp` `PageDown` | A viewport at a time |
| `Enter` | Fire `onRowClick` for the active row, or step into it when the list has no `onRowClick` |
| `Space` | Toggle the active row's selection (`multiSelect` only) |
| `F2` | Step into the active row: focus its first control |
| `Escape` | Step back out of a row to the list, making that row the active one |

The list keeps the focus and points at the active row with `aria-activedescendant`, rather than moving focus onto the row: a virtualized row unmounts when it scrolls out of the window, which would strand the focus. For the same reason the per-row checkboxes are not tab stops.

Keys are ignored while the focus is inside a cell, so an input, a `Dropdown` or a button in a row keeps its own. `F2` and `Escape` are the way in and back out; `Escape` is left alone when the cell's own content already handled it, so closing a `Dropdown` does not also leave the row.

**Controls inside rows are not tab stops.** The list holds them at `tabindex="-1"` and puts back the ones of the row being used, so `Tab` walks that row and then leaves the list. Without that, every control in every *rendered* row is a stop, the set changes as the list scrolls, and tabbing through a long list drops out of it at an arbitrary row, because the next one isn't in the DOM yet when the browser looks for the next stop. Entering a row happens with `F2`, with `Enter` when the list has no `onRowClick`, or simply by clicking a control. A `tabindex` you set yourself is restored exactly as it was, and an element you already parked at `-1` stays parked.

**`tabAcrossRows` makes `Tab` walk the whole grid**, for rows that are filled in rather than read: from the last field of a row it enters the next row's first field, `Shift+Tab` walks back the same way, and `Tab` out of the last row (or back out of the first) leaves the list as it always did. The list mounts the row it is moving into, so it works across the edge of the rendered window. A row with nothing focusable in it is stepped over, and when nothing past it has anything either, the focus goes back to the list so the next `Tab` leaves. `Tab` on the list's own stop steps into the active row instead of over the whole list, since otherwise nothing inside it would ever be reachable by tabbing.

It is off by default on purpose: for a list that is read, one tab stop plus the arrow keys is what a screen-reader user expects, and a few thousand rows of tab stops is a trap with no way past it. Turn it on for a grid of inputs — invoice lines, a timesheet — where field-by-field entry is the whole interaction. It does not need `keyboardNavigation`: with that off the list takes no stop of its own and `tabAcrossRows` only carries `Tab` across the boundary between two rows.

The row being used also stays mounted while it holds the focus, even once it scrolls out of the window, so scrolling can't drop the focus to the document body. Set `keyboardNavigation={false}` for a list whose rows bring their own key handling, as [Tree](Tree.md) does.

Because `role="list"` is not a composite role, the active row is only announced in table mode. In list mode, pass `ariaRoles={{ container: 'listbox', row: 'option' }}` (or another composite pair) to have it announced there too.

The header is the second stop, and one stop however many columns it has.

| Key | Does |
|-----|------|
| `←` `→` | Move between the select-all box, the column headers and the column menu |
| `Home` `End` | Jump to the first or last of those |
| `Enter` `Space` | Sort the focused column, or toggle select-all on its cell |
| `Alt` + `←` `→` | Resize the focused column by 10px (`Alt+Shift` by 1px) |
| `Alt` + `Home` `End` | Take the focused column to its minimum width, or fit it to its content |

Focus sits on the header cell rather than on the sort button inside it, so every key a column understands is in one place and a column costs one stop instead of three.

**Imperative handle (`ref`):**

Pass a `ref` typed as `VirtualListHandle` to control the list imperatively:

```tsx
import { useRef } from 'react';
import { VirtualList } from '@ahrowe/ui';
import type { VirtualListHandle } from '@ahrowe/ui';

const listRef = useRef<VirtualListHandle>(null);

<VirtualList ref={listRef} items={items} columns={columns} height={600} />;

listRef.current?.scrollToKey(user.id, { align: 'center' });
```

**Reach for `scrollToKey` unless you actually mean a position.** A key survives sorting, filtering and reloading, because it belongs to the row rather than to where the row happens to sit. `scrollToIndex` is for the cases where there is no row yet: a jump box, "row 500", a scrollbar of your own. Its index is zero-based and counts the **rendered** order, so on a sorted list it is not the index in your `items` array.

| Method | Signature | Description |
|--------|-----------|-------------|
| `scrollToIndex` | `(index, options?) => void` | Scroll so the row at `index` is visible. `options`: `{ align?: 'start' \| 'center' \| 'end'; behavior?: ScrollBehavior }`. A jump into rows that have never been rendered re-aims itself as their real heights are measured, so it lands on the row you asked for rather than on estimated arithmetic. Input of your own during the jump cancels it |
| `scrollToKey` | `(key, options?) => void` | Scroll to the row whose `getItemKey` matches `key` (no-op if not found) |
| `scrollToTop` | `(options?) => void` | Scroll back to the top |
| `getScrollElement` | `() => HTMLElement \| null` | The element that scrolls: the list itself, or the `scrollParent` it was given. `null` for `scrollParent='window'`, where the page scrolls |
| `remeasure` | `() => void` | Re-read where the list sits in its `scrollParent`. Only needed in that mode, and only when something **above** the list changed height without the list re-rendering — an element that merely moves fires no event of its own |
| `resetColumnWidths` | `() => void` | Drop every user-set column width, returning the table to the widths its columns declare, and clear them from `persistColumnsKey` storage |
| `recalculateColumns` | `() => void` | Re-measure `fit` columns on the next layout. Only needed after an out-of-band content change that doesn't flow through a new `items` array or `columns` set — those re-measure automatically, as does content first appearing in a cell |

**Row reordering:** two modes, both drag-and-drop, both mouse and touch. Set exactly one:

- **`reorderable`** — flat reorder. Rows move **live** as you drag, so `onReorder` fires repeatedly and you apply each move immediately.
- **`treeReorder`** — drop `before`, `after`, or `inside` another row. Rows do **not** move during the drag; a drop indicator is shown and `onTreeDrop` fires **once** on release.

If both are set, `treeReorder` wins. In either mode, pass `getItemKey` so rows keep their measured heights across the reorder.

```tsx
// Flat: onReorder fires live, on every position change
const [rows, setRows] = useState(initialRows);

<VirtualList
  items={rows}
  renderRow={(row) => <div>{row.label}</div>}
  getItemKey={(row) => row.id}
  reorderable
  onReorder={(from, to) => setRows((prev) => move(prev, from, to))}
  height={400}
/>
```

The list is always a flat array, including in tree mode. Keep the real tree in your own state, flatten the expanded nodes into `items` tagging each with its depth, and re-flatten after applying a drop:

```tsx
// Tree: onTreeDrop fires once, on release
<VirtualList
  items={flattenedRows}                        // each row carries its own depth
  renderRow={(row) => (
    <div style={{ paddingLeft: row.depth * 24 }}>
      <span data-drag-handle>⠿</span>
      {row.label}
    </div>
  )}
  getItemKey={(row) => row.id}
  getItemDepth={(row) => row.depth}
  isGroup={(row) => row.hasChildren}           // only groups offer an `inside` drop
  treeReorder
  dragHandle
  treeIndentPx={24}                            // match the row content's per-level padding
  onTreeDrop={(dragKey, target) => applyDrop(dragKey, target)}
  height={400}
/>
```

| Prop | Type | Description |
|------|------|-------------|
| `reorderable` | `boolean` | Flat drag-to-reorder. The whole row is draggable unless `dragHandle` is set |
| `onReorder` | `(fromIndex, toIndex) => void` | Fires **live** during a flat drag, once per position change. Apply it straight away |
| `treeReorder` | `boolean` | Tree drag-and-drop: `before` / `after` / `inside` drops instead of a flat move. Wins over `reorderable` |
| `onTreeDrop` | `(dragKey, target: TreeDropTarget) => void` | Fires **once** on release. Never called when the target is the dragged node itself or one of its descendants |
| `getItemDepth` | `(item, index) => number` | Each row's depth in the tree, `0` = root (default `0` for every row). Aligns the drop indicator and blocks dropping a node into its own subtree |
| `isGroup` | `(item, index) => boolean` | Whether a row can take children, i.e. whether hovering its middle third offers an `inside` drop (default `false`, leaf) |
| `treeIndentPx` | `number` | Px per depth level used to indent the `before`/`after` drop line so it lines up with where the dropped content starts (default `24`) |
| `dragHandle` | `boolean` | Only elements marked `data-drag-handle` start a drag, instead of the whole row. Applies to both modes, mouse and touch |
| `longPressDelay` | `number` | Touch only: press-and-hold duration in ms before drag mode arms, so a normal swipe still scrolls (default `400`) |

**`TreeDropTarget`** (`import type { TreeDropTarget, TreeDropPosition } from '@ahrowe/ui'`):

```ts
interface TreeDropTarget {
  key: string | number;                          // getItemKey of the row dropped on
  position: 'before' | 'inside' | 'after';       // TreeDropPosition
}
```

`before`/`after` place the dragged node as a sibling at the target's depth; `inside` makes it a child (only offered when `isGroup` is true for that row).

**Use `dragHandle` for rows containing inputs or other interactive controls.** Without it the whole row is draggable, which swallows clicking, typing, and text selection inside those controls. Render a handle in the row yourself and mark it: `<span data-drag-handle>⠿</span>`. With `dragHandle` the grab cursor is yours to style on the handle; without it, the list puts it on the whole row.

**Touch:** a press-and-hold of `longPressDelay` lifts a floating copy of the row that follows your finger, with auto-scroll near the list's top and bottom edges. A swipe before the hold completes scrolls the list as normal instead of starting a drag. With `dragHandle`, only a press that starts on the handle arms it, so the rest of the row stays tappable.

**No scroll of its own (`scrollParent`):** the list renders at its full height as a plain block in the page flow and virtualizes against a scrollport it does not own. For a long list at the end of a scrolling page, where an inner scrollbar inside the page's own is the thing you are trying to avoid.

```tsx
// Whatever scrolls around it, found once on mount. The usual answer.
<VirtualList items={positions} columns={columns} scrollParent='nearest' estimatedRowHeight={48} />

// Or name it: the page, an element, or a getter for a ref that fills later.
<VirtualList items={positions} columns={columns} scrollParent='window' estimatedRowHeight={48} />
<VirtualList items={positions} columns={columns} scrollParent={() => shellRef.current} estimatedRowHeight={48} />
```

`'nearest'` walks up to the first ancestor whose `overflow-y` is `auto`, `scroll`
or `overlay`, whether or not it currently scrolls, and falls back to the page. A
wrapper carrying `overflow: auto` as a layout fix is therefore picked ahead of
the real scrollport, and the list then renders its first window and stops; name
the element outright where that happens. That is the same ancestor
[Sticky](Sticky.md) pins to, which is what a sticky header above the list needs:
the two have to answer to the same scrollport or they come apart on the first
scroll. It is resolved once and re-read only if that element leaves the DOM,
because walking the tree reads computed style.

Everything else works as before — the same windowing, the same `scrollToIndex`, the same keyboard model. What changes:

- **`height` no longer applies.** The list is as tall as its rows.
- **Set `estimatedRowHeight` to the real height** where the rows have one. Row heights are corrected as rows are measured, and here that correction resizes the *page*, so a guess that is wrong by 10px across 2000 rows moves the page's scrollbar under the user's hand. Rows of one fixed height cost nothing: the first measurement is right and the page height never moves again.
- **The header sticks to that scrollport**, which is the top of the viewport rather than the top of the list. Give the list `--vl-header-top` to clear a fixed app bar: `<VirtualList style={{ '--vl-header-top': '64px' }} … />`.
- **A frozen column layout wider than the list overflows the page sideways** instead of scrolling inside the list. A container cannot scroll one axis and not the other, and getting the vertical scrollbar back would undo the whole mode. Only reachable with `resizableColumns`.
- **Call `remeasure()`** when something above the list changes height without the list re-rendering. The list watches the scrollport's own content box, which covers most of it; an element that merely moves fires nothing.

**The list says so when it isn't virtualizing.** A viewport as tall as its own content renders every row, which is what a percentage `height` amounts to inside a parent with no definite height: the scroll container grows to fit the rows instead of clipping them. Nothing looks broken, the page is just slow. Past a hundred rows the list writes a `console.warn` naming that, which is the one failure mode of this component that is otherwise invisible. Passing `height` alongside `scrollParent` warns too, since the list then has no viewport to size.

Only the rendered rows are in the DOM, in this mode as in the other one, so the browser's own find-in-page and print reach the window on screen and not the rest of the list. That is the price of virtualization, and it is the reason to use it only where the row count earns it.

**Lazy loading:** `onLoadMore` fires when the user scrolls within `loadMoreThreshold` px of the bottom, **and** repeatedly whenever the rows don't fill the list's viewport. That second trigger matters because a list shorter than its own height can't be scrolled at all, so a first batch smaller than the viewport would otherwise be the last batch ever loaded. The list keeps asking for pages until the content overflows. Whichever trigger fires, one row count is asked at most once, so the loop ends as soon as a call appends nothing, and a page scrolled well past the end of the list does not go on asking with every scroll event. Gate the prop on your own "has more" flag to skip even that last probe:

```tsx
<VirtualList items={items} onLoadMore={hasMore ? loadNextPage : undefined} height={600} />
```

This needs the list to have a **bounded height** (the default `height="100%"` inside a sized parent, or an explicit `height`). With a height that grows with its content, there is no empty space to detect and only the scroll trigger applies. Nothing fires while `isLoading` is set, and only one load runs at a time.

**Slots:** `root` `header` `headerCell` `headerSort` `sortIcon` `resizeHandle` `headerToggle` `togglePopover` `toggleItem` `toggleReset` `body` `row` `cell` `selectCell` `activeRow` `loadingIndicator` `dropIndicator` `columnChip`

**Labels:** `selectRow` `selectAllRows` `toggleColumns` `columnsHidden(count)` `resetColumnWidths` — the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `VirtualListLabels`.

**In-row popovers:** Rows support inline popover content that overflows the row's bottom edge — e.g. a `Dropdown`, `InputDropdown`, `Tooltip`, or `Popover` whose list opens below its trigger. All four render their popover through a `BodyEnd` portal and track their trigger's position directly, so they always escape row/cell clipping and paint above the rows below, regardless of focus — no special CSS is needed on the row or cell for this. Cells still clip their own content by default (ellipsis / neighbour-spill guard); that's unaffected. This works in both list mode (`renderRow`) and table mode (`columns`). Stop the row's click handler from firing when interacting with the control (`onClick={(e) => e.stopPropagation()}` on the cell) if the row is also selectable.
