Skip to main content

Virtual List Widget

The virtual list widget efficiently renders large lists by only creating VNodes for items within the visible viewport plus an overscan buffer. This enables smooth rendering of datasets with 100,000+ items.

Basic Usage

import { ui } from "@rezi-ui/core";

const items = Array.from({ length: 10000 }, (_, i) => ({
  id: String(i),
  name: `Item ${i}`,
}));

ui.virtualList({
  id: "my-list",
  items,
  itemHeight: 1,
  renderItem: (item, index, focused) => {
    return ui.text(item.name, {
      style: focused ? { bg: { r: 50, g: 100, b: 150 } } : undefined,
    });
  },
});

Item Height Modes

Fixed Height (Fastest)

When all items have the same height, use a fixed number:
ui.virtualList({
  id: "fixed-list",
  items,
  itemHeight: 1, // All items are 1 cell tall
  renderItem: (item) => ui.text(item.name),
});
Performance: O(1) offset calculation via multiplication. Best for maximum performance.

Variable Height (Pre-computed)

When item heights are known in advance:
interface Item {
  id: string;
  content: string;
  lines: number; // Pre-computed height
}

ui.virtualList({
  id: "variable-list",
  items,
  itemHeight: (item) => item.lines,
  renderItem: (item) => {
    return ui.box({ p: 1, h: item.lines }, [
      ui.text(item.content, { wrap: true }),
    ]);
  },
});
Performance: O(n) cumulative offset array with binary search for visible range.

Estimated Height (Dynamic)

For items with unknown heights, provide an estimate. The list measures actual heights and corrects offsets:
ui.virtualList({
  id: "estimated-list",
  items,
  estimateItemHeight: 3, // Estimate 3 cells per item
  renderItem: (item, index, focused) => {
    // Actual height varies based on content
    return ui.box({ p: 1 }, [
      ui.text(item.title, { variant: "heading" }),
      ui.text(item.description, { wrap: true }),
    ]);
  },
});
Performance: Initial layout uses estimates, then corrects with measurements. Slight overhead but handles dynamic content.

Keyboard Navigation

Built-in Navigation

Enabled by default:
ui.virtualList({
  id: "navigable-list",
  items,
  itemHeight: 1,
  renderItem: (item, index, focused) => {
    return ui.text(item.name, {
      style: focused ? { bg: { r: 50, g: 100, b: 150 } } : undefined,
    });
  },
  keyboardNavigation: true, // Default
});
Supported keys:
  • Arrow Up/Down: Move selection
  • Page Up/Down: Jump by viewport height
  • Home/End: Jump to first/last item

Wrap Around

ui.virtualList({
  id: "wrap-list",
  items,
  itemHeight: 1,
  renderItem,
  wrapAround: true, // End → Start, Start → End
});

Disable Navigation

ui.virtualList({
  id: "no-nav-list",
  items,
  itemHeight: 1,
  renderItem,
  keyboardNavigation: false,
});

Selection Handling

import { defineWidget } from "@rezi-ui/core";

const MyList = defineWidget((ctx) => {
  const [items] = ctx.useState(data);

  const handleSelect = (item: Item, index: number) => {
    console.log(`Selected item ${index}:`, item);
    // Open detail view, trigger action, etc.
  };

  return ui.virtualList({
    id: "selectable-list",
    items,
    itemHeight: 1,
    renderItem: (item, index, focused) => {
      return ui.text(item.name, {
        style: focused ? { bg: { r: 50, g: 100, b: 150 } } : undefined,
      });
    },
    onSelect: handleSelect,
  });
});

Scroll Management

Controlled Scroll

import { defineWidget } from "@rezi-ui/core";

const MyList = defineWidget((ctx) => {
  const [scrollTop, setScrollTop] = ctx.useState(0);

  return ui.column({ gap: 1 }, [
    ui.text(`Scroll position: ${scrollTop}`),
    ui.virtualList({
      id: "controlled-list",
      items,
      itemHeight: 1,
      renderItem,
      onScroll: (newScrollTop, visibleRange) => {
        setScrollTop(newScrollTop);
        console.log(`Visible items: ${visibleRange[0]} - ${visibleRange[1]}`);
      },
    }),
  ]);
});

Scroll Direction

Control mouse wheel behavior:
ui.virtualList({
  id: "natural-scroll-list",
  items,
  itemHeight: 1,
  renderItem,
  scrollDirection: "natural", // Trackpad-style (default: "traditional")
});

Overscan Buffer

Control how many items to render outside the visible viewport:
ui.virtualList({
  id: "overscan-list",
  items,
  itemHeight: 1,
  renderItem,
  overscan: 5, // Render 5 items above and below viewport (default: 3)
});
Higher overscan:
  • Pro: Smoother scrolling (less pop-in)
  • Con: More VNodes rendered
Lower overscan:
  • Pro: Fewer VNodes (better for very complex items)
  • Con: Slight pop-in during fast scrolling

Large Dataset Example (100K+ Items)

import { defineWidget } from "@rezi-ui/core";

interface LogEntry {
  id: string;
  timestamp: number;
  level: "info" | "warn" | "error";
  message: string;
}

const LogViewer = defineWidget((ctx) => {
  const [logs] = ctx.useState<LogEntry[]>(() => {
    // Generate 100,000 log entries
    return Array.from({ length: 100_000 }, (_, i) => ({
      id: String(i),
      timestamp: Date.now() - i * 1000,
      level: ["info", "warn", "error"][i % 3] as LogEntry["level"],
      message: `Log entry ${i}`,
    }));
  });

  return ui.virtualList({
    id: "log-list",
    items: logs,
    itemHeight: 1,
    overscan: 10,
    renderItem: (log, index, focused) => {
      const levelColor =
        log.level === "error"
          ? { r: 255, g: 80, b: 80 }
          : log.level === "warn"
            ? { r: 255, g: 200, b: 50 }
            : { r: 150, g: 150, b: 150 };

      return ui.row({ gap: 1 }, [
        ui.text(new Date(log.timestamp).toLocaleTimeString(), {
          style: { fg: { r: 100, g: 100, b: 100 } },
        }),
        ui.text(log.level.toUpperCase(), {
          style: { fg: levelColor },
        }),
        ui.text(log.message),
      ]);
    },
    onSelect: (log) => {
      console.log("Selected log:", log);
    },
  });
});
Performance: Renders instantly, scrolls at 60 FPS, uses minimal memory.

Custom Selection Style

ui.virtualList({
  id: "styled-list",
  items,
  itemHeight: 1,
  renderItem: (item, index, focused) => {
    return ui.text(item.name);
  },
  selectionStyle: {
    bg: { r: 70, g: 130, b: 180 },
    fg: { r: 255, g: 255, b: 255 },
    bold: true,
  },
});

Complex Item Rendering

interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  priority: "low" | "medium" | "high";
}

ui.virtualList({
  id: "task-list",
  items: tasks,
  estimateItemHeight: 3, // Multi-line items
  renderItem: (task, index, focused) => {
    const priorityColor =
      task.priority === "high"
        ? { r: 255, g: 80, b: 80 }
        : task.priority === "medium"
          ? { r: 255, g: 200, b: 50 }
          : { r: 150, g: 150, b: 150 };

    return ui.box(
      {
        p: 1,
        border: "single",
        style: focused ? { bg: { r: 40, g: 40, b: 60 } } : undefined,
      },
      [
        ui.row({ gap: 1 }, [
          ui.text(task.completed ? "✓" : "○", {
            style: { fg: task.completed ? { r: 100, g: 200, b: 100 } : undefined },
          }),
          ui.text(task.title, {
            variant: "heading",
            style: task.completed ? { dim: true } : undefined,
          }),
          ui.spacer({ flex: 1 }),
          ui.badge({ text: task.priority, variant: "default" }),
        ]),
        ui.text(task.description, {
          style: { fg: { r: 150, g: 150, b: 150 } },
        }),
      ],
    );
  },
  onSelect: (task) => {
    // Toggle task completion
  },
});

Performance Characteristics

MetricFixed HeightVariable HeightEstimated Height
Initial render~5ms~5ms~5ms
Scroll update~1ms~2ms~2ms
Offset calculationO(1)O(n) + binary searchO(n) + corrections
Memory usageO(viewport + overscan)O(viewport + overscan + offset array)O(viewport + overscan + offset array + measurements)
Best forUniform itemsKnown heightsDynamic content

Comparison with Regular Rendering

// WITHOUT virtualization (slow for large lists)
function slowList(items: Item[]) {
  return ui.column({ gap: 0 }, items.map((item) => ui.text(item.name)));
}
// Problem: Creates 100,000 VNodes, slow render, high memory

// WITH virtualization (fast)
function fastList(items: Item[]) {
  return ui.virtualList({
    id: "fast-list",
    items,
    itemHeight: 1,
    renderItem: (item) => ui.text(item.name),
  });
}
// Solution: Creates ~30 VNodes (viewport size), instant render, low memory

Props Reference

VirtualListProps

PropTypeDefaultDescription
idstringRequiredWidget identifier
itemsreadonly T[]RequiredData array to render
itemHeightnumber | (item: T, index: number) => numberFixed or computed height
estimateItemHeightnumber | (item: T, index: number) => numberHeight estimate for dynamic content
measureItemHeight(item: T, index: number, ctx: MeasureCtx) => numberCustom measurement function
renderItem(item: T, index: number, focused: boolean) => VNodeRequiredItem render function
overscannumber3Items to render outside viewport
keyboardNavigationbooleantrueEnable arrow key navigation
wrapAroundbooleanfalseWrap selection end→start
scrollDirection"natural" | "traditional""traditional"Mouse wheel direction
selectionStyleTextStyleFocused item style
onScroll(scrollTop: number, visibleRange: [number, number]) => voidScroll callback
onSelect(item: T, index: number) => voidSelection callback (Enter key)
focusablebooleantrueInclude in tab order
accessibleLabelstringAccessibility label
focusConfigFocusConfigFocus appearance
  • Table - Virtualized table with columns
  • Tree - Hierarchical virtualized lists
  • Logs Console - High-volume log streaming

Location in Source

  • Implementation: packages/core/src/widgets/virtualList.ts
  • Types: packages/core/src/widgets/types.ts:952-1008
  • Factory: packages/core/src/widgets/ui.ts:virtualList()

Build docs developers (and LLMs) love