Skip to content

VirtualList

MD · 1950B gzip budget

Renders only visible rows plus overscan, for smooth scrolling on low-end devices.

Category
Data display
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale
Row 0
Row 1
Row 2
Row 3
Row 4
Row 5
Row 6
Row 7
Row 8
Row 9
Row 10
Row 11

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/virtual-list.json

Copies the source into your project. Pulls in 1 shared registry item: cn.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useMemo, useRef, useState } from "react";
import { cn } from "../lib/cn";

export interface VirtualListProps<T> {
  items: T[];
  itemHeight: number;
  height: number;
  renderItem: (item: T, index: number) => React.ReactNode;
  className?: string;
}

/**
 * Renders only the rows near the viewport, plus a small overscan buffer —
 * the difference between a smooth scroll and a dropped-frame one on the
 * low-end Android hardware most of the world actually browses on, once a
 * list passes a few hundred rows.
 *
 * A `role="list"`/`role="listitem"` pair rather than semantic `<ul>`/`<li>`:
 * the scroll container's real children do not span the full list, so a plain
 * `<ul>` would misreport its item count to assistive technology. `aria-setsize`
 * / `aria-posinset` on each row restore that count explicitly.
 */
export function VirtualList<T>({ items, itemHeight, height, renderItem, className }: VirtualListProps<T>) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  const overscan = 4;
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const visibleCount = Math.ceil(height / itemHeight) + overscan * 2;
  const endIndex = Math.min(items.length, startIndex + visibleCount);

  const visible = useMemo(() => items.slice(startIndex, endIndex), [items, startIndex, endIndex]);

  return (
    <div
      ref={containerRef}
      role="list"
      onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
      className={cn("overflow-y-auto", className)}
      style={{ height }}
    >
      <div style={{ height: items.length * itemHeight, position: "relative" }}>
        {visible.map((item, i) => {
          const index = startIndex + i;
          return (
            <div
              key={index}
              role="listitem"
              aria-setsize={items.length}
              aria-posinset={index + 1}
              style={{ position: "absolute", top: index * itemHeight, insetInlineStart: 0, insetInlineEnd: 0, height: itemHeight }}
            >
              {renderItem(item, index)}
            </div>
          );
        })}
      </div>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation