{"slug":"virtualizer","title":"Virtualizer","description":"Build smooth virtualized lists, grids, window views, and masonry layouts.","contentType":"guides","content":"The virtualizer package provides low-level primitives for rendering large data\nsets without mounting every item at once. You control rendering, measurement,\nand scroll behavior while the virtualizer keeps the visible range in sync.\n\n<Resources pkg=\"@zag-js/virtualizer\" />\n\n## Overview\n\nThe package virtualizes large datasets by continuously reconciling:\n\n- Viewport state (`scroll` + viewport rect)\n- Item measurements (estimated first, measured after render)\n- Visible range (plus overscan)\n\nUse this setup flow in every framework:\n\n1. Create the virtualizer with realistic estimates.\n2. Render `virtualizer.getVirtualItems()`.\n3. Measure mounted items (`measureElement` / `measureItem`).\n4. Cleanup on unmount (`destroy`).\n\n```ts\nimport { ListVirtualizer } from \"@zag-js/virtualizer\"\n\nconst virtualizer = new ListVirtualizer({\n  count: messages.length,\n  estimatedSize: () => 56,\n  overscan: 8,\n  orientation: \"vertical\",\n  dir: \"ltr\",\n  onRangeChange: ({ range, reason }) => {\n    renderRange(range, reason)\n  },\n})\n\nvirtualizer.init(scrollRef.current)\n```\n\n### Performance and debugging\n\n- Keep estimates close to measured reality to reduce correction churn.\n- Raise `overscan` only when fast scroll reveals blank gaps.\n- Log `range` and `reason` in `onRangeChange` to detect unnecessary work.\n- Measure after layout settles; avoid measuring hidden elements.\n\n### Pitfalls\n\n- `transform: scale(...)` on the scroll container is unsupported.\n- Always call `destroy()` to release observers/listeners.\n\n## List virtualization\n\nUse `ListVirtualizer` for one-dimensional feeds, logs, and chat timelines.\n\n```ts\nconst virtualizer = new ListVirtualizer({\n  count: messages.length,\n  estimatedSize: (index) => (index % 3 === 0 ? 72 : 56),\n  overscan: 10,\n  orientation: \"vertical\",\n  dir: \"ltr\",\n  indexToKey: (index) => messages[index].id,\n  keyToIndex: (key) => messageIndexById.get(key) ?? -1,\n  onRangeChange: ({ range, reason }) => {\n    // reason: \"scroll\" | \"resize\" | \"measurement\" | \"count\" | \"manual\"\n    updateVisibleWindow(range, reason)\n  },\n})\n```\n\nFor horizontal lists, set `orientation: \"horizontal\"` and pass `dir: \"ltr\"` or\n`dir: \"rtl\"` to match document direction.\n\n## Grid virtualization\n\nUse `GridVirtualizer` when both row and column virtualization are required.\n\n```ts\nimport { GridVirtualizer } from \"@zag-js/virtualizer\"\n\nconst virtualizer = new GridVirtualizer({\n  rowCount: rows.length,\n  columnCount: 4,\n  estimatedRowSize: () => 180,\n  estimatedColumnSize: () => 260,\n  overscan: 4,\n  orientation: \"vertical\",\n  dir: \"ltr\",\n  onRangeChange: ({ range, reason }) => {\n    reportVisibleRows(range.startIndex, range.endIndex, reason)\n  },\n})\n```\n\nThe reported range maps to visible row indexes.\n\n## Window virtualization\n\nUse `WindowVirtualizer` when scroll is driven by `window`.\n\n```ts\nimport { WindowVirtualizer } from \"@zag-js/virtualizer\"\n\nconst virtualizer = new WindowVirtualizer({\n  count: rows.length,\n  estimatedSize: () => 48,\n  overscan: 6,\n  orientation: \"vertical\",\n  dir: \"ltr\",\n  initialRect: { width: 1024, height: 720 },\n  scrollingElement: document.scrollingElement ?? undefined,\n  windowOffset: 0,\n})\n\nvirtualizer.init(containerEl)\n```\n\nIf your app supports horizontal layouts, `orientation` and `dir` follow the same\nbehavior as `ListVirtualizer`.\n\n## Waterfall virtualization\n\nUse `WaterfallVirtualizer` for masonry-style layouts with uneven item heights.\n\n```ts\nimport { WaterfallVirtualizer } from \"@zag-js/virtualizer\"\n\nconst virtualizer = new WaterfallVirtualizer({\n  count: cards.length,\n  minColumnWidth: 280,\n  columnGap: 16,\n  rowGap: 16,\n  estimatedSize: (index, laneWidth) =>\n    estimateCardHeight(cards[index], laneWidth),\n  laneAssignment: \"measured\", // or \"preserve\"\n  initialRect: { width: 1200, height: 800 },\n})\n```\n\n`WaterfallVirtualizer` is vertical-only and LTR-only in v1\n(`orientation: \"vertical\"`, `dir: \"ltr\"`).\n\n- `measured` assigns items to the current shortest lane.\n- `preserve` keeps previous lane choices when possible for more stable\n  placement.\n\n## SSR and hydration\n\nSeed predictable viewport and measurement data to avoid hydration jumps.\n\n```ts\nconst virtualizer = new ListVirtualizer({\n  count: items.length,\n  estimatedSize: () => 56,\n  initialRect: { width: 1024, height: 720 },\n  initialOffset: 0,\n  initialMeasurements: cachedMeasurements,\n})\n```\n\nUse:\n\n- `initialRect` for first known viewport dimensions.\n- `initialOffset` for known initial scroll position.\n- `initialMeasurements` (`Map` or object) to restore measured sizes.\n\n## Anchor stability\n\nFor prepend/chat flows, pair stable keys with controlled compensation.\n\n```ts\nconst virtualizer = new ListVirtualizer({\n  count: items.length,\n  estimatedSize: () => 20,\n  indexToKey: (index) => items[index].id,\n  keyToIndex: (key) => keyToIndexMap.get(key) ?? -1,\n  shouldAdjustScrollOnSizeChange: ({ key, delta, viewportStart }) => {\n    return isPrependedKey(key) && delta !== 0 && viewportStart > 0\n  },\n})\n```\n\nPractical pattern:\n\n1. Keep stable `indexToKey` / `keyToIndex`.\n2. Prepend data and update `count`.\n3. Re-measure prepended rows.\n4. Use `shouldAdjustScrollOnSizeChange` to decide when compensation applies.","package":"@zag-js/virtualizer","editUrl":"https://github.com/chakra-ui/zag/edit/main/website/data/guides/virtualizer.mdx"}