VirtualGrid
MD · 1950B gzip budgetWindowed 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 fixtureTry 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.jsonCopies 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