Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelogContact
Components
Buttons & Actions
ButtonButton GroupCopy ButtonDelete ButtonTheme ToggleToggleToggle Group
Inputs
Avatar UploadCalendarCheckboxColor PickerComboboxCountry SelectCron PickerCurrency InputDate PickerDate Range PickerDate Time PickerDuration PickerEmail InputEmoji PickerFile UploadFilter BuilderInputInput GroupListboxMasked InputMention InputMonth PickerMulti SelectNumber InputOTP InputPassword InputPhone InputRadio GroupRatingSearch InputSegmented ControlSelectSignature PadSliderSwitchTag InputTextareaTime PickerTimezone SelectTree Select
Forms
Address InputBooking WidgetCSV ImportFieldForm WizardInline EditInvoice line itemsRich Text EditorSchema Builder
Navigation
Back To TopBounce SidebarBreadcrumbCommand MenuGlobal SearchGooey NavHook SidebarNav MenuNav TabsPaginationProximity SidebarScroll ProgressSidebarStepsTable Of ContentsTabsTopbarUser MenuWorkspace Switcher
Overlays
Alert DialogContext MenuDialogDrawerDropdown MenuFamily DrawerHover CardPopoverShare DialogSheetShortcuts DialogTooltipTour
Feedback
AlertBannerCookie ConsentEmoji ReactionEmpty StateNotification BellNotification CenterProgressRoute ProgressSetup ChecklistSkeletonSpinnerToast
Data Display
Approval FlowAvatarBadgeCalendar ViewCode BlockComment ThreadContact cardCountdownDescription ListDiff ViewerFile ListFile managerFolderGanttJSON ViewerKbdLog ViewerMarkdownPresenceSchedulerTagTimelineTree View
Tables & Lists
Audit LogData TableDeal PipelineEditable GridKanbanListMembers tablePermissions MatrixTableTask ListVirtual List
Charts & Stats
Animated CounterArea chartBar chartDonut chartFunnel chartGaugeGitHub ActivityHeatmap calendarLeaderboardLine chartRadar chartRadial barSparklineStat CardTrackerTrend badge
Layout
AccordionApp ShellCardCollapsibleDetail LayoutEntity HeaderPage HeaderScroll AreaSeparatorSettings LayoutSplit ViewToolbar
Media
Audio PlayerCarouselCSV ViewerDOCX EditorDOCX ViewerExcel ViewerFile SystemImage compareImage CropperImage GalleryImage LightboxLocation MapPDF ViewerPowerPoint ViewerQR CodeStep PlayerVideo Player
AI Kit
Agent Status CardChat MessageChat ThreadChat WidgetConversation ListFluid OrbGrid RevealMatrix OrbModel PickerPrompt InputReasoning IndicatorRule BuilderSuggestion ChipsTool Call TimelineUsage MeterVoice InputWorkflow Canvas
Text & Effects
Background PatternGravity LettersMarqueeTerminalText AnimateText Rotate
Mobile
Action SheetBottom Tab BarFABHaptic ButtonHero HeaderInstall PromptLarge Title HeaderNotification BannerOnboarding CarouselPicker WheelPull To RefreshSlide To ConfirmSticky CTA BarSwipe DeckSwipeable Tabs
Commerce
Cart drawerCategory pickerCheckout stepsCoupon inputFavorite buttonFilter panelListing cardListing gridOrder trackerPricePrice rangePricing tableProduct galleryQuantity stepperReviewsSeller card
Marketing Sections
App Download SectionBlog Cards SectionCTA SectionFAQ SectionFeature GridFeature ShowcaseHero SectionIntegrations SectionLogo CloudMarketing FooterMarketing HeaderMarketing SectionNewsletter SectionPricing SectionStats SectionSteps SectionTeam SectionTestimonials SectionTimeline SectionVideo Section
Blocks
Admin Dashboard PageAdmin ShellAI Chat PageAPI Keys PageAutomation Builder PageCheckout PageCRM Contact PageCRM Pipeline PageError PagesInbox PageInvoice PageKPI DashboardListing Detail PageLogin PageMarketplace Home PageMarketplace Search PageMarketplace ShellMobile App HomeOnboarding FlowOrder History PagePost Listing WizardPricing PageProfile PageRegister PageSettings PageUsers Admin Page
Templates
About PageAcademy PageAccessibility PageAffiliate PageAgency PageAI Product PageAppointment Booking PageBlog PageBlog Post PageBug Bounty PageCareers PageChangelog PageCommunity PageCompare PageContact PageCustomer Story PageCustomers PageDemo Booking PageDevelopers PageDocs Home PageDownload PageEnterprise PageEvents PageExperts PageFAQ PageFeature PageGlossary PageHelp Article PageHelp Center PageHome Services PageImpact PageIntegration Detail PageIntegrations PageJob Detail PageLanding PageLaunch PageLegal PageManifesto PagePortfolio PagePress PageProduct Tour PagePromo PageReal Estate PageReferral PageResearch Report PageRestaurant PageRoadmap PageROI Calculator PageSecurity PageSolutions PageStartups PageStatus PageStorefront PageSwitch PageTemplate Gallery PageTravel PageWaitlist PageYear in Review Page
Hooks & Utilities
AI ChatChart kitCommerceDate utilsFile exportFile typesFiltersForm validationHapticsInvoicingMotion presetsNavigationNumber formatRelative timeRomanian locationsSpreadsheetText matchTonesUIProvideruseClipboarduseConsentuseControllableStateuseCooldownuseDebounceuseKeyboardInsetuseLocaleuseLongPressuseMediaControlsuseMediaQueryuseOverflowItemsusePersistedStateuseScrollEdgesuseScrollStateuseSelectionuseShortcutuseStickToBottom
GalleryTheme

Tables & Lists

Virtual List

Tables & Lists

Virtual List React component for Tailwind CSS v4

Task ListAnimated Counter
Live props
Content
endMessage
Appearance
variant
size
dividers
radius
tone
color
Layout
scroll
height
overscan8
stickyOffset0px
State
loading
skeletonCountFill
hasMore
loadingMore
Behavior
loadMode
threshold600px
persistKey
selectionMode
selectionIndicator
showChevron
locale
Code
import { VirtualList } from "@/components/ui/virtual-list"

<VirtualList />
  1. Home
  2. Components
  3. Tables & Lists
  4. Virtual List
Pro

Virtual List React component for Tailwind CSS v4

v1.0.0·Added 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-list

Virtual 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) => 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.

  • getItemKeydefault "item.id"
    (item: T, index: number) => string

    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.

  • renderGroupHeaderdefault "the group string"
    (group: string, firstItem: T) => ReactNode

    The header content: "A", "Today", "September 2026".

  • emptyStatedefault "<EmptyState preset="empty" size="sm" />"
    ReactNode

    Shown when there are no items, nothing is loading and nothing more can load.

  • endMessagedefault ""
    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.

  • 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 ""
    string

    Any 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 | string

    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.

  • 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.

  • overscandefault 8
    number

    Rows rendered past each edge of the viewport. More hides blank flashes on very fast flicks; fewer keeps the DOM smaller.

  • stickyOffsetdefault 0
    number

    px between the scroll edge and the pinned group header, e.g. the height of a sticky app bar above a window list.

  • loadingdefault false
    boolean

    The first load: skeleton rows in the real row shape (avatar, two lines) fill the viewport, and aria-busy is set.

  • skeletonCountdefault 0
    number

    Skeleton rows while loading. 0 fills the scroll box (height / row estimate).

  • hasMoredefault false
    boolean

    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.

  • loadingMoredefault false
    boolean

    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.

  • 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.

  • thresholddefault 600
    number

    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).

  • persistKeydefault ""
    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.

  • 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.

  • 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.

  • selectionIndicatordefault "auto"
    "auto" | "checkbox" | "check" | "highlight"

    auto: a checkbox for multiple, a trailing check for single; highlight tints the row only.

  • showChevrondefault true
    boolean

    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).

  • localedefault ""
    string

    Formats 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.

  • 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.

Dependencies

@tanstack/react-virtuallucide-reactmotionbuttonempty-statelistmotionnumber-formatskeletonspinnerui-configuse-localeuse-persisted-stateuse-scroll-state

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

ListData TablePaginationTimelineSkeleton

More React table and list components

KanbanDeal PipelineAudit LogEditable GridMembers tablePermissions Matrix
All 11 React table and list components

Articles using this component

  • React Log Viewer: Stream 100,000 Lines With Regex Search

    Component guides · Oct 16, 2026 · 8 min read

  • React Virtualized Table: 100k Rows With TanStack Virtual

    Component guides · Oct 16, 2026 · 12 min read

  • React Pull to Refresh in a PWA Without Fighting the Browser

    Mobile UI in React · Oct 9, 2026 · 11 min read

PreviousTask ListNext Animated Counter
Clients5,000 clients · Northwind CRM
A
You have reached the end

Rendered: 0 of 5,000· visible 1–1· overscan 8loadMode auto · threshold 600px · locale en-US