DesignX
Core@paxeer/core/virtual-listimplemented

Virtual list

Windowed list for dense IDE, tape, and roster rows.

Install

$pnpm exec designx add @paxeer/core/virtual-list

Source code

components/designx/core/virtual-list.tsxtsx
"use client";

import { type ReactNode, useMemo, useState } from "react";
import { cn } from "../lib/cn";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./empty";

export interface VirtualListProps<T> {
  items: T[];
  itemHeight: number;
  height: number;
  overscan?: number;
  getItemKey?: (item: T, index: number) => string | number;
  renderItem: (item: T, index: number) => ReactNode;
  label: string;
  className?: string;
  empty?: ReactNode;
}

export function VirtualList<T>({
  items,
  itemHeight,
  height,
  overscan = 4,
  getItemKey,
  renderItem,
  label,
  className,
  empty,
}: VirtualListProps<T>) {
  const [scrollTop, setScrollTop] = useState(0);

  const { start, end, offsetY } = useMemo(() => {
    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);
    return {
      start: startIndex,
      end: endIndex,
      offsetY: startIndex * itemHeight,
    };
  }, [height, itemHeight, items.length, overscan, scrollTop]);

  if (items.length === 0) {
    return (
      <Empty className={cn("p-6", className)} style={{ height }} role="list" aria-label={label}>
        <EmptyHeader>
          <EmptyTitle>No rows</EmptyTitle>
          <EmptyDescription>{typeof empty === "string" ? empty : "Nothing to show yet."}</EmptyDescription>
        </EmptyHeader>
      </Empty>
    );
  }

  return (
    <div
      role="list"
      aria-label={label}
      tabIndex={0}
      className={cn("dx-focus-ring dx-scroll-region overflow-y-auto", className)}
      style={{ height }}
      onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
    >
      <div style={{ height: items.length * itemHeight, position: "relative" }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {items.slice(start, end).map((item, offset) => {
            const index = start + offset;
            return (
              <div
                key={getItemKey ? getItemKey(item, index) : index}
                role="listitem"
                style={{ height: itemHeight }}
              >
                {renderItem(item, index)}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

Dependencies

Registry dependencies