# Virtual List

> A React virtual list for long and endless lists at 60fps, with sticky group headers, infinite loading and scroll restoration.

- Type: React component (Tables & Lists)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add virtual-list`
- Docs: https://wingo-ui.com/components/virtual-list

## Interaction

Scroll through thousands of rows: only the rows on screen (plus a few past each edge) are in the DOM, so a flick stays smooth on a phone with native momentum and no scroll chaining into the page. With getGroup the current group's header (a letter, a day) stays pinned at the top on a frosted band and the next one pushes it up as it arrives. Near the end the next page loads on its own (loadMode auto), from a Load more button (button), or by pulling past the last row until the label reads Release to load and letting go (pull, on touch and trackpads; the indicator spins with the pull and is also a button). New rows fade in, a failed page shows an inline error row with Try again and keeps what loaded, and after the last page a quiet end line appears. Rows are list rows, so taps, swipe actions, long press menus and selection work as in List. Keyboard: Up / Down move between rows and scroll the next row into view even when it is not rendered yet, PageUp / PageDown jump 10 rows, Home / End go to the first and last loaded row; in selectable lists Space toggles, Shift + arrows extend and Ctrl / Cmd + A selects every row, rendered or not. A focused row stays in the DOM while it scrolls away. Screen readers hear the position in the set (unknown while more can load) and a polite announcement after each page. With persistKey the list opens where you left it after back navigation. Reduced motion drops the fades; the pull still follows the finger.

## Installation

```bash
npx wingo-ui@latest add virtual-list
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/virtual-list` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/virtual-list.tsx`

npm dependencies: `npm i @tanstack/react-virtual lucide-react motion`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [List](https://wingo-ui.com/components/list) ([Markdown](https://wingo-ui.com/components/list.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))

## Usage

```tsx
import { useRef } from "react"
import { ListItem } from "@/components/ui/list"
import { VirtualList, type VirtualListHandle } from "@/components/ui/virtual-list"

type Client = { id: string; name: string; city: string; balance: string }

const letter = (client: Client) => client.name.charAt(0).toLocaleUpperCase("en")

export function Clients({ clients, hasMore, loadPage }: { clients: Client[]; hasMore: boolean; loadPage: () => Promise<void> }) {
  const list = useRef<VirtualListHandle>(null)
  return (
    <div className="h-[560px]">
      <VirtualList
        aria-label="Clients"
        items={clients}
        getGroup={letter}
        renderItem={(client) => (
          <ListItem id={client.id} title={client.name} description={client.city} avatar={{ name: client.name }} value={client.balance} />
        )}
        hasMore={hasMore}
        onLoadMore={loadPage}
        persistKey="clients"
        handleRef={list}
        labels={{ end: "You reached the end" }}
      />
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `T[]` |  | Every loaded row. Append a page to load more; a shorter array (a filter) keeps the scroll position clamped. |
| `renderItem` | `(item: T, index: number) => ReactNode` |  | Renders one row, usually a <ListItem /> returned directly (not wrapped in your own component), so it inherits size and dividers and receives aria-posinset / aria-setsize. Keep images at a fixed size so rows measure right the first time. |
| `getItemKey?` | `(item: T, index: number) => string` | `"item.id"` | Stable row keys for measurement, selection and scroll restoration. Define it at module scope or with useCallback. |
| `getGroup?` | `(item: T) => string` |  | Consecutive items with the same key share a group header (32px, eyebrow style) that pins to the top while you are in that group. Letters for contacts, groupByDay keys for feeds. Unset: no headers. |
| `renderGroupHeader?` | `(group: string, firstItem: T) => ReactNode` | `"the group string"` | The header content: "A", "Today", "September 2026". |
| `emptyState?` | `ReactNode` | `"<EmptyState preset=\"empty\" size=\"sm\" />"` | Shown when there are no items, nothing is loading and nothing more can load. |
| `endMessage?` | `ReactNode` | `""` | The quiet line after the last page (hasMore false and onLoadMore set). Empty uses labels.end ("You have reached the end"); null hides it. |
| `variant?` | `"plain" \| "inset"` | `"plain"` | plain: rows on the page background. inset: one raised panel (the iOS grouped look) that is also the scroll box. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Row size passed to every ListItem: 44 / 56 / 64px, with matching avatars, header padding and skeletons. |
| `dividers?` | `"full" \| "inset" \| "none"` | `"inset"` | Hairlines between rows. inset starts after the icon or avatar; a group header starts a fresh run. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg"` | `"md"` | Corners of the inset panel: 0 / 12 / 16 / 24px. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Selection color (checkboxes, the check mark) and the Load more button's tint. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. |
| `scroll?` | `"self" \| "window"` | `"self"` | self: the list scrolls inside height. window: the page scrolls (useWindowVirtualizer), or the nearest ScrollContainerProvider (an app shell's content column, a demo frame) when there is one. |
| `height?` | `number \| string` | `"100%"` | The scroll box height with scroll="self" (a number is px, any CSS length works). The server renders the rows that fit a px height, so hydration never jumps. |
| `estimateSize?` | `number or ((index: number) => number)` | `"44 / 56 / 64 by size"` | A first guess per row. Rows are measured after render (one-line and two-line descriptions both fit); a close guess only avoids a jump. |
| `overscan?` | `number` | `8` | Rows rendered past each edge of the viewport. More hides blank flashes on very fast flicks; fewer keeps the DOM smaller. |
| `stickyOffset?` | `number` | `0` | px between the scroll edge and the pinned group header, e.g. the height of a sticky app bar above a window list. |
| `loading?` | `boolean` | `false` | The first load: skeleton rows in the real row shape (avatar, two lines) fill the viewport, and aria-busy is set. |
| `skeletonCount?` | `number` | `0` | Skeleton rows while loading. 0 fills the scroll box (height / row estimate). |
| `hasMore?` | `boolean` | `false` | More pages exist. Turns on the loading footer (auto), the Load more button or the pull indicator, and makes aria-setsize -1 (unknown). The demo pages in 50 clients at a time when it is on and shows all 5,000 when it is off. |
| `loadingMore?` | `boolean` | `false` | Controlled flag for a page in flight. Not needed when onLoadMore returns a promise: that sets it automatically. |
| `error?` | `ReactNode` |  | A failed page load. With rows: an inline error row with Try again under them (the loaded rows stay, automatic loading pauses). Without rows: the error empty state. true uses labels.error. |
| `onRetry?` | `() => (void \| Promise<unknown>)` |  | The Try again button of the error row. |
| `loadMode?` | `"auto" \| "button" \| "pull"` | `"auto"` | auto: loads when the last row comes within threshold px (no gesture needed, the default). button: a full-width Load more button that shows its own spinner. pull: at the end, pull past 64px (rubberband, touch and trackpad) and let go. |
| `threshold?` | `number` | `600` | px before the end at which auto mode asks for the next page, so it usually arrives before you get there. |
| `onLoadMore?` | `() => (void \| Promise<unknown>)` |  | Asks for the next page. A returned promise drives the loading state; calls while a page is loading are ignored. |
| `onEndReached?` | `() => void` |  | Fires once per page when the bottom of the last row comes into view (analytics). |
| `persistKey?` | `string` | `""` | Restores the scroll position (sessionStorage, anchored to a row so measured heights do not drift) when the list mounts again, e.g. after back navigation. Skipped when the first row changed. |
| `selectionMode?` | `"none" \| "single" \| "multiple"` | `"none"` | Selectable rows, like List: the list becomes a listbox with one Tab stop, checkboxes (multiple) or a check (single). Ctrl / Cmd + A selects every row, rendered or not. |
| `selectedKeys?` | `string[]` |  | The selected row keys (controlled). |
| `defaultSelectedKeys?` | `string[]` |  | The initial selection (uncontrolled). |
| `onSelectedKeysChange?` | `(keys: string[]) => void` |  | Called with the new selection, in row order. |
| `selectionIndicator?` | `"auto" \| "checkbox" \| "check" \| "highlight"` | `"auto"` | auto: a checkbox for multiple, a trailing check for single; highlight tints the row only. |
| `showChevron?` | `boolean` | `true` | Rows with href or onSelect end in a chevron. |
| `onAction?` | `(key: string, item: T) => void` |  | Row activation, after the row's own onSelect. |
| `handleRef?` | `RefObject<VirtualListHandle> or (handle: VirtualListHandle \| null) => void` |  | { scrollToIndex(index, { align, smooth }), scrollToKey(key, options), scrollToTop({ smooth }), getVirtualizer() }. Indexes count items, not group headers; smooth turns off under reduced motion. |
| `onRangeChange?` | `(range: { startIndex; endIndex; rendered; total }) => void` |  | The visible item indexes and how many rows are in the DOM, whenever that changes (a position readout, analytics). |
| `locale?` | `string` | `""` | Formats the loaded-count announcement. Empty uses UIProvider, then the page language. |
| `labels?` | `Partial<VirtualListLabels>` | `` "{ loadMore: \"Load more\", loading: \"Loading…\", end: \"You have reached the end\", pull: \"Pull to load more\", release: \"Release to load\", error: \"Could not load more\", retry: \"Try again\", empty: \"Nothing here yet\", loaded: (n, formatted) => `${formatted} more loaded` }" `` | Every string of the list itself; DEFAULT_VIRTUAL_LIST_LABELS is exported. |
| `listLabels?` | `Partial<ListLabels>` |  | Strings of the rows (the row menu button, the checkbox), passed to the list context. |
| `classNames?` | `Partial<Record<"content" \| "inner" \| "row" \| "groupHeader" \| "footer" \| "loading" \| "loadMore" \| "pull" \| "end" \| "error" \| "empty" \| "skeleton", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root (the scroll box with scroll="self"), merged last. |

## Parts

### DEFAULT_VIRTUAL_LIST_LABELS (constant)

The English strings: load more, loading, end of list, pull and release, error, retry, empty and the loaded announcement.

### VirtualListHandle (type)

What handleRef receives: scrollToIndex, scrollToKey, scrollToTop and getVirtualizer for the TanStack virtualizer.

### VirtualListRange (type)

What onRangeChange receives: { startIndex, endIndex, rendered, total } for the rows in view and in the DOM.

## Examples

- **Activity feed by day**: getGroup with groupByDay keys (Today, Yesterday…), pinned day headers and a Load more button.
- **Pull to load on a phone**: loadMode="pull": scroll to the end, pull up past the last row and let go. The row is also a button.
- **10,000 rows and a handle**: No paging, only ~20 rows in the DOM. handleRef jumps to any row or back to the top.
- **First load, a failed page, retry**: Skeleton rows on the first load; the third page fails once and shows an inline retry row.
- **Inset notifications, large rows**: variant="inset" with avatars, two-line descriptions measured per row, size="lg".
- **Window scroll**: scroll="window" follows the nearest ScrollContainerProvider (here the demo box) with stickyOffset under the app bar.
- **Back navigation keeps your place**: persistKey: open a client, go back, and the list is where you left it.

## See also

- [List](https://wingo-ui.com/components/list) ([Markdown](https://wingo-ui.com/components/list.md))
- [Data Table](https://wingo-ui.com/components/data-table) ([Markdown](https://wingo-ui.com/components/data-table.md))
- [Pagination](https://wingo-ui.com/components/pagination) ([Markdown](https://wingo-ui.com/components/pagination.md))
- [Timeline](https://wingo-ui.com/components/timeline) ([Markdown](https://wingo-ui.com/components/timeline.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))

---

Canonical: https://wingo-ui.com/components/virtual-list
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
