Skip to content

VirtualGrid

MD · 1950B gzip budget

Windowed grid for rendering thousands of items efficiently.

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

Preview

Live fixture
Preview locale

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-grid.json

Copies the source into your project.

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 React, { useCallback, useEffect, useRef, useState } from "react";

export interface VirtualGridProps<T> {
  items: T[];
  columns?: number;
  rowHeight?: number;
  gap?: number;
  renderItem: (item: T, index: number) => React.ReactNode;
  className?: string;
  ariaLabel?: string;
  overscan?: number;
}

export function VirtualGrid<T>({
  items,
  columns = 3,
  rowHeight = 200,
  gap = 16,
  renderItem,
  className = "",
  ariaLabel,
  overscan = 3,
}: VirtualGridProps<T>) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = useState(0);
  const [containerHeight, setContainerHeight] = useState(0);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const handleScroll = () => setScrollTop(container.scrollTop);
    const handleResize = () => setContainerHeight(container.clientHeight);

    container.addEventListener("scroll", handleScroll, { passive: true });

    let resizeObserver: ResizeObserver | null = null;
    if (typeof ResizeObserver !== "undefined") {
      resizeObserver = new ResizeObserver(handleResize);
      resizeObserver.observe(container);
    }

    handleResize();

    return () => {
      container.removeEventListener("scroll", handleScroll);
      resizeObserver?.disconnect();
    };
  }, []);

  const totalRows = Math.ceil(items.length / columns);
  const totalHeight = totalRows * (rowHeight + gap) - gap;

  const startRow = Math.max(0, Math.floor(scrollTop / (rowHeight + gap)) - overscan);
  const endRow = Math.min(
    totalRows,
    Math.ceil((scrollTop + containerHeight) / (rowHeight + gap)) + overscan
  );

  const visibleItems: { item: T; index: number; row: number; col: number }[] = [];

  for (let row = startRow; row < endRow; row++) {
    for (let col = 0; col < columns; col++) {
      const index = row * columns + col;
      if (index < items.length) {
        visibleItems.push({ item: items[index], index, row, col });
      }
    }
  }

  const cellWidth = `calc((100% - ${(columns - 1) * gap}px) / ${columns})`;

  return (
    <div
      ref={containerRef}
      role="list"
      aria-label={ariaLabel}
      className={className}
      style={{
        overflow: "auto",
        position: "relative",
        height: "100%",
      }}
    >
      <div
        aria-hidden="true"
        style={{
          height: totalHeight,
          position: "relative",
        }}
      >
        {visibleItems.map(({ item, index, row, col }) => {
          const top = row * (rowHeight + gap);
          const left = `calc(${col} * (${cellWidth} + ${gap}px))`;

          return (
            <div
              key={index}
              role="listitem"
              style={{
                position: "absolute",
                top,
                left,
                width: cellWidth,
                height: rowHeight,
              }}
            >
              {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