Bloom Logo

Virtualized List

A high-performance scrollable list that renders only visible items, supporting tens of thousands of rows at 60 FPS with minimal memory footprint.

importimport { VirtualizedList } from "@/components/ui/virtualizedList/virtualizedList";
$npx @bloomui-react/cli add virtualizedList
virtualizedList.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface VirtualizedListRef {
  scrollToIndex: (index: number) => void;
}

export interface VirtualizedListProps<T = any> {
  items: T[];
  itemHeight?: number;
  getItemHeight?: (item: T, index: number) => number;
  height: number;
  renderItem: (item: T, index: number) => React.ReactNode;
  overscan?: number;
  onEndReached?: () => void;
  endReachedThreshold?: number;
  className?: string;
}

const VirtualizedListRender = <T,>(
  {
    items,
    itemHeight = 40,
    getItemHeight,
    height,
    renderItem,
    overscan = 5,
    onEndReached,
    endReachedThreshold = 100,
    className,
  }: VirtualizedListProps<T>,
  ref: React.Ref<VirtualizedListRef>,
) => {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = React.useState(0);
  const isEndReachedFiredRef = React.useRef(false);

  const calculateHeight = (index: number) => {
    if (getItemHeight) {
      return getItemHeight(items[index], index);
    }
    return itemHeight;
  };

  const itemOffsets = React.useMemo(() => {
    const offsets: number[] = [0];
    for (let i = 0; i < items.length; i++) {
      offsets.push(offsets[i] + calculateHeight(i));
    }
    return offsets;
  }, [items, getItemHeight, itemHeight]);

  const totalHeight = itemOffsets[items.length] || 0;

  React.useImperativeHandle(ref, () => ({
    scrollToIndex: (index: number) => {
      if (containerRef.current && index >= 0 && index < items.length) {
        containerRef.current.scrollTop = itemOffsets[index];
      }
    },
  }));

  const handleScroll = React.useCallback(() => {
    if (containerRef.current) {
      const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
      setScrollTop(scrollTop);

      if (
        onEndReached &&
        scrollHeight - (scrollTop + clientHeight) < endReachedThreshold
      ) {
        if (!isEndReachedFiredRef.current) {
          isEndReachedFiredRef.current = true;
          onEndReached();
        }
      } else {
        isEndReachedFiredRef.current = false;
      }
    }
  }, [onEndReached, endReachedThreshold]);

  let startIndex = 0;
  while (
    startIndex < items.length &&
    itemOffsets[startIndex + 1] <= scrollTop
  ) {
    startIndex++;
  }
  startIndex = Math.max(0, startIndex - overscan);

  let endIndex = startIndex;
  while (
    endIndex < items.length &&
    itemOffsets[endIndex] < scrollTop + height
  ) {
    endIndex++;
  }
  endIndex = Math.min(items.length - 1, endIndex + overscan);

  const visibleItems = [];
  for (let i = startIndex; i <= endIndex; i++) {
    if (i >= 0 && i < items.length) {
      visibleItems.push(
        <div
          key={i}
          style={{
            position: "absolute",
            top: itemOffsets[i],
            height: calculateHeight(i),
            left: 0,
            right: 0,
          }}
        >
          {renderItem(items[i], i)}
        </div>,
      );
    }
  }

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      className={cn(
        "overflow-y-auto rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900",
        className,
      )}
      style={{ height, position: "relative" }}
    >
      <div style={{ height: totalHeight, position: "relative" }}>
        {visibleItems}
      </div>
    </div>
  );
};

export const VirtualizedList = React.forwardRef(VirtualizedListRender) as <T>(
  props: VirtualizedListProps<T> & { ref?: React.Ref<VirtualizedListRef> },
) => React.ReactElement;

Default

A list of 10,000 items rendered with smooth scrolling. Only visible rows are mounted in the DOM.

10,000 items — scroll to test

#1Item 1
#2Item 2
#3Item 3
#4Item 4
#5Item 5
#6Item 6
#7Item 7
#8Item 8
#9Item 9
#10Item 10
#11Item 11
#12Item 12
#13Item 13
#14Item 14

Custom Render

Use the renderItem prop to display complex row layouts with avatars and badges.

items: any[]itemHeight: numberrenderItem: (item: any, index: number) => React.ReactNode

5,000 users

1

User 1

user1@example.com

Admin
2

User 2

user2@example.com

Editor
3

User 3

user3@example.com

Viewer
4

User 4

user4@example.com

Admin
5

User 5

user5@example.com

Editor
6

User 6

user6@example.com

Viewer
7

User 7

user7@example.com

Admin
8

User 8

user8@example.com

Editor
9

User 9

user9@example.com

Viewer
10

User 10

user10@example.com

Admin
11

User 11

user11@example.com

Editor
12

User 12

user12@example.com

Viewer

Dynamic Item Height Calculation

Calculate varying row heights dynamically using getItemHeight={(item, index) => number}.

getItemHeight: (item, index) => number
Row #1Compact Row (44px)
Row #2Expanded Tall Row (64px)
Row #3Compact Row (44px)
Row #4Expanded Tall Row (64px)
Row #5Compact Row (44px)
Row #6Expanded Tall Row (64px)
Row #7Compact Row (44px)
Row #8Expanded Tall Row (64px)
Row #9Compact Row (44px)
Row #10Expanded Tall Row (64px)
Row #11Compact Row (44px)
Row #12Expanded Tall Row (64px)

Scroll to Index Method

Imperatively jump to any item index in the virtualized list using listRef.current.scrollToIndex(index).

scrollToIndex: (index: number) => void (via ref)
#1Item 1
#2Item 2
#3Item 3
#4Item 4
#5Item 5
#6Item 6
#7Item 7
#8Item 8
#9Item 9
#10Item 10
#11Item 11
#12Item 12
#13Item 13

Infinite Scroll Loading

Automatically trigger data fetching when scrolling near bottom threshold with onEndReached.

onEndReached: () => voidendReachedThreshold?: number
#1Item 1
#2Item 2
#3Item 3
#4Item 4
#5Item 5
#6Item 6
#7Item 7
#8Item 8
#9Item 9
#10Item 10
#11Item 11
#12Item 12
#13Item 13
API Reference
PropTypeDefaultDescription
itemsT[]Array of data items to render.
itemHeightnumberFixed height in pixels for each row.
heightnumberContainer height in pixels.
renderItem(item: T, index: number) => ReactNodeRender function for each row.
overscannumber5Extra items rendered above/below viewport for smooth scrolling.
classNamestringAdditional CSS classes for the container.