Virtual List React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React virtual list for long and endless lists at 60fps, with sticky group headers, infinite loading and scroll restoration.
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.
Virtual List is a Pro React component in the React table and list components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add virtual-list, together with Button, Empty State, List, Motion presets, Number format and 6 more and the npm packages @tanstack/react-virtual, lucide-react and motion.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add virtual-listVirtual List is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import VirtualList from @/components/ui/virtual-list.
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
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) => ReactNodeRenders 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.
getItemKeydefault"item.id"(item: T, index: number) => stringStable row keys for measurement, selection and scroll restoration. Define it at module scope or with useCallback.
getGroup(item: T) => stringConsecutive 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.
renderGroupHeaderdefault"the group string"(group: string, firstItem: T) => ReactNodeThe header content: "A", "Today", "September 2026".
emptyStatedefault"<EmptyState preset="empty" size="sm" />"ReactNodeShown when there are no items, nothing is loading and nothing more can load.
endMessagedefault""ReactNodeThe quiet line after the last page (hasMore false and onLoadMore set). Empty uses labels.end ("You have reached the end"); null hides it.
variantdefault"plain""plain" | "inset"plain: rows on the page background. inset: one raised panel (the iOS grouped look) that is also the scroll box.
sizedefault"md""sm" | "md" | "lg"Row size passed to every ListItem: 44 / 56 / 64px, with matching avatars, header padding and skeletons.
dividersdefault"inset""full" | "inset" | "none"Hairlines between rows. inset starts after the icon or avatar; a group header starts a fresh run.
radiusdefault"md""none" | "sm" | "md" | "lg"Corners of the inset panel: 0 / 12 / 16 / 24px.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"Selection color (checkboxes, the check mark) and the Load more button's tint.
colordefault""stringAny CSS color; replaces the tone.
scrolldefault"self""self" | "window"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.
heightdefault"100%"number | stringThe 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.
estimateSizedefault"44 / 56 / 64 by size"number or ((index: number) => number)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.
overscandefault8numberRows rendered past each edge of the viewport. More hides blank flashes on very fast flicks; fewer keeps the DOM smaller.
stickyOffsetdefault0numberpx between the scroll edge and the pinned group header, e.g. the height of a sticky app bar above a window list.
loadingdefaultfalsebooleanThe first load: skeleton rows in the real row shape (avatar, two lines) fill the viewport, and aria-busy is set.
skeletonCountdefault0numberSkeleton rows while loading. 0 fills the scroll box (height / row estimate).
hasMoredefaultfalsebooleanMore 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.
loadingMoredefaultfalsebooleanControlled flag for a page in flight. Not needed when onLoadMore returns a promise: that sets it automatically.
errorReactNodeA 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.
loadModedefault"auto""auto" | "button" | "pull"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.
thresholddefault600numberpx 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() => voidFires once per page when the bottom of the last row comes into view (analytics).
persistKeydefault""stringRestores 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.
selectionModedefault"none""none" | "single" | "multiple"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.
selectedKeysstring[]The selected row keys (controlled).
defaultSelectedKeysstring[]The initial selection (uncontrolled).
onSelectedKeysChange(keys: string[]) => voidCalled with the new selection, in row order.
selectionIndicatordefault"auto""auto" | "checkbox" | "check" | "highlight"auto: a checkbox for multiple, a trailing check for single; highlight tints the row only.
showChevrondefaulttruebooleanRows with href or onSelect end in a chevron.
onAction(key: string, item: T) => voidRow activation, after the row's own onSelect.
handleRefRefObject<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 }) => voidThe visible item indexes and how many rows are in the DOM, whenever that changes (a position readout, analytics).
localedefault""stringFormats the loaded-count announcement. Empty uses UIProvider, then the page language.
labelsdefault"{ 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` }"Partial<VirtualListLabels>Every string of the list itself; DEFAULT_VIRTUAL_LIST_LABELS is exported.
listLabelsPartial<ListLabels>Strings of the rows (the row menu button, the checkbox), passed to the list context.
classNamesPartial<Record<"content" | "inner" | "row" | "groupHeader" | "footer" | "loading" | "loadMore" | "pull" | "end" | "error" | "empty" | "skeleton", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root (the scroll box with scroll="self"), merged last.
Parts
DEFAULT_VIRTUAL_LIST_LABELS
constantThe English strings: load more, loading, end of list, pull and release, error, retry, empty and the loaded announcement.
VirtualListHandle
typeWhat handleRef receives: scrollToIndex, scrollToKey, scrollToTop and getVirtualizer for the TanStack virtualizer.
VirtualListRange
typeWhat 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.
Dependencies
Files
- components/ui/virtual-list.tsx
FAQ
Is the React Virtual List component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Virtual List?
Run npx wingo-ui@latest add virtual-list. It also adds the Wingo UI items it builds on (Button, Empty State, List, Motion presets, Number format and 6 more) and the npm packages @tanstack/react-virtual, lucide-react and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/virtual-list.
How does Virtual List behave on phones?
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.
See also
More React table and list components
Articles using this component
- React Log Viewer: Stream 100,000 Lines With Regex Search
Component guides8 min read
- React Virtualized Table: 100k Rows With TanStack Virtual
Component guides12 min read
- React Pull to Refresh in a PWA Without Fighting the Browser
Mobile UI in React11 min read