@vielzeug/codex 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/catalog.json +137 -129
- package/data/llms-full.txt +13088 -17651
- package/data/llms.txt +12 -11
- package/data/manifest.json +1 -1
- package/data/packages/arsenal.json +1 -1
- package/data/packages/assay.json +1 -1
- package/data/packages/clockwork.json +2 -2
- package/data/packages/codex.json +1 -1
- package/data/packages/coins.json +1 -1
- package/data/packages/conduit.json +1 -1
- package/data/packages/courier.json +1 -1
- package/data/packages/dnd.json +14 -12
- package/data/packages/familiar.json +26 -16
- package/data/packages/flux.json +1 -1
- package/data/packages/forge.json +1 -1
- package/data/packages/herald.json +19 -33
- package/data/packages/keymap.json +13 -19
- package/data/packages/ledger.json +28 -25
- package/data/packages/lingua.json +2 -2
- package/data/packages/necromancer.json +50 -0
- package/data/packages/orbit.json +34 -39
- package/data/packages/ore.json +1 -1
- package/data/packages/prism.json +37 -40
- package/data/packages/pulse.json +26 -24
- package/data/packages/refine.json +1 -1
- package/data/packages/ripple.json +1 -1
- package/data/packages/rune.json +6 -7
- package/data/packages/sandbox.json +7 -6
- package/data/packages/scout.json +10 -10
- package/data/packages/scroll.json +18 -17
- package/data/packages/sourcerer.json +1 -1
- package/data/packages/spell.json +1 -1
- package/data/packages/tempo.json +49 -81
- package/data/packages/vault.json +37 -40
- package/data/packages/ward.json +5 -17
- package/data/packages/wayfinder.json +9 -9
- package/data/refine.json +4902 -4902
- package/data/search.json +205 -206
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/http.js +46 -6
- package/dist/http.js.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/index.js +13 -5
- package/dist/tools/index.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,71 +1,72 @@
|
|
|
1
1
|
{
|
|
2
|
-
"apiSource": "export { ScrollError, ScrollRangeError } from './errors';\nexport { createDomVirtualList, createVirtualScroller } from './dom-virtual-list';\nexport type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';\nexport { createGridVirtualizer } from './grid-virtualizer';\nexport type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';\nexport { createGroupedVirtualizer } from './grouped-virtualizer';\nexport type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';\nexport { createReactiveGroupedVirtualizer, createReactiveVirtualizer } from './reactive';\nexport type { ReactiveGroupVirtualizer, ReactiveVirtualizer, Signal } from './reactive';\nexport { createMeasurementCache, createVirtualizer, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN } from './virtualizer';\nexport type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';\n",
|
|
2
|
+
"apiSource": "export { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';\nexport { createDomVirtualList, createVirtualScroller } from './dom-virtual-list';\nexport type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';\nexport { createGridVirtualizer } from './grid-virtualizer';\nexport type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';\nexport { createGroupedVirtualizer } from './grouped-virtualizer';\nexport type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';\nexport { createReactiveGroupedVirtualizer, createReactiveVirtualizer } from './reactive';\nexport type { ReactiveGroupVirtualizer, ReactiveVirtualizer, Signal } from './reactive';\nexport { createMeasurementCache, createVirtualizer, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN } from './virtualizer';\nexport type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Scroll — Virtual list engine for TypeScript\ndescription: Lightweight, framework-agnostic virtual list engine with variable heights, sticky headers, grid support, and
|
|
5
|
-
"api": "---\ntitle: Scroll — API Reference\ndescription: Complete API reference for the Scroll virtual list engine.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| ------------------------------------ | ----------------------------------------------- | -------------- | ------------------------------------------------------------------------------------- |\n| `createVirtualizer()` | Core 1D virtualizer | Sync | `onChange` fires on construction — wire DOM first |\n| `createDomVirtualList()` | DOM adapter for dropdown/listbox UIs | Sync | Virtualizer is created lazily on first `setItems()` |\n| `createVirtualScroller()` | Self-contained scroller (creates its own DOM) | Sync | `dispose()` removes the generated scroll element |\n| `createGroupedVirtualizer()` | Sectioned list with sticky headers | Sync | `update()` preserves measured sizes — call `invalidate()` only on font/layout changes |\n| `createGridVirtualizer()` | Two-dimensional grid virtualizer | Sync | `onRangeChange` fires even when `onChange` is omitted |\n| `createReactiveVirtualizer()` | Virtualizer with reactive signal output | Sync | `onChange` must not be passed — it is wired internally |\n| `createReactiveGroupedVirtualizer()` | Grouped virtualizer with reactive signal output | Sync | `onChange` must not be passed — it is wired internally |\n\n## Package Entry Point\n\nEverything exports from a single entry:\n\n```ts\nimport {\n createVirtualizer,\n createDomVirtualList,\n createVirtualScroller,\n createGroupedVirtualizer,\n createGridVirtualizer,\n createReactiveVirtualizer,\n type Virtualizer,\n type VirtualItem,\n type VirtualizerState,\n} from '@vielzeug/scroll';\n```\n\n## `createVirtualizer(target, options)`\n\n```ts\ncreateVirtualizer(target: ScrollTarget, options: VirtualizerOptions): Virtualizer;\n```\n\nCreates and immediately attaches a virtualizer to the provided scroll container. `onChange` fires synchronously on construction with the initial visible window. Call `dispose()` on unmount.\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\n\nconst virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n gap: 8,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n\n for (const item of items) {\n const row = document.createElement('div');\n row.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:${item.size}px;`;\n row.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(row);\n }\n },\n});\n```\n\n### Parameters\n\n| Parameter | Type | Description |\n| --------- | ----------------------- | --------------------------- |\n| `target` | `HTMLElement \\| Window` | Scroll container to observe |\n| `options` | `VirtualizerOptions` | Initial options |\n\n### `VirtualizerOptions`\n\n| Option | Type | Default | Description |\n| ------------------- | -------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------ |\n| `count` | `number` | required | Total item count |\n| `estimateSize` | `number \\| (index: number) => number` | `36` | Fixed size or per-index estimate in pixels |\n| `gap` | `number` | `0` | Gap between adjacent items in pixels |\n| `getItemKey` | `(index: number) => string \\| number` | `index => index` | Stable key for the measurement cache |\n| `horizontal` | `boolean` | `false` | Virtualize along the X axis instead of Y |\n| `initialOffset` | `number` | — | Initial scroll position; applied once on construction |\n| `measurementCache` | `MeasurementCache` | — | Shared external cache for scroll restoration or SSR pre-measurement |\n| `onChange` | `(state: VirtualizerState) => void` | — | Called when the visible window changes. **Fixed at construction.** |\n| `onScrollEnd` | `(offset: number) => void` | — | Called when scrolling settles (native `scrollend` or debounce). **Fixed at construction.** |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | — | Called when scroll activity starts or stops. **Fixed at construction.** |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric on both sides |\n| `scrollEndDelay` | `number` | `150` | Debounce delay (ms) used to detect scroll end when native `scrollend` is unavailable |\n| `sticky` | `(index: number) => boolean` | — | Mark an item as a sticky header (pinned at viewport top) |\n\n`onChange`, `onScrollEnd`, `onScrollingChange`, and `scrollEndDelay` are fixed at construction — they cannot be changed via `update()`.\n\n**Returns:** `Virtualizer`\n\n### `VirtualizerState`\n\n```ts\ninterface VirtualizerState {\n readonly items: VirtualItem[];\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n}\n```\n\n`items` contains the currently visible items plus overscan. `stickyItems` contains items marked sticky that are pinned at the viewport top.\n\n### `Virtualizer` — read-only properties\n\n| Property | Type | Description |\n| -------------- | --------------- | ----------------------------------------------------------- |\n| `count` | `number` | Current item count |\n| `isScrolling` | `boolean` | `true` while the user is scrolling; `false` once settled |\n| `items` | `VirtualItem[]` | Currently rendered items. Always populated. |\n| `scrollOffset` | `number` | Current scroll position in pixels |\n| `stickyItems` | `VirtualItem[]` | Items pinned at the viewport top (requires `sticky` option) |\n| `totalSize` | `number` | Total height (or width in horizontal mode) |\n\n### `Virtualizer` — methods\n\n| Method | Signature | Description |\n| ------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------- |\n| `update` | `(next: VirtualizerUpdateOptions) => void` | Atomically update live options |\n| `measure` | `(index: number, size: number) => void` | Record one measured size; rebuild batched in microtask |\n| `measureBatch` | `(entries: Array<{ index: number; size: number }>) => void` | Record many sizes; single rebuild |\n| `measureEl` | `(index: number, el: HTMLElement) => () => void` | Attach ResizeObserver to auto-measure. Returns a disconnect function |\n| `refresh` | `() => void` | Rebuild offset table and re-emit; preserves cached measurements |\n| `prepend` | `(additionalCount: number) => void` | Add items at the top; adjusts scroll offset to keep viewport stable |\n| `scrollToIndex` | `(index: number, options?: ScrollToIndexOptions) => void` | Scroll to an item; out-of-range indices are clamped |\n| `scrollToOffset` | `(offset: number, options?: { behavior?: ScrollBehavior }) => void` | Scroll to a raw pixel offset |\n| `scrollToTop` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to offset `0` |\n| `scrollToBottom` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the end of the list |\n| `isAtEnd` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end — check before appending items to decide whether to auto-follow (chat \"stick to bottom\") |\n| `invalidate` | `() => void` | Clear all measurements and rebuild from estimates |\n| `dispose` | `() => void` | Detach listeners; idempotent |\n| `disposed` | `boolean` | `true` after `dispose()` is called |\n| `[Symbol.dispose]` | `() => void` | Delegates to `dispose()` — enables `using` declarations |\n\n### `update(next)`\n\nAtomically updates one or more live options. Accepts: `count`, `estimateSize`, `gap`, `getItemKey`, `measurementCache`, `overscan`, `sticky`. Creation-time options (`horizontal`, `initialOffset`, `onChange`, `onScrollEnd`, `onScrollingChange`, `scrollEndDelay`) cannot be changed after construction.\n\nWhen `estimateSize` changes, the measurement cache is cleared and a scroll anchor is applied to keep the current viewport position visually stable.\n\n```ts\nvirt.update({ count: rows.length });\nvirt.update({ estimateSize: 40 });\nvirt.update({ gap: 8, overscan: { start: 5, end: 5 } });\n```\n\n### `measure(index, size)` and `measureBatch(entries)`\n\nReport exact sizes for variable-height rows. Calls within one microtask tick coalesce into a single offset rebuild. `measure()` is a no-op when the new size equals the current effective size.\n\n```ts\nvirt.measure(item.index, el.offsetHeight);\n\n// Prefer measureBatch for ResizeObserver batches\nvirt.measureBatch(entries.map((e) => ({ index: Number(e.target.dataset.index), size: e.contentRect.height })));\n```\n\n### `measureEl(index, el)`\n\nAttaches a `ResizeObserver` to auto-measure `el` on resize. Returns a disconnect function. The\nobserver is also disconnected automatically when the virtualizer is disposed, so calling the\nreturned function is only needed to stop observing a specific element early (e.g. before it is\nrecycled or removed).\n\n```ts\nconst disconnect = virt.measureEl(item.index, rowEl);\n// later: disconnect();\n```\n\n### `refresh()`\n\nRebuilds the full offset table and re-emits. Preserves cached measurements. Use after reordering, filtering, or any data change where sizes may have changed.\n\n### `prepend(additionalCount)`\n\nAdds `additionalCount` items at the front while adjusting scroll offset so the viewport stays visually stable. Use for \"load previous page\" patterns.\n\n### `scrollToIndex(index, options?)`\n\nScroll to an item. Out-of-range indices are clamped silently.\n\n| `align` | Behavior |\n| ------------------ | ------------------------------------------------------------ |\n| `'start'` | Item top at viewport top |\n| `'end'` | Item bottom at viewport bottom |\n| `'center'` | Item centered in the viewport |\n| `'auto'` (default) | No scroll if already fully visible; otherwise minimum scroll |\n\n```ts\nvirt.scrollToIndex(0, { align: 'start' });\nvirt.scrollToIndex(500, { align: 'center', behavior: 'smooth' });\nvirt.scrollToIndex(focusedIndex, { align: 'auto' });\n```\n\n### `scrollToOffset(offset, options?)`\n\n```ts\nvirt.scrollToOffset(Number(sessionStorage.getItem('scrollOffset') ?? '0'));\n```\n\n### `invalidate()`\n\nClears all measured sizes and rebuilds from estimator values.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\n### `dispose()` and `[Symbol.dispose]()`\n\n`dispose()` detaches observers and event listeners. It is idempotent.\n\n```ts\n{\n using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render });\n} // → dispose() called automatically\n```\n\n## `createDomVirtualList(options)`\n\n```ts\ncreateDomVirtualList<T>(options: DomVirtualListOptions<T>): DomVirtualListController<T>;\n```\n\nDOM-focused adapter. Manages virtualizer lifecycle, applies list-height styles automatically, and provides a node pool via `recycle`. The virtualizer is created lazily on the first non-empty `setItems()` call and destroyed automatically when `setItems([])` is called.\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\nconst ctrl = createDomVirtualList<Row>({\n estimateSize: 36,\n getItemKey: (_, row) => row.id,\n listElement: listEl,\n scrollElement: scrollEl,\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createElement('div'));\n el.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`;\n el.textContent = item.data.label;\n listEl.appendChild(el);\n }\n },\n});\n\nctrl.setItems(rows);\nctrl.scrollToIndex(focusedIndex, { align: 'auto' });\nctrl.dispose();\n```\n\n### `DomVirtualListOptions<T>`\n\n| Option | Type | Default | Description |\n| ------------------ | --------------------------------------------- | -------- | ---------------------------------------------------------- |\n| `scrollElement` | `HTMLElement \\| Window` | required | Scroll container to observe |\n| `listElement` | `HTMLElement` | required | Element that receives height and item children |\n| `render` | `(args: DomVirtualListRenderArgs<T>) => void` | required | Called on every visible-window change |\n| `estimateSize` | `number \\| (index, item) => number` | `36` | Fixed or per-item size estimate |\n| `gap` | `number` | `0` | Gap between items in pixels |\n| `getItemKey` | `(index, item) => string \\| number` | — | Stable key; keeps measurements across `setItems()` calls |\n| `horizontal` | `boolean` | `false` | Virtualize along X axis |\n| `measurementCache` | `MeasurementCache` | — | External measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |\n| `sticky` | `(index: number, item: T) => boolean` | — | Mark items as sticky headers |\n| `clear` | `(listEl: HTMLElement) => void` | — | Custom teardown for listEl; defaults to `textContent = ''` |\n| `stickToBottom` | `boolean \\| StickToBottomOptions` | — | Auto-scroll to the end after `setItems()` whenever the list was already at (or near) the end — the chat \"stick to bottom on new message\" pattern |\n\nWithout `getItemKey`, each `setItems()` call drops cached measurements.\n\n### `StickToBottomOptions`\n\n| Option | Type | Default | Description |\n| ----------- | --------- | ------- | --------------------------------------------------------------------------- |\n| `enabled` | `boolean` | `true` | Enable/disable at runtime — pass the object form to toggle without removing it |\n| `threshold` | `number` | `48` | Distance in pixels from the end still considered \"at the end\" |\n\n`stickToBottom` fires on **any** `setItems()` call made while the list is at the end — not just when the item count grows. This also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. It never fires while the user has scrolled away from the end, so reading older messages is never interrupted.\n\n```ts\nconst chat = createDomVirtualList<Message>({\n estimateSize: 48,\n getItemKey: (_, m) => m.id,\n listElement: listEl,\n render: renderMessages,\n scrollElement: scrollEl,\n stickToBottom: true, // or { threshold: 80 } for a larger \"still at bottom\" tolerance\n});\n\nchat.setItems(messages); // scrolls to bottom on first load\n// … later, a new message arrives (or the last one grows while streaming) …\nchat.setItems([...messages, newMessage]); // follows along only if the user was already at the bottom\n```\n\n### `DomVirtualListRenderArgs<T>`\n\n```ts\ntype DomVirtualListRenderArgs<T> = {\n items: Array<VirtualRenderItem<T>>; // visible items — each has .data + layout fields\n listEl: HTMLElement;\n recycle: RecycleFn; // node pool — returns existing node or calls create()\n stickyItems: Array<VirtualRenderItem<T>>; // sticky items (requires sticky option)\n totalSize: number;\n};\n```\n\n`VirtualRenderItem<T>` is `VirtualItem` (`start`, `end`, `size`, `index`) enriched with `data: T`.\n\n`recycle(key, create)` returns a live node for `key` if one exists in the pool, or calls `create()` for a new one. Nodes not reused in a render cycle are removed automatically. `listEl.style.height` is set before `render` is called — you do not need to set it yourself.\n\n### `DomVirtualListController<T>`\n\nExtends `Virtualizer` (minus `prepend` and `update`) with `setItems()`. All virtualizer methods and live getters are available directly.\n\n| Member | Description |\n| ------------------ | ------------------------------------------------------------------------------------------- |\n| `setItems(items)` | Set the current item array. Spawns virtualizer on first non-empty call; destroys it on `[]` |\n| `count` | Current item count (live getter) |\n| `items` | Currently rendered virtual items (live getter) |\n| `totalSize` | Total list size in pixels (live getter) |\n| `scrollOffset` | Current scroll position (live getter) |\n| `stickyItems` | Sticky items pinned at viewport top (live getter) |\n| `measure` | Delegate to underlying virtualizer; no-op before first `setItems` |\n| `measureBatch` | Batch measurement delegate |\n| `measureEl` | Attach auto-measuring ResizeObserver |\n| `refresh` | Rebuild offset table and re-emit |\n| `invalidate` | Clear measurements and rebuild from estimates |\n| `scrollToIndex` | Scroll to an item |\n| `scrollToOffset` | Scroll to a pixel offset |\n| `scrollToTop` | Scroll to offset `0` |\n| `scrollToBottom` | Scroll to the end of the list |\n| `isAtEnd` | `true` when within `threshold` px of the end |\n| `dispose` | Teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called (live getter) |\n| `[Symbol.dispose]` | Delegates to `dispose()` |\n\n## `createVirtualScroller(container, options)`\n\n```ts\ncreateVirtualScroller<T>(container: HTMLElement, options: VirtualScrollerOptions<T>): DomVirtualListController<T>;\n```\n\nCreates a scroll container `div` and inner list `div`, appends them to `container`, and returns a fully wired `DomVirtualListController`. Useful when the scroll DOM doesn't already exist.\n\n```ts\nconst list = createVirtualScroller<Row>(document.getElementById('root')!, {\n estimateSize: 36,\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createElement('div'));\n el.textContent = item.data.label;\n el.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);`;\n listEl.appendChild(el);\n }\n },\n});\n\nlist.setItems(rows);\nlist.dispose(); // also removes the generated scroll container\n```\n\n`VirtualScrollerOptions<T>` is `DomVirtualListOptions<T>` minus `listElement`/`scrollElement`, plus:\n\n| Option | Type | Description |\n| ---------------- | -------- | ------------------------------------------------- |\n| `containerClass` | `string` | CSS class applied to the generated scroll element |\n\n`dispose()` removes the generated scroll container from the DOM.\n\n## `createGroupedVirtualizer(target, options)`\n\n```ts\ncreateGroupedVirtualizer<T>(target: ScrollTarget, options: GroupVirtualizerOptions<T>): GroupVirtualizer<T>;\n```\n\nVirtualizes a sectioned list. Headers are automatically sticky (pinned at viewport top while the section is in view).\n\n```ts\nimport { createGroupedVirtualizer } from '@vielzeug/scroll';\n\ntype Contact = { id: number; name: string };\n\nconst virt = createGroupedVirtualizer<Contact>(scrollEl, {\n estimateHeaderSize: 32,\n estimateItemSize: 48,\n sections: [\n { label: 'A', items: [{ id: 1, name: 'Alice' }] },\n { label: 'B', items: [{ id: 2, name: 'Bob' }] },\n ],\n onChange: ({ headers, items, stickyHeader, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n\n if (stickyHeader) {\n const el = document.createElement('div');\n el.className = 'sticky-header';\n el.textContent = stickyHeader.label;\n listEl.appendChild(el);\n }\n\n for (const header of headers) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${header.start}px;height:${header.size}px;`;\n el.textContent = header.label;\n listEl.appendChild(el);\n }\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;height:${item.size}px;`;\n el.textContent = item.data.name;\n listEl.appendChild(el);\n }\n },\n});\n\nvirt.scrollToSection(1, { align: 'start' });\nvirt.update(nextSections);\nvirt.dispose();\n```\n\n### `GroupVirtualizerOptions<T>`\n\n| Option | Type | Default | Description |\n| -------------------- | ------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------- |\n| `sections` | `Array<GroupSection<T>>` | required | Initial sections |\n| `onChange` | `(state: GroupVirtualizerState<T>) => void` | — | Called when the visible window changes. **Fixed at construction.** |\n| `onScrollEnd` | `(offset: number) => void` | — | Called when scrolling settles. **Fixed at construction.** |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | — | Called when scroll activity starts or stops. **Fixed at construction.** |\n| `estimateHeaderSize` | `number \\| (section, sectionIndex) => number` | `36` | Header height estimate |\n| `estimateItemSize` | `number \\| (item, itemIndex, sectionIndex) => number` | `36` | Item height estimate |\n| `getItemKey` | `(item: T, itemIndex: number, sectionIndex: number) => VirtualKey` | — | Stable key for measurement cache |\n| `horizontal` | `boolean` | `false` | Virtualize along X axis |\n| `measurementCache` | `MeasurementCache` | — | External measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Overscan on each side (number = symmetric) |\n| `scrollEndDelay` | `number` | `150` | Debounce delay (ms) for scroll-end detection |\n\n### `GroupSection<T>`\n\n```ts\ninterface GroupSection<T> {\n items: T[];\n label: string;\n}\n```\n\n### `GroupVirtualizerState<T>`\n\n```ts\ninterface GroupVirtualizerState<T> {\n readonly headers: GroupVirtualHeader[];\n readonly items: Array<GroupVirtualItem<T>>;\n readonly stickyHeader: GroupVirtualHeader | null;\n readonly totalSize: number;\n}\n```\n\n`stickyHeader` is the header of the section currently at or above the viewport top, or `null` when at the very top. Render it as a floating overlay above the list.\n\n### `GroupVirtualItem<T>` and `GroupVirtualHeader`\n\n```ts\ninterface GroupVirtualItem<T> extends VirtualItem {\n data: T;\n itemIndex: number; // index within the section\n sectionIndex: number;\n}\n\ninterface GroupVirtualHeader extends VirtualItem {\n label: string;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualizer<T>` — methods\n\n`GroupVirtualizer<T>` is an independent interface that exposes all core virtualizer methods directly, plus grouped-specific navigation.\n\n| Method / Property | Description |\n| ---------------------------------- | ------------------------------------------------------------------------ |\n| `update(sections, opts?)` | Replace all sections with optional config overrides; see `GroupVirtualizerUpdateOptions<T>` |\n| `scrollToSection(i, options?)` | Scroll to section header at index `i`. Out-of-range is a no-op |\n| `scrollToItem(s, i, options?)` | Scroll to item `i` in section `s`. Out-of-range is a no-op |\n| `scrollToIndex(i, options?)` | Scroll to flat index `i` (from underlying virtualizer) |\n| `scrollToOffset(offset, options?)` | Scroll to a raw pixel offset |\n| `scrollToTop(options?)` | Scroll to offset `0` |\n| `scrollToBottom(options?)` | Scroll to the end of the list |\n| `measure(index, size)` | Record a measurement for a flat index |\n| `measureBatch(entries)` | Batch-record measurements for flat indices |\n| `measureEl(index, el)` | Attach auto-measuring ResizeObserver. Returns disconnect function |\n| `invalidate()` | Clear all measurements and rebuild |\n| `refresh()` | Rebuild offset table without clearing measurements |\n| `isScrolling` | `true` while the user is scrolling; `false` once scroll settles |\n| `scrollOffset` | Current scroll position in pixels (live getter) |\n| `dispose()` | Teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called |\n| `[Symbol.dispose]()` | Delegates to `dispose()` |\n\nAll scroll methods accept an optional `ScrollToIndexOptions` object (`{ align?, behavior?, onComplete? }`).\n\n### `GroupVirtualizerUpdateOptions<T>`\n\nPassed as the second argument to `groupVirtualizer.update()`. All fields are optional — omit any you don't want to change.\n\n| Option | Type | Description |\n| -------------------- | ------------------------------------------------------------- | -------------------------------------------------------- |\n| `estimateHeaderSize` | `number \\| (section, sectionIndex) => number` | New header size estimate, applied on next rebuild |\n| `estimateItemSize` | `number \\| (item, itemIndex, sectionIndex) => number` | New item size estimate, applied on next rebuild |\n| `getItemKey` | `(item, itemIndex, sectionIndex) => VirtualKey` | New item key function |\n| `measurementCache` | `MeasurementCache` | Hot-swap the measurement cache |\n| `overscan` | `number \\| { start?, end? }` | New overscan count |\n\n> **Note:** `onChange`, `onScrollEnd`, `onScrollingChange`, and `horizontal` are fixed at construction and cannot be changed via `update()`.\n\n## `createGridVirtualizer(target, options)`\n\n```ts\ncreateGridVirtualizer(target: ScrollTarget, options: GridVirtualizerOptions): GridVirtualizer;\n```\n\nTwo-dimensional virtualizer. Fires `onChange` with visible row and column descriptors. Callers form the cross-product `rows × cols` to render visible cells.\n\n```ts\nimport { createGridVirtualizer } from '@vielzeug/scroll';\n\nconst grid = createGridVirtualizer(scrollEl, {\n rowCount: 10_000,\n colCount: 50,\n estimateRowSize: 36,\n estimateColSize: 120,\n onChange: ({ rows, cols, totalHeight, totalWidth }) => {\n containerEl.style.cssText = `position:relative;height:${totalHeight}px;width:${totalWidth}px;`;\n containerEl.innerHTML = '';\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createElement('div');\n cell.style.cssText = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;`;\n cell.textContent = `${row.index},${col.index}`;\n containerEl.appendChild(cell);\n }\n }\n },\n});\n\ngrid.scrollToCell(500, 10, { rowAlign: 'center', colAlign: 'start' });\ngrid.dispose();\n```\n\n### `GridVirtualizerOptions`\n\n| Option | Type | Default | Description |\n| --------------------- | --------------------------------------- | ---------------------- | -------------------------------------- |\n| `rowCount` | `number` | required | Total row count |\n| `colCount` | `number` | required | Total column count |\n| `estimateRowSize` | `number \\| (row) => number` | `36` | Row height estimate |\n| `estimateColSize` | `number \\| (col) => number` | `36` | Column width estimate |\n| `rowGap` | `number` | `0` | Gap between rows |\n| `colGap` | `number` | `0` | Gap between columns |\n| `overscanY` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | Row overscan |\n| `overscanX` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | Column overscan |\n| `initialScrollTop` | `number` | — | Initial vertical scroll position |\n| `initialScrollLeft` | `number` | — | Initial horizontal scroll position |\n| `onChange` | `(state: GridVirtualizerState) => void` | — | Called when the visible window changes |\n| `onRangeChange` | `(range: GridRangeChangeEvent) => void` | — | Zero-allocation range callback |\n| `rowMeasurementCache` | `Map<number, number>` | — | External row measurement cache |\n| `colMeasurementCache` | `Map<number, number>` | — | External column measurement cache |\n\n### `GridVirtualizerState`\n\n```ts\ninterface GridVirtualizerState {\n readonly cols: VirtualItem[];\n readonly rows: VirtualItem[];\n readonly totalHeight: number;\n readonly totalWidth: number;\n}\n```\n\n### `GridVirtualizer` — properties and methods\n\n**Read-only properties:** `rows`, `cols`, `scrollTop`, `scrollLeft`, `totalHeight`, `totalWidth`\n\n| Method | Description |\n| ---------------------------------- | --------------------------------------------------------------------------------- |\n| `update(next)` | Atomically update row/col counts, estimates, gaps, and overscan |\n| `measureRow(row, size)` | Record a row height |\n| `measureColumn(col, size)` | Record a column width |\n| `measureBatch(rows, cols)` | Measure rows and columns in a single coordinated rebuild pass |\n| `measureRowEl(row, el)` | Auto-measure row height via ResizeObserver. Returns disconnect fn |\n| `measureColEl(col, el)` | Auto-measure column width via ResizeObserver. Returns disconnect fn |\n| `refresh()` | Rebuild offset tables from current measurements |\n| `invalidate()` | Clear all measurements and rebuild from estimates |\n| `scrollToCell(row, col, options?)` | Scroll to bring a cell into view; no-op when `rowCount === 0` or `colCount === 0` |\n| `scrollToRow(row, options?)` | Scroll to bring a row into view; `rowAlign` controls alignment |\n| `scrollToColumn(col, options?)` | Scroll to bring a column into view; `colAlign` controls alignment |\n| `prependRows(n)` | Add `n` rows at the top; adjusts scroll offset to keep viewport stable |\n| `dispose()` | Teardown; idempotent |\n| `[Symbol.dispose]()` | Delegates to `dispose()` |\n\n`measureRowEl`/`measureColEl`'s `ResizeObserver` is also disconnected automatically on `dispose()` —\nthe returned disconnect function is only needed to stop observing a specific element early.\n\n### `ScrollToCellOptions`\n\n```ts\ninterface ScrollToCellOptions {\n behavior?: ScrollBehavior;\n colAlign?: 'auto' | 'center' | 'end' | 'start';\n rowAlign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n## `createReactiveGroupedVirtualizer(target, options)`\n\n```ts\ncreateReactiveGroupedVirtualizer<T>(\n target: ScrollTarget,\n options: Omit<GroupVirtualizerOptions<T>, 'onChange'>,\n): ReactiveGroupVirtualizer<T>;\n```\n\nWraps `createGroupedVirtualizer` and exposes state as a `Signal<GroupVirtualizerState<T>>` from `@vielzeug/ripple`. All `GroupVirtualizer<T>` methods and live getters are available. `onChange` must not be provided — it is wired internally.\n\n```ts\nimport { createReactiveGroupedVirtualizer } from '@vielzeug/scroll';\nimport { effect } from '@vielzeug/ripple';\n\nconst virt = createReactiveGroupedVirtualizer<Contact>(scrollEl, {\n estimateHeaderSize: 32,\n estimateItemSize: 48,\n sections,\n});\n\neffect(() => {\n const { headers, items, stickyHeader, totalSize } = virt.state.value;\n render(headers, items, stickyHeader, totalSize);\n});\n\nvirt.update(nextSections);\nvirt.dispose();\n```\n\n### `ReactiveGroupVirtualizer<T>`\n\n```ts\ninterface ReactiveGroupVirtualizer<T> extends GroupVirtualizer<T> {\n readonly state: Signal<GroupVirtualizerState<T>>;\n}\n```\n\nThe `state` signal is updated synchronously on every render cycle. All live getters remain current via Proxy.\n\n## `createReactiveVirtualizer(target, options)`\n\n```ts\ncreateReactiveVirtualizer(\n target: ScrollTarget,\n options: Omit<VirtualizerOptions, 'onChange'>,\n): ReactiveVirtualizer;\n```\n\nWraps `createVirtualizer` and exposes state as a `Signal<VirtualizerState>` from `@vielzeug/ripple`. All `Virtualizer` methods and live getters are available on the returned object. `onChange` must not be provided — it is wired internally.\n\n```ts\nimport { createReactiveVirtualizer } from '@vielzeug/scroll';\nimport { effect } from '@vielzeug/ripple';\n\nconst virt = createReactiveVirtualizer(scrollEl, {\n count: 1000,\n estimateSize: 40,\n});\n\neffect(() => {\n const { items, totalSize } = virt.state.value;\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;height:${item.size}px;`;\n listEl.appendChild(el);\n }\n});\n\nvirt.update({ count: 2000 });\nvirt.dispose();\n```\n\n### `ReactiveVirtualizer`\n\n```ts\ninterface ReactiveVirtualizer extends Virtualizer {\n readonly state: Signal<VirtualizerState>;\n}\n```\n\nThe `state` signal is updated synchronously whenever the visible window changes. All live getters (`count`, `items`, `totalSize`, `scrollOffset`, `stickyItems`) remain current.\n\n## Types\n\n### `VirtualItem`\n\n```ts\ninterface VirtualItem {\n end: number;\n index: number;\n size: number;\n start: number;\n}\n```\n\n### `VirtualizerState`\n\n```ts\ninterface VirtualizerState {\n readonly items: VirtualItem[];\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n}\n```\n\n### `ScrollToIndexOptions`\n\n```ts\ninterface ScrollToIndexOptions {\n align?: 'auto' | 'center' | 'end' | 'start';\n behavior?: ScrollBehavior;\n /** Called when the scroll animation completes (instant scrolls: next microtask). */\n onComplete?: () => void;\n}\n```\n\n### `Overscan`\n\n```ts\ntype Overscan = number | { end?: number; start?: number };\n```\n\nPassing a number is shorthand for symmetric overscan on both sides.\n\n### `VirtualKey`\n\n```ts\ntype VirtualKey = number | string;\n```\n\n### `VirtualRenderItem<T>`\n\n```ts\ntype VirtualRenderItem<T> = VirtualItem & { readonly data: T };\n```\n\n### `ScrollTarget`\n\n```ts\ntype ScrollTarget = HTMLElement | Window;\n```\n\n### `MeasurementCache`\n\n```ts\ntype MeasurementCache = Map<VirtualKey, number>;\n```\n\nUse `createMeasurementCache()` to create an empty cache:\n\n```ts\nimport { createMeasurementCache } from '@vielzeug/scroll';\n\nconst cache = createMeasurementCache();\nconst virt1 = createVirtualizer(el1, { count: 100, measurementCache: cache });\nconst virt2 = createVirtualizer(el2, { count: 100, measurementCache: cache });\n```\n\n### `RecycleFn`\n\n```ts\ntype RecycleFn = (key: VirtualKey, create: () => HTMLElement) => HTMLElement;\n```\n\n### `VirtualizerUpdateOptions`\n\n```ts\ninterface VirtualizerUpdateOptions {\n count?: number;\n estimateSize?: number | ((index: number) => number);\n gap?: number;\n getItemKey?: (index: number) => VirtualKey;\n /** Replace the active measurement cache. Existing entries are used immediately on the next rebuild. */\n measurementCache?: MeasurementCache;\n overscan?: Overscan;\n sticky?: (index: number) => boolean;\n}\n```\n\n### `VirtualScrollerOptions<T>`\n\n`DomVirtualListOptions<T>` minus `listElement` and `scrollElement`, plus:\n\n```ts\ntype VirtualScrollerOptions<T> = Omit<DomVirtualListOptions<T>, 'listElement' | 'scrollElement'> & {\n /** CSS class applied to the generated scroll container element. */\n containerClass?: string;\n};\n```\n\n### `GridVirtualizerUpdateOptions`\n\n```ts\ninterface GridVirtualizerUpdateOptions {\n colCount?: number;\n colGap?: number;\n estimateColSize?: number | ((col: number) => number);\n estimateRowSize?: number | ((row: number) => number);\n overscanX?: { end?: number; start?: number };\n overscanY?: { end?: number; start?: number };\n rowCount?: number;\n rowGap?: number;\n}\n```\n\n### `GridRangeChangeEvent`\n\nFired by `onRangeChange` on `createGridVirtualizer`. Zero-allocation alternative to `onChange` — no `rows`/`cols` arrays are allocated.\n\n```ts\ninterface GridRangeChangeEvent {\n firstCol: number;\n firstRow: number;\n lastCol: number;\n lastRow: number;\n}\n```\n\n### Constants\n\n```ts\nconst DEFAULT_ESTIMATE_SIZE = 36; // default estimateSize\nconst DEFAULT_OVERSCAN = 3; // default overscan on each side\n```\n",
|
|
6
|
-
"usage": "---\ntitle: Scroll — Usage Guide\ndescription: Fixed and variable heights, measurement, programmatic scrolling, and framework integration for Scroll.\n---\n\n[[toc]]\n\n## Basic Usage\n\nRender only visible rows by passing a scroll container, a total item count, and a size estimate. Scroll calls `onChange` with the visible window whenever it changes.\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\n\nconst scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;\nconst listEl = document.querySelector<HTMLElement>('.list')!;\n\nconst virt = createVirtualizer(scrollEl, {\n count: 10_000,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = `Row ${item.index}`;\n listEl.appendChild(el);\n }\n },\n});\n\n// Cleanup\nvirt.dispose();\n```\n\n```html\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;position:relative;\">\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## DOM Layout Requirements\n\nScroll uses **absolute positioning** for rendered items inside a relative container that stretches to the full list height. Your HTML needs three elements:\n\n```html\n<!-- 1. Scroll container — has a fixed height and overflow:auto/scroll -->\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;position:relative;\">\n <!-- 2. Spacer — height set to totalSize so the scrollbar is correct -->\n <div class=\"spacer\" style=\"position:relative;\">\n <!-- 3. Item container — items positioned absolutely inside here -->\n <div class=\"items\"></div>\n </div>\n</div>\n```\n\nA common alternative is to make the spacer and item container the same element:\n\n```html\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;\">\n <!-- Single relative container; items are absolute children -->\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## DOM Adapter for Dropdowns and Listboxes\n\nIf your component already has a dropdown scroll container and a listbox element, use `createDomVirtualList`. It wraps the `Virtualizer` lifecycle and keeps the integration surface small. Items arrive as `VirtualRenderItem<T>` — a `VirtualItem` enriched with a `.data` field. Use `recycle` for efficient DOM node reuse.\n\nThe virtualizer is created lazily on the first non-empty `setItems()` call and destroyed automatically when `setItems([])` is called (clearing list styles in the process).\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\ntype Option = { disabled?: boolean; label: string; value: string };\n\nlet options: Option[] = [];\n\nconst domVirtualList = createDomVirtualList<Option>({\n estimateSize: 36,\n gap: 6,\n getItemKey: (_index, option) => option.value,\n listElement: listboxEl,\n overscan: { end: 4, start: 4 },\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const row = recycle(item.data.value, () => document.createElement('button'));\n row.type = 'button';\n row.className = 'option';\n row.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`;\n row.textContent = item.data.label;\n row.disabled = !!item.data.disabled;\n listEl.appendChild(row);\n }\n },\n scrollElement: dropdownEl,\n});\n\n// Keep in sync when options change\ndomVirtualList.setItems(options);\n\n// Open: setItems populates the list\n// Close: setItems([]) destroys the virtualizer and clears list styles\ndomVirtualList.setItems(isOpen ? options : []);\n\n// Keyboard nav\ndomVirtualList.scrollToIndex(focusedIndex, { align: 'auto' });\n\n// Component teardown\ndomVirtualList.dispose();\n```\n\nFor variable-height rows, pass `getItemKey` so measurements survive `setItems()` calls when items reorder or are filtered.\n\nWhen multiple sizes are available at once, use `measureBatch` to coalesce into a single rebuild:\n\n```ts\ndomVirtualList.measureBatch(\n entries.map((e) => ({ index: Number(e.target.dataset.index), size: e.contentRect.height })),\n);\n```\n\nUse `domVirtualList.invalidate()` to discard all cached measurements.\n\n## Fixed Heights\n\nPass a single number to `estimateSize` when all rows are the same height. This is the simplest and most performant case — the offset table never needs to be rebuilt during scrolling.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: 10_000,\n estimateSize: 36, // every row is 36px\n onChange: ({ items, totalSize }) => {\n list.style.height = `${totalSize}px`;\n list.innerHTML = '';\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = data[item.index].name;\n list.appendChild(el);\n }\n },\n});\n```\n\n## Variable Heights — Estimator\n\nPass a **per-index function** to `estimateSize` when rows have predictable but non-uniform heights (e.g. group headers vs. regular rows). The offset table is built once at attach time using these estimates.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: flatList.length,\n estimateSize: (i) => (flatList[i].type === 'header' ? 48 : 36),\n onChange: ({ items, totalSize }) => {\n // render...\n },\n});\n```\n\n## Variable Heights — Measured\n\nFor truly dynamic heights (e.g. text wrapping, embedded images), render items at their estimated size first, then report the actual measured height with `measure()`. Scroll will coalesce all measurement calls within a single microtask tick into one offset rebuild.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 60, // initial estimate\n onChange: ({ items, totalSize }) => {\n list.style.height = `${totalSize}px`;\n list.innerHTML = '';\n\n for (const item of items) {\n const el = document.createElement('div');\n el.dataset.index = String(item.index);\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.innerHTML = rows[item.index].html;\n list.appendChild(el);\n }\n\n // Measure after the DOM has painted\n requestAnimationFrame(() => {\n for (const item of items) {\n const el = list.querySelector<HTMLElement>(`[data-index=\"${item.index}\"]`);\n if (el) virt.measure(item.index, el.offsetHeight);\n }\n });\n },\n});\n```\n\n::: tip Measurement is idempotent\n`measure(index, height)` is a no-op when the new height matches the current effective height (measured or estimated). It is safe to call on every render without triggering unnecessary rebuilds.\n:::\n\n## Variable Heights — Batch Measurement\n\nWhen a `ResizeObserver` fires with multiple entries at once, use `measureBatch()` to apply all sizes in a single offset rebuild instead of triggering one rebuild per `measure()` call.\n\n```ts\nconst observer = new ResizeObserver((entries) => {\n virt.measureBatch(\n entries\n .filter((e) => e.target instanceof HTMLElement && e.target.dataset.index)\n .map((e) => ({\n index: Number((e.target as HTMLElement).dataset.index),\n size: e.contentRect.height,\n })),\n );\n});\n\n// Observe each rendered row\nfor (const item of virt.items) {\n const el = listEl.querySelector<HTMLElement>(`[data-index=\"${item.index}\"]`);\n if (el) observer.observe(el);\n}\n```\n\n## Overscan\n\n`overscan` controls how many extra items render outside the visible viewport on each side. Higher values reduce the chance of blank rows during fast scrolling; lower values keep the DOM smaller.\n\n```ts\ncreateVirtualizer(scrollEl, {\n count: 1_000,\n estimateSize: 36,\n overscan: 5, // symmetric shorthand — same as { start: 5, end: 5 } (default: 3)\n onChange: () => {\n /* ... */\n },\n});\n```\n\nAsymmetric overscan:\n\n```ts\ncreateVirtualizer(scrollEl, {\n count: 1_000,\n estimateSize: 36,\n overscan: { start: 8, end: 2 },\n onChange: () => {\n /* ... */\n },\n});\n```\n\n## Horizontal Lists\n\nSet `horizontal: true` to virtualize along the X axis.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: chips.length,\n estimateSize: 120,\n horizontal: true,\n onChange: ({ items, totalSize }) => {\n list.style.width = `${totalSize}px`;\n\n for (const item of items) {\n const chip = document.createElement('button');\n chip.style.cssText = `position:absolute;left:${item.start}px;top:0;width:${item.size}px;`;\n chip.textContent = chips[item.index].label;\n list.appendChild(chip);\n }\n },\n});\n```\n\n## Window Scroll Target\n\n`createVirtualizer` accepts `window` as the scroll target.\n\n```ts\nconst virt = createVirtualizer(window, {\n count: rows.length,\n estimateSize: 40,\n initialOffset: 320,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = `${totalSize}px`;\n renderRows(items);\n },\n});\n```\n\n## Scroll State\n\nUse `virt.scrollOffset` to read the current scroll position at any time.\n\n```ts\nconst virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, onChange: render });\n\n// Accessed outside onChange\nconsole.log(virt.scrollOffset);\n```\n\n## Updating Options\n\nWhen your data or render strategy changes, call `update()` with one or more option fields. Updates are applied atomically and trigger re-render when needed.\n\n```ts\n// Load more data\ndata.push(...newItems);\nvirt.update({ count: data.length });\n```\n\n```ts\n// Change multiple options together\nvirt.update({ count: data.length, overscan: { start: 5, end: 5 } });\n\n// Rebuild after reordering/filtering stable-key rows\nvirt.refresh();\n```\n\n## Switching Row Density\n\nUpdating `estimateSize` clears all previously measured heights, rebuilds offsets, and re-renders. This makes density switching (compact / comfortable / spacious views) straightforward.\n\n```ts\nfunction setDensity(mode: 'compact' | 'comfortable') {\n virt.update({ estimateSize: mode === 'compact' ? 32 : 48 });\n}\n```\n\n## Programmatic Scrolling\n\n### `scrollToIndex(index, options?)`\n\nScroll to bring a specific item into view.\n\n| `align` | Behaviour |\n| ------------------ | ------------------------------------------------------------------------ |\n| `'start'` | Item top aligns with the container top |\n| `'end'` | Item bottom aligns with the container bottom |\n| `'center'` | Item is centered in the viewport |\n| `'auto'` (default) | No scroll if already fully visible; otherwise scrolls the minimum amount |\n\n```ts\n// Jump to item 500 at the top of the viewport\nvirt.scrollToIndex(500, { align: 'start' });\n\n// Smooth-scroll to an item, centering it\nvirt.scrollToIndex(500, { align: 'center', behavior: 'smooth' });\n\n// Scroll only if the item is not already visible\nvirt.scrollToIndex(focusedIndex, { align: 'auto' });\n```\n\nOut-of-range indices are clamped silently: negative values scroll to item `0`, values ≥ `count` scroll to the last item.\n\n### `scrollToOffset(offset, options?)`\n\nScroll to an exact pixel position, useful for restoring a previously saved scroll state.\n\n```ts\n// Restore scroll position\nconst savedOffset = sessionStorage.getItem('scrollOffset');\nif (savedOffset) virt.scrollToOffset(Number(savedOffset));\n\n// Save on scroll\nscrollEl.addEventListener('scroll', () => {\n sessionStorage.setItem('scrollOffset', String(scrollEl.scrollTop));\n});\n```\n\n### `scrollToTop(options?)` / `scrollToBottom(options?)`\n\nConvenience wrappers to jump directly to the start or end of the list.\n\n```ts\n// Jump to the top\nvirt.scrollToTop();\n\n// Jump to the bottom with smooth scroll\nvirt.scrollToBottom({ behavior: 'smooth' });\n```\n\n### Chat \"stick to bottom on new message\"\n\n`createDomVirtualList`'s `stickToBottom` option automates the common chat/log pattern: follow new messages while the user is at the bottom, but never yank them away from history they scrolled up to read.\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\nconst chat = createDomVirtualList<Message>({\n estimateSize: 48,\n getItemKey: (_, m) => m.id,\n listElement: listEl,\n render: renderMessages,\n scrollElement: scrollEl,\n stickToBottom: true, // or { threshold: 80 } to widen the \"still at bottom\" tolerance\n});\n\nchat.setItems(messages);\n\n// New message arrives — follows only if the user hasn't scrolled up.\nsocket.on('message', (msg) => {\n messages = [...messages, msg];\n chat.setItems(messages);\n});\n```\n\nIt also follows a **streaming** last message that grows in place (tokens appended to the same message object, array length unchanged) — every `setItems()` call re-checks \"was the list at the end before this update?\", not just count changes. Build `isAtEnd()` from `createVirtualizer` directly for custom cases (e.g. showing a \"jump to latest\" button only while scrolled away):\n\n```ts\nconst showJumpButton = !virt.isAtEnd();\n```\n\n## Infinite Scroll — Loading More at the End\n\nUse `isAtEnd(threshold)` to fetch the next page as the user nears the bottom. `isAtEnd()` reports scroll position only — it keeps returning `true` while a fetch is in flight — so guard it with your own `loading` flag to avoid firing the same request twice.\n\n```ts\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\nlet rows = await fetchPage(0);\nlet loading = false;\n\nlet virt: Virtualizer;\nvirt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n\n if (!loading && virt.isAtEnd(200)) {\n loading = true;\n fetchPage(rows.length).then((nextRows) => {\n rows = [...rows, ...nextRows];\n virt.update({ count: rows.length });\n loading = false;\n });\n }\n },\n});\n```\n\n`isAtEnd(200)` fires once the viewport is within 200px of the bottom — tune the threshold to your row height and fetch latency. `loading` is the only guard needed: it's cleared once the new page lands, and `update({ count })` re-triggers `onChange`, which re-checks `isAtEnd()` against the new total on the next scroll.\n\n## Shared Measurement Cache\n\nWhen the same items are displayed across multiple virtualizer instances (e.g. a list and a detail panel that share row heights), pass a shared `MeasurementCache` created by `createMeasurementCache()`. Measurements recorded by one virtualizer are immediately available to all others using the same cache.\n\n```ts\nimport { createMeasurementCache, createVirtualizer } from '@vielzeug/scroll';\n\nconst cache = createMeasurementCache();\n\nconst listVirt = createVirtualizer(listScrollEl, {\n count: rows.length,\n estimateSize: 36,\n measurementCache: cache,\n onChange: renderList,\n});\n\nconst previewVirt = createVirtualizer(previewScrollEl, {\n count: rows.length,\n estimateSize: 36,\n measurementCache: cache,\n onChange: renderPreview,\n});\n\n// A measurement on listVirt is reflected in previewVirt immediately.\nlistVirt.measure(0, 72);\n```\n\nThe cache is a plain `Map<VirtualKey, number>` — you can pre-populate it from server data or persist it across sessions.\n\n```ts\n// Pre-populate from server-sent sizes\nconst cache = createMeasurementCache();\nfor (const { id, height } of serverSizes) cache.set(id, height);\n```\n\n## Invalidating Measurements\n\nCall `invalidate()` after an event that changes item heights without a data change — for example, a font load, a viewport width change that causes text to reflow, or toggling between a grid and list layout.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\nOn variable-height lists, `scrollToIndex()` uses the current estimate/measured cache. If you need an exact post-layout position after heights change, call `invalidate()` before scrolling again.\n\nFor same-length updates, call `setItems()` (DOM adapter) or `update()` (core). If the rendered height of rows changed, call `invalidate()` before scrolling again.\n\n## Lifecycle — create and dispose\n\n`createVirtualizer(el, options)` attaches immediately to the provided scroll container. If your container is replaced, dispose the old instance and create a new one.\n\n```ts\nlet virt = createVirtualizer(scrollContainerEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: render,\n});\n\nfunction remount(nextScrollContainerEl: HTMLElement) {\n virt.dispose();\n virt = createVirtualizer(nextScrollContainerEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: render,\n });\n}\n```\n\n`dispose()` is idempotent and safe to call multiple times.\n\n### Explicit Resource Management\n\n```ts\n// The `using` keyword calls virt.dispose() automatically at block exit\n{\n using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render });\n // ... use virt ...\n} // → virt.dispose() called here\n```\n\n## Framework Integration\n\nScroll is rendering-layer agnostic. The pattern is always the same: create the virtualizer when your scroll container is mounted, re-render your DOM in `onChange`, and call `dispose()` on unmount.\n\n::: code-group\n\n```tsx [React]\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\nimport { useEffect, useLayoutEffect, useRef } from 'react';\n\ninterface Row {\n id: number;\n label: string;\n}\n\nfunction VirtualList({ rows }: { rows: Row[] }) {\n const scrollRef = useRef<HTMLDivElement>(null);\n const listRef = useRef<HTMLDivElement>(null);\n const virtRef = useRef<Virtualizer | null>(null);\n\n useEffect(() => {\n const scrollEl = scrollRef.current;\n const listEl = listRef.current;\n if (!scrollEl || !listEl) return;\n\n const virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n virtRef.current = virt;\n return () => virt.dispose();\n }, []); // attach once\n\n // useLayoutEffect, not useEffect: syncs count before paint. With useEffect,\n // the DOM (and anything reading `rows`) paints once with the new length before\n // the virtualizer's internal count catches up, which can render stale/out-of-bounds indices.\n useLayoutEffect(() => {\n virtRef.current?.update({ count: rows.length });\n }, [rows.length]);\n\n return (\n <div ref={scrollRef} style={{ height: 400, overflow: 'auto', position: 'relative' }}>\n <div ref={listRef} style={{ position: 'relative' }} />\n </div>\n );\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\nimport { onMounted, onUnmounted, ref, watch } from 'vue';\n\nconst props = defineProps<{ rows: { id: number; label: string }[] }>();\nconst scrollRef = ref<HTMLElement | null>(null);\nconst listRef = ref<HTMLElement | null>(null);\nlet virt: Virtualizer | null = null;\n\nonMounted(() => {\n if (!scrollRef.value || !listRef.value) return;\n const listEl = listRef.value;\n virt = createVirtualizer(scrollRef.value, {\n count: props.rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = props.rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n});\nwatch(\n () => props.rows.length,\n (n) => {\n virt?.update({ count: n });\n },\n);\nonUnmounted(() => virt?.dispose());\n</script>\n\n<template>\n <div ref=\"scrollRef\" style=\"height:400px;overflow:auto;position:relative;\">\n <div ref=\"listRef\" style=\"position:relative;\" />\n </div>\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\n let { rows }: { rows: { id: number; label: string }[] } = $props();\n let scrollEl: HTMLElement;\n let listEl: HTMLElement;\n let virt: Virtualizer;\n\n $effect(() => {\n virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n return () => virt.dispose();\n });\n\n $effect(() => { virt?.update({ count: rows.length }); });\n</script>\n\n<div bind:this={scrollEl} style=\"height:400px;overflow:auto;position:relative;\">\n <div bind:this={listEl} style=\"position:relative;\" />\n</div>\n```\n\n```ts [Web Components]\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\n@customElement('virtual-list')\nclass VirtualList extends LitElement {\n static styles = css`\n .scroll {\n height: 400px;\n overflow: auto;\n position: relative;\n }\n .list {\n position: relative;\n }\n `;\n\n @property({ type: Array }) rows: { label: string }[] = [];\n #virt: Virtualizer | null = null;\n\n firstUpdated() {\n const scrollEl = this.renderRoot.querySelector<HTMLElement>('.scroll')!;\n const listEl = this.renderRoot.querySelector<HTMLElement>('.list')!;\n this.#virt = createVirtualizer(scrollEl, {\n count: this.rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = '';\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = this.rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n }\n\n updated() {\n this.#virt?.update({ count: this.rows.length });\n }\n disconnectedCallback() {\n this.#virt?.dispose();\n super.disconnectedCallback();\n }\n render() {\n return html`<div class=\"scroll\"><div class=\"list\"></div></div>`;\n }\n}\n```\n\n:::\n\n### Pitfalls\n\n- **React:** Putting `rows` in the `useEffect` dependency array causes the virtualizer to be destroyed and recreated on every data update. Only include the scroll element reference. Call `virt.update({ count })` from a separate `useEffect` for data changes.\n- **React:** Use `useLayoutEffect`, not `useEffect`, for the `count`-sync effect. `useEffect` fires after paint — a new `count` can reach the DOM (e.g. via other state derived from `rows`) before `update({ count })` runs, rendering stale or out-of-bounds indices for one frame.\n- **Vue 3:** `ref.value` is `null` inside `setup()` — the DOM doesn't exist yet. Always create the virtualizer inside `onMounted`, not in `setup()`.\n- **Svelte:** In Svelte 5, `$effect` with `bind:this` runs after the DOM is painted. The `bind:this` variable is available when the `$effect` runs — no extra tick needed.\n- **Web Components:** `firstUpdated` fires once after the first render. Use `updated()` for subsequent prop changes — Lit calls it every time `rows` changes.\n\n## Working with Other Vielzeug Libraries\n\n### With Ore\n\nBuild a virtualizing custom element using Ore for the component shell and Scroll for the rendering engine.\n\n```ts\nimport { define, html, onMounted, ref } from '@vielzeug/ore';\nimport { createVirtualizer } from '@vielzeug/scroll';\n\ndefine('virtual-list', {\n setup() {\n const scrollRef = ref<HTMLElement>();\n const listRef = ref<HTMLElement>();\n\n onMounted(() => {\n if (!scrollRef.value || !listRef.value) return;\n const listEl = listRef.value;\n const virt = createVirtualizer(scrollRef.value, {\n count: 1000,\n estimateSize: 40,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.innerHTML = items.map((i) => `<div style=\"position:absolute;top:${i.start}px;height:40px;\">Row ${i.index}</div>`).join('');\n },\n },\n },\n });\n return () => virt.dispose();\n });\n\n return () => html`\n <div ref=${scrollRef} style=\"height:400px;overflow:auto;position:relative\">\n <div ref=${listRef} style=\"position:relative\"></div>\n </div>\n `;\n },\n});\n```\n\n## Best Practices\n\n- Always provide `count` and `estimateSize` as a starting point, even for variable-height lists — measurements refine the estimates.\n- Call `dispose()` in the framework cleanup callback (useEffect return, onUnmounted, onDestroy) to free resize observers.\n- Use `overscan` to pre-render rows above and below the visible area to reduce blank flicker during fast scrolling.\n- Prefer `scrollToIndex()` with `align: 'start'` for programmatic navigation; use `align: 'center'` for focus management.\n- Use `createDomVirtualList()` for comboboxes, listboxes, and selects — it manages the virtualizer lifecycle and DOM node pooling for you.\n- Invalidate measurements with `invalidate()` when item content changes size (e.g., after expanding an accordion row).\n- For very large lists (>100k items), set a narrower `overscan` to limit DOM node count at any one time.\n- Use `refresh()` when item data or sizes may have changed; it rebuilds the offset table and re-emits.\n",
|
|
4
|
+
"index": "---\ntitle: Scroll — Virtual list engine for TypeScript\ndescription: Lightweight, framework-agnostic virtual list engine with variable heights, sticky headers, grid support, and reactive integration.\npackage: scroll\ncategory: ui-performance\nkeywords: [virtual-list, virtualization, windowing, scroll, performance, large-lists]\nrelated: [dnd, ore, refine]\nexports:\n [\n createVirtualizer,\n createDomVirtualList,\n createVirtualScroller,\n createGroupedVirtualizer,\n createGridVirtualizer,\n createReactiveVirtualizer,\n createReactiveGroupedVirtualizer,\n createMeasurementCache,\n ScrollConfigurationError,\n ScrollError,\n ScrollRangeError,\n DEFAULT_ESTIMATE_SIZE,\n DEFAULT_OVERSCAN,\n ]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"scroll\" />\n\n## Why Scroll?\n\nRendering thousands of items as real DOM nodes freezes the browser. Each node consumes layout, paint, and memory — long lists need to render only what is visible in the viewport.\n\n```ts\n// Before — render all 10 000 items (browser freezes)\nlist.replaceChildren();\nitems.forEach((item) => {\n const el = document.createElement('div');\n el.textContent = item.name;\n list.appendChild(el); // 10 000 DOM nodes\n});\n\n// After — Scroll (only ~15 visible rows in the DOM at any time)\nimport { createVirtualizer } from '@vielzeug/scroll';\nconst virtualizer = createVirtualizer(scrollEl, {\n count: items.length,\n estimateSize: 36,\n onChange: ({ items: visibleItems, totalSize }) => {\n list.style.height = `${totalSize}px`;\n list.replaceChildren();\n for (const { index, start } of visibleItems) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${start}px;height:36px;`;\n el.textContent = items[index].name;\n list.appendChild(el);\n }\n },\n});\n```\n\n| Feature | Scroll | TanStack Virtual | react-window |\n| ------------------ | --------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------- |\n| Bundle size | <PackageInfo package=\"scroll\" type=\"size\" /> | ~5 kB | ~8 kB |\n| Framework agnostic | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | React only |\n| Variable heights | <ore-icon name=\"check\" size=\"16\"></ore-icon> Measured | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> Static |\n| O(log n) lookup | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| `using` support | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"x\" size=\"16\"></ore-icon> `@vielzeug/ripple` | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Scroll when** you need to render large lists in a framework-agnostic environment with precise control over item measurement and scroll position.\n\n**Consider TanStack Virtual** if you need its framework adapters and ecosystem integration.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/scroll\n```\n\n```sh [npm]\nnpm install @vielzeug/scroll\n```\n\n```sh [yarn]\nyarn add @vielzeug/scroll\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\n\nconst scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;\nconst spacer = document.querySelector<HTMLElement>('.spacer')!;\nconst list = document.querySelector<HTMLElement>('.list')!;\n\nconst virt = createVirtualizer(scrollEl, {\n count: 10_000,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n // Stretch the container so the scrollbar reflects the full list\n spacer.style.height = `${totalSize}px`;\n list.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.textContent = `Row ${item.index}`;\n list.appendChild(el);\n }\n },\n});\n\n// Clean up\nvirt.dispose();\n```\n\n### Entry Points\n\nAll APIs export from a single entry: `@vielzeug/scroll`.\n\n## Features\n\n<div class=\"features-grid\">\n\n- **Framework-agnostic** — callback-based `onChange` connects to any rendering layer (React, Vue, Svelte, Lit, vanilla DOM)\n- **Fixed and variable heights** — pass a fixed number, a per-index estimator function, or call `measure()` after rendering for exact heights\n- **Batched measurements** — calling `measure()` many times in a single tick coalesces into one prefix-sum rebuild via `queueMicrotask`\n- **Stable-key reflow** — call `refresh()` after reorder/filter changes to rebuild offsets without discarding measured sizes\n- **Sticky headers** — mark items with `sticky` to pin them at the viewport top; `createGroupedVirtualizer` handles section headers automatically\n- **Grouped sections** — `createGroupedVirtualizer` virtualizes sectioned data with per-section headers, `onChange` state, and `scrollToSection`/`scrollToItem`\n- **Grid virtualization** — `createGridVirtualizer` virtualizes two-dimensional data with independent row/column measurement and `scrollToCell`\n- `createReactiveVirtualizer()` / `createReactiveGroupedVirtualizer()` — Expose current state as a Ripple `Signal`\n- **DOM adapter** — `createDomVirtualList` and `createVirtualScroller` manage virtualizer lifecycle, list-height styles, and DOM node pooling\n- **Skipped re-renders** — `onChange` is not called when a scroll event doesn't move the visible window across an item boundary\n- **Programmatic scrolling** — `scrollToIndex()` with `start`, `end`, `center`, and `auto` alignment; `scrollToOffset()` for pixel control; `scrollToRow()`/`scrollToColumn()` for grids; all support `behavior: 'smooth'`\n- **Horizontal + window targets** — supports both element and `window` scrolling, in vertical or horizontal mode\n- **Asymmetric overscan + gap** — tune start/end overscan independently and add inter-item spacing\n- **Atomic updates** — `virt.update(...)` lets you change count, estimator, overscan, and more in one call\n- **Clamp-safe** — `scrollToIndex` silently clamps out-of-range indices\n- **Scroll state events** — `onScrollingChange` fires when scrolling starts/stops; `onScrollEnd` fires once scrolling settles (native `scrollend` or debounce fallback); `isScrolling` getter available at any time\n- **Scroll anchor** — viewport position is preserved visually when `estimateSize` changes via `update()`\n- **Prepend support** — `prepend()` adds items at the top while keeping the viewport visually stable\n- **Disposable** — implements `[Symbol.dispose]` for `using` declarations\n- `ScrollConfigurationError` — Rejects malformed static configuration before listeners attach or updates apply\n\n</div>\n\n## How It Works\n\nScroll maintains a prefix-sum offset array. On every scroll event it runs two binary searches — one for the first visible index, one for the last — to determine the render window in O(log n) time. Only the items within that window (plus `overscan` on each side) are passed to `onChange`.\n\n```text\nItems: [0] [1] [2] [3] [4] [5] [6] ...\nOffsets: 0 36 72 108 144 180 216 ...\n\nscrollTop = 90, containerHeight = 120 → visible items 2–5\nWith overscan=3: render items 0–8\n```\n\nThe offset array is rebuilt (O(n)) only when layout inputs change: on `measure()` flush, `refresh()`, `update({ count })`, `update({ estimateSize })`, or `invalidate()`. Scroll and resize events recompute the visible window without rebuilding offsets.\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Refine](/refine/) — accessible web components that use Scroll internally for virtualized listboxes and comboboxes\n- [Ore](/ore/) — web-component authoring layer; use with Scroll to build virtualizing custom elements\n- [Dnd](/dnd/) — drag-and-drop engine; combine with Scroll to make sortable virtual lists\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Scroll — API Reference\ndescription: Complete API reference for the Scroll virtual list engine.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| ------------------------------------ | ----------------------------------------------- | -------------- | ------------------------------------------------------------------------------------- |\n| `createVirtualizer()` | Core 1D virtualizer | Sync | `onChange` fires on construction — wire DOM first |\n| `createDomVirtualList()` | DOM adapter for dropdown/listbox UIs | Sync | Virtualizer is created lazily on first `setItems()` |\n| `createVirtualScroller()` | Self-contained scroller (creates its own DOM) | Sync | `dispose()` removes the generated scroll element |\n| `createGroupedVirtualizer()` | Sectioned list with sticky headers | Sync | `update()` preserves measured sizes — call `invalidate()` only on font/layout changes |\n| `createGridVirtualizer()` | Two-dimensional grid virtualizer | Sync | `onRangeChange` fires even when `onChange` is omitted |\n| `createReactiveVirtualizer()` | Virtualizer with reactive signal output | Sync | `onChange` must not be passed — it is wired internally |\n| `createReactiveGroupedVirtualizer()` | Grouped virtualizer with reactive signal output | Sync | `onChange` must not be passed — it is wired internally |\n\n## Package Entry Point\n\nEverything exports from a single entry:\n\n```ts\nimport {\n createVirtualizer,\n createDomVirtualList,\n createVirtualScroller,\n createGroupedVirtualizer,\n createGridVirtualizer,\n createReactiveVirtualizer,\n type Virtualizer,\n type VirtualItem,\n type VirtualizerState,\n} from '@vielzeug/scroll';\n```\n\n## `createVirtualizer(target, options)`\n\n```ts\ncreateVirtualizer(target: ScrollTarget, options: VirtualizerOptions): Virtualizer;\n```\n\nCreates and immediately attaches a virtualizer to the provided scroll container. `onChange` fires synchronously on construction with the initial visible window. Call `dispose()` on unmount.\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\n\nconst rows = [{ label: 'Ada Lovelace' }, { label: 'Grace Hopper' }];\nconst scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;\nconst listEl = document.querySelector<HTMLElement>('.list')!;\n\nconst virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n gap: 8,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const row = document.createElement('div');\n row.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:${item.size}px;`;\n row.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(row);\n }\n },\n});\n```\n\n### Parameters\n\n| Parameter | Type | Description |\n| --------- | ----------------------- | --------------------------- |\n| `target` | `HTMLElement \\| Window` | Scroll container to observe |\n| `options` | `VirtualizerOptions` | Initial options |\n\n### `VirtualizerOptions`\n\n| Option | Type | Default | Description |\n| ------------------- | -------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------ |\n| `count` | `number` | required | Total item count |\n| `estimateSize` | `number \\| (index: number) => number` | `36` | Fixed size or per-index estimate in pixels |\n| `gap` | `number` | `0` | Gap between adjacent items in pixels |\n| `getItemKey` | `(index: number) => string \\| number` | `index => index` | Stable key for the measurement cache |\n| `horizontal` | `boolean` | `false` | Virtualize along the X axis instead of Y |\n| `initialOffset` | `number` | — | Initial scroll position; applied once on construction |\n| `measurementCache` | `MeasurementCache` | — | Shared external cache for scroll restoration or SSR pre-measurement |\n| `onChange` | `(state: VirtualizerState) => void` | — | Called when the visible window changes; replace through `update()`. |\n| `onScrollEnd` | `(offset: number) => void` | — | Called when scrolling settles; replace through `update()`. |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | — | Called when scroll activity starts or stops; replace through `update()`. |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric on both sides |\n| `scrollEndDelay` | `number` | `150` | Debounce delay (ms) used to detect scroll end when native `scrollend` is unavailable |\n| `sticky` | `(index: number) => boolean` | — | Mark an item as a sticky header (pinned at viewport top) |\n\nCallbacks and `scrollEndDelay` can be replaced through `update()`; `horizontal` and `initialOffset` remain construction-only.\n\n**Returns:** `Virtualizer`\n\n### `VirtualizerState`\n\n```ts\ninterface VirtualizerState {\n readonly items: VirtualItem[];\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n}\n```\n\n`items` contains the currently visible items plus overscan. `stickyItems` contains items marked sticky that are pinned at the viewport top.\n\n### `Virtualizer` — read-only properties\n\n| Property | Type | Description |\n| -------------- | --------------- | ----------------------------------------------------------- |\n| `count` | `number` | Current item count |\n| `isScrolling` | `boolean` | `true` while the user is scrolling; `false` once settled |\n| `items` | `VirtualItem[]` | Currently rendered items. Always populated. |\n| `scrollOffset` | `number` | Current scroll position in pixels |\n| `stickyItems` | `VirtualItem[]` | Items pinned at the viewport top (requires `sticky` option) |\n| `totalSize` | `number` | Total height (or width in horizontal mode) |\n\n### `Virtualizer` — methods\n\n| Method | Signature | Description |\n| ------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------- |\n| `update` | `(next: VirtualizerUpdateOptions) => void` | Atomically update live options |\n| `measure` | `(index: number, size: number) => void` | Record one measured size; rebuild batched in microtask |\n| `measureBatch` | `(entries: Array<{ index: number; size: number }>) => void` | Record many sizes; single rebuild |\n| `measureEl` | `(index: number, el: HTMLElement) => () => void` | Attach ResizeObserver to auto-measure. Returns a disconnect function |\n| `refresh` | `() => void` | Rebuild offset table and re-emit; preserves cached measurements |\n| `prepend` | `(additionalCount: number) => void` | Add items at the top; adjusts scroll offset to keep viewport stable |\n| `scrollToIndex` | `(index: number, options?: ScrollToIndexOptions) => void` | Scroll to an item; out-of-range indices are clamped |\n| `scrollToOffset` | `(offset: number, options?: { behavior?: ScrollBehavior }) => void` | Scroll to a raw pixel offset |\n| `scrollToTop` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to offset `0` |\n| `scrollToBottom` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the end of the list |\n| `isAtEnd` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end — check before appending items to decide whether to auto-follow (chat \"stick to bottom\") |\n| `invalidate` | `() => void` | Clear all measurements and rebuild from estimates |\n| `dispose` | `() => void` | Detach listeners; idempotent |\n| `disposed` | `boolean` | `true` after `dispose()` is called |\n| `[Symbol.dispose]` | `() => void` | Delegates to `dispose()` — enables `using` declarations |\n\n### `update(next)`\n\nAtomically updates one or more live options. Accepts: `count`, `estimateSize`, `gap`, `getItemKey`, `measurementCache`, `onChange`, `onScrollEnd`, `onScrollingChange`, `overscan`, `scrollEndDelay`, and `sticky`. `horizontal` and `initialOffset` remain construction-only. Invalid static numeric values throw `ScrollConfigurationError` before any update applies.\n\nWhen `estimateSize` changes, the measurement cache is cleared and a scroll anchor is applied to keep the current viewport position visually stable.\n\n```ts\nvirt.update({ count: rows.length });\nvirt.update({ estimateSize: 40 });\nvirt.update({ gap: 8, overscan: { start: 5, end: 5 } });\n```\n\n### `measure(index, size)` and `measureBatch(entries)`\n\nReport exact sizes for variable-height rows. Calls within one microtask tick coalesce into a single offset rebuild. `measure()` is a no-op when the new size equals the current effective size.\n\n```ts\nvirt.measure(item.index, el.offsetHeight);\n\n// Prefer measureBatch for ResizeObserver batches\nvirt.measureBatch(entries.map((e) => ({ index: Number(e.target.dataset.index), size: e.contentRect.height })));\n```\n\n### `measureEl(index, el)`\n\nAttaches a `ResizeObserver` to auto-measure `el` on resize. Returns a disconnect function. The\nobserver is also disconnected automatically when the virtualizer is disposed, so calling the\nreturned function is only needed to stop observing a specific element early (e.g. before it is\nrecycled or removed).\n\n```ts\nconst disconnect = virt.measureEl(item.index, rowEl);\n// later: disconnect();\n```\n\n### `refresh()`\n\nRebuilds the full offset table and re-emits. Preserves cached measurements. Use after reordering, filtering, or any data change where sizes may have changed.\n\n### `prepend(additionalCount)`\n\nAdds `additionalCount` items at the front while adjusting scroll offset so the viewport stays visually stable. Use for \"load previous page\" patterns.\n\n### `scrollToIndex(index, options?)`\n\nScroll to an item. Out-of-range indices are clamped silently.\n\n| `align` | Behavior |\n| ------------------ | ------------------------------------------------------------ |\n| `'start'` | Item top at viewport top |\n| `'end'` | Item bottom at viewport bottom |\n| `'center'` | Item centered in the viewport |\n| `'auto'` (default) | No scroll if already fully visible; otherwise minimum scroll |\n\n```ts\nvirt.scrollToIndex(0, { align: 'start' });\nvirt.scrollToIndex(500, { align: 'center', behavior: 'smooth' });\nvirt.scrollToIndex(focusedIndex, { align: 'auto' });\n```\n\n### `scrollToOffset(offset, options?)`\n\n```ts\nvirt.scrollToOffset(Number(sessionStorage.getItem('scrollOffset') ?? '0'));\n```\n\n### `invalidate()`\n\nClears all measured sizes and rebuilds from estimator values.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\n### `dispose()` and `[Symbol.dispose]()`\n\n`dispose()` detaches observers and event listeners. It is idempotent.\n\n```ts\n{\n using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render });\n} // → dispose() called automatically\n```\n\n## `createDomVirtualList(options)`\n\n```ts\ncreateDomVirtualList<T>(options: DomVirtualListOptions<T>): DomVirtualListController<T>;\n```\n\nDOM-focused adapter. Manages virtualizer lifecycle, applies list-height styles automatically, and provides a node pool via `recycle`. The virtualizer is created lazily on the first non-empty `setItems()` call and destroyed automatically when `setItems([])` is called.\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\nconst ctrl = createDomVirtualList<Row>({\n estimateSize: 36,\n getItemKey: (_, row) => row.id,\n listElement: listEl,\n scrollElement: scrollEl,\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createElement('div'));\n el.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`;\n el.textContent = item.data.label;\n listEl.appendChild(el);\n }\n },\n});\n\nctrl.setItems(rows);\nctrl.scrollToIndex(focusedIndex, { align: 'auto' });\nctrl.dispose();\n```\n\n### `DomVirtualListOptions<T>`\n\n| Option | Type | Default | Description |\n| ------------------ | --------------------------------------------- | -------- | ---------------------------------------------------------- |\n| `scrollElement` | `HTMLElement \\| Window` | required | Scroll container to observe |\n| `listElement` | `HTMLElement` | required | Element that receives height and item children |\n| `render` | `(args: DomVirtualListRenderArgs<T>) => void` | required | Called on every visible-window change |\n| `estimateSize` | `number \\| (index, item) => number` | `36` | Fixed or per-item size estimate |\n| `gap` | `number` | `0` | Gap between items in pixels |\n| `getItemKey` | `(index, item) => string \\| number` | — | Stable key; keeps measurements across `setItems()` calls |\n| `horizontal` | `boolean` | `false` | Virtualize along X axis |\n| `measurementCache` | `MeasurementCache` | — | External measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |\n| `sticky` | `(index: number, item: T) => boolean` | — | Mark items as sticky headers |\n| `clear` | `(listEl: HTMLElement) => void` | — | Custom teardown for listEl; defaults to `textContent = ''` |\n| `stickToBottom` | `boolean \\| StickToBottomOptions` | — | Auto-scroll to the end after `setItems()` whenever the list was already at (or near) the end — the chat \"stick to bottom on new message\" pattern |\n\nWithout `getItemKey`, each `setItems()` call drops cached measurements.\n\n### `StickToBottomOptions`\n\n| Option | Type | Default | Description |\n| ----------- | --------- | ------- | --------------------------------------------------------------------------- |\n| `enabled` | `boolean` | `true` | Enable/disable at runtime — pass the object form to toggle without removing it |\n| `threshold` | `number` | `48` | Distance in pixels from the end still considered \"at the end\" |\n\n`stickToBottom` fires on **any** `setItems()` call made while the list is at the end — not just when the item count grows. This also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. It never fires while the user has scrolled away from the end, so reading older messages is never interrupted.\n\n```ts\nconst chat = createDomVirtualList<Message>({\n estimateSize: 48,\n getItemKey: (_, m) => m.id,\n listElement: listEl,\n render: renderMessages,\n scrollElement: scrollEl,\n stickToBottom: true, // or { threshold: 80 } for a larger \"still at bottom\" tolerance\n});\n\nchat.setItems(messages); // scrolls to bottom on first load\n// … later, a new message arrives (or the last one grows while streaming) …\nchat.setItems([...messages, newMessage]); // follows along only if the user was already at the bottom\n```\n\n### `DomVirtualListRenderArgs<T>`\n\n```ts\ntype DomVirtualListRenderArgs<T> = {\n items: Array<VirtualRenderItem<T>>; // visible items — each has .data + layout fields\n listEl: HTMLElement;\n recycle: RecycleFn; // node pool — returns existing node or calls create()\n stickyItems: Array<VirtualRenderItem<T>>; // sticky items (requires sticky option)\n totalSize: number;\n};\n```\n\n`VirtualRenderItem<T>` is `VirtualItem` (`start`, `end`, `size`, `index`) enriched with `data: T`.\n\n`recycle(key, create)` returns a live node for `key` if one exists in the pool, or calls `create()` for a new one. Nodes not reused in a render cycle are removed automatically. `listEl.style.height` is set before `render` is called — you do not need to set it yourself.\n\n### `DomVirtualListController<T>`\n\nExtends `Virtualizer` (minus `prepend` and `update`) with `setItems()`. All virtualizer methods and live getters are available directly.\n\n| Member | Description |\n| ------------------ | ------------------------------------------------------------------------------------------- |\n| `setItems(items)` | Set the current item array. Spawns virtualizer on first non-empty call; destroys it on `[]` |\n| `count` | Current item count (live getter) |\n| `items` | Currently rendered virtual items (live getter) |\n| `totalSize` | Total list size in pixels (live getter) |\n| `scrollOffset` | Current scroll position (live getter) |\n| `stickyItems` | Sticky items pinned at viewport top (live getter) |\n| `measure` | Delegate to underlying virtualizer; no-op before first `setItems` |\n| `measureBatch` | Batch measurement delegate |\n| `measureEl` | Attach auto-measuring ResizeObserver |\n| `refresh` | Rebuild offset table and re-emit |\n| `invalidate` | Clear measurements and rebuild from estimates |\n| `scrollToIndex` | Scroll to an item |\n| `scrollToOffset` | Scroll to a pixel offset |\n| `scrollToTop` | Scroll to offset `0` |\n| `scrollToBottom` | Scroll to the end of the list |\n| `isAtEnd` | `true` when within `threshold` px of the end |\n| `dispose` | Teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called (live getter) |\n| `[Symbol.dispose]` | Delegates to `dispose()` |\n\n## `createVirtualScroller(container, options)`\n\n```ts\ncreateVirtualScroller<T>(container: HTMLElement, options: VirtualScrollerOptions<T>): DomVirtualListController<T>;\n```\n\nCreates a scroll container `div` and inner list `div`, appends them to `container`, and returns a fully wired `DomVirtualListController`. Useful when the scroll DOM doesn't already exist.\n\n```ts\nconst list = createVirtualScroller<Row>(document.getElementById('root')!, {\n estimateSize: 36,\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createElement('div'));\n el.textContent = item.data.label;\n el.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);`;\n listEl.appendChild(el);\n }\n },\n});\n\nlist.setItems(rows);\nlist.dispose(); // also removes the generated scroll container\n```\n\n`VirtualScrollerOptions<T>` is `DomVirtualListOptions<T>` minus `listElement`/`scrollElement`, plus:\n\n| Option | Type | Description |\n| ---------------- | -------- | ------------------------------------------------- |\n| `containerClass` | `string` | CSS class applied to the generated scroll element |\n\n`dispose()` removes the generated scroll container from the DOM.\n\n## `createGroupedVirtualizer(target, options)`\n\n```ts\ncreateGroupedVirtualizer<T>(target: ScrollTarget, options: GroupVirtualizerOptions<T>): GroupVirtualizer<T>;\n```\n\nVirtualizes a sectioned list. Headers are automatically sticky (pinned at viewport top while the section is in view).\n\n```ts\nimport { createGroupedVirtualizer } from '@vielzeug/scroll';\n\ntype Contact = { id: number; name: string };\n\nconst virt = createGroupedVirtualizer<Contact>(scrollEl, {\n estimateHeaderSize: 32,\n estimateItemSize: 48,\n sections: [\n { label: 'A', items: [{ id: 1, name: 'Alice' }] },\n { label: 'B', items: [{ id: 2, name: 'Bob' }] },\n ],\n onChange: ({ headers, items, stickyHeader, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n if (stickyHeader) {\n const el = document.createElement('div');\n el.className = 'sticky-header';\n el.textContent = stickyHeader.label;\n listEl.appendChild(el);\n }\n\n for (const header of headers) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${header.start}px;height:${header.size}px;`;\n el.textContent = header.label;\n listEl.appendChild(el);\n }\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;height:${item.size}px;`;\n el.textContent = item.data.name;\n listEl.appendChild(el);\n }\n },\n});\n\nvirt.scrollToSection(1, { align: 'start' });\nvirt.update(nextSections);\nvirt.dispose();\n```\n\n### `GroupVirtualizerOptions<T>`\n\n| Option | Type | Default | Description |\n| -------------------- | ------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------- |\n| `sections` | `Array<GroupSection<T>>` | required | Initial sections |\n| `onChange` | `(state: GroupVirtualizerState<T>) => void` | — | Called when the visible window changes; replace through `update()`. |\n| `onScrollEnd` | `(offset: number) => void` | — | Called when scrolling settles; replace through `update()`. |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | — | Called when scroll activity starts or stops; replace through `update()`. |\n| `estimateHeaderSize` | `number \\| (section, sectionIndex) => number` | `36` | Header height estimate |\n| `estimateItemSize` | `number \\| (item, itemIndex, sectionIndex) => number` | `36` | Item height estimate |\n| `getItemKey` | `(item: T, itemIndex: number, sectionIndex: number) => VirtualKey` | — | Stable key for measurement cache |\n| `horizontal` | `boolean` | `false` | Virtualize along X axis |\n| `measurementCache` | `MeasurementCache` | — | External measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Overscan on each side (number = symmetric) |\n| `scrollEndDelay` | `number` | `150` | Debounce delay (ms) for scroll-end detection |\n\n### `GroupSection<T>`\n\n```ts\ninterface GroupSection<T> {\n items: T[];\n label: string;\n}\n```\n\n### `GroupVirtualizerState<T>`\n\n```ts\ninterface GroupVirtualizerState<T> {\n readonly headers: GroupVirtualHeader[];\n readonly items: Array<GroupVirtualItem<T>>;\n readonly stickyHeader: GroupVirtualHeader | null;\n readonly totalSize: number;\n}\n```\n\n`stickyHeader` is the header of the section currently at or above the viewport top, or `null` when at the very top. Render it as a floating overlay above the list.\n\n### `GroupVirtualItem<T>` and `GroupVirtualHeader`\n\n```ts\ninterface GroupVirtualItem<T> extends VirtualItem {\n data: T;\n itemIndex: number; // index within the section\n sectionIndex: number;\n}\n\ninterface GroupVirtualHeader extends VirtualItem {\n label: string;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualizer<T>` — methods\n\n`GroupVirtualizer<T>` is an independent interface that exposes all core virtualizer methods directly, plus grouped-specific navigation.\n\n| Method / Property | Description |\n| ---------------------------------- | ------------------------------------------------------------------------ |\n| `update(sections, opts?)` | Replace all sections with optional config overrides; see `GroupVirtualizerUpdateOptions<T>` |\n| `scrollToSection(i, options?)` | Scroll to section header at index `i`. Out-of-range is a no-op |\n| `scrollToItem(s, i, options?)` | Scroll to item `i` in section `s`. Out-of-range is a no-op |\n| `scrollToIndex(i, options?)` | Scroll to flat index `i` (from underlying virtualizer) |\n| `scrollToOffset(offset, options?)` | Scroll to a raw pixel offset |\n| `scrollToTop(options?)` | Scroll to offset `0` |\n| `scrollToBottom(options?)` | Scroll to the end of the list |\n| `measure(index, size)` | Record a measurement for a flat index |\n| `measureBatch(entries)` | Batch-record measurements for flat indices |\n| `measureEl(index, el)` | Attach auto-measuring ResizeObserver. Returns disconnect function |\n| `invalidate()` | Clear all measurements and rebuild |\n| `refresh()` | Rebuild offset table without clearing measurements |\n| `isScrolling` | `true` while the user is scrolling; `false` once scroll settles |\n| `scrollOffset` | Current scroll position in pixels (live getter) |\n| `dispose()` | Teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called |\n| `[Symbol.dispose]()` | Delegates to `dispose()` |\n\nAll scroll methods accept an optional `ScrollToIndexOptions` object (`{ align?, behavior?, onComplete? }`).\n\n### `GroupVirtualizerUpdateOptions<T>`\n\nPassed as the second argument to `groupVirtualizer.update()`. All fields are optional — omit any you don't want to change.\n\n| Option | Type | Description |\n| -------------------- | ------------------------------------------------------------- | -------------------------------------------------------- |\n| `estimateHeaderSize` | `number \\| (section, sectionIndex) => number` | New header size estimate, applied on next rebuild |\n| `estimateItemSize` | `number \\| (item, itemIndex, sectionIndex) => number` | New item size estimate, applied on next rebuild |\n| `getItemKey` | `(item, itemIndex, sectionIndex) => VirtualKey` | New item key function |\n| `measurementCache` | `MeasurementCache` | Hot-swap the measurement cache |\n| `overscan` | `number \\| { start?, end? }` | New overscan count |\n\n> `onChange`, `onScrollEnd`, `onScrollingChange`, and `scrollEndDelay` can be replaced through `update()`. `horizontal` remains construction-only.\n\n## `createGridVirtualizer(target, options)`\n\n```ts\ncreateGridVirtualizer(target: ScrollTarget, options: GridVirtualizerOptions): GridVirtualizer;\n```\n\nTwo-dimensional virtualizer. Fires `onChange` with visible row and column descriptors. Callers form the cross-product `rows × cols` to render visible cells.\n\n```ts\nimport { createGridVirtualizer } from '@vielzeug/scroll';\n\nconst grid = createGridVirtualizer(scrollEl, {\n rowCount: 10_000,\n colCount: 50,\n estimateRowSize: 36,\n estimateColSize: 120,\n onChange: ({ rows, cols, totalHeight, totalWidth }) => {\n containerEl.style.cssText = `position:relative;height:${totalHeight}px;width:${totalWidth}px;`;\n containerEl.replaceChildren();\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createElement('div');\n cell.style.cssText = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;`;\n cell.textContent = `${row.index},${col.index}`;\n containerEl.appendChild(cell);\n }\n }\n },\n});\n\ngrid.scrollToCell(500, 10, { rowAlign: 'center', colAlign: 'start' });\ngrid.dispose();\n```\n\n### `GridVirtualizerOptions`\n\n| Option | Type | Default | Description |\n| --------------------- | --------------------------------------- | ---------------------- | -------------------------------------- |\n| `rowCount` | `number` | required | Total row count |\n| `colCount` | `number` | required | Total column count |\n| `estimateRowSize` | `number \\| (row) => number` | `36` | Row height estimate |\n| `estimateColSize` | `number \\| (col) => number` | `36` | Column width estimate |\n| `rowGap` | `number` | `0` | Gap between rows |\n| `colGap` | `number` | `0` | Gap between columns |\n| `overscanY` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | Row overscan |\n| `overscanX` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | Column overscan |\n| `initialScrollTop` | `number` | — | Initial vertical scroll position |\n| `initialScrollLeft` | `number` | — | Initial horizontal scroll position |\n| `onChange` | `(state: GridVirtualizerState) => void` | — | Called when the visible window changes |\n| `onRangeChange` | `(range: GridRangeChangeEvent) => void` | — | Zero-allocation range callback |\n| `rowMeasurementCache` | `Map<number, number>` | — | External row measurement cache |\n| `colMeasurementCache` | `Map<number, number>` | — | External column measurement cache |\n\n### `GridVirtualizerState`\n\n```ts\ninterface GridVirtualizerState {\n readonly cols: VirtualItem[];\n readonly rows: VirtualItem[];\n readonly totalHeight: number;\n readonly totalWidth: number;\n}\n```\n\n### `GridVirtualizer` — properties and methods\n\n**Read-only properties:** `rows`, `cols`, `scrollTop`, `scrollLeft`, `totalHeight`, `totalWidth`\n\n| Method | Description |\n| ---------------------------------- | --------------------------------------------------------------------------------- |\n| `update(next)` | Atomically update row/col counts, estimates, gaps, and overscan |\n| `measureRow(row, size)` | Record a row height |\n| `measureColumn(col, size)` | Record a column width |\n| `measureBatch(rows, cols)` | Measure rows and columns in a single coordinated rebuild pass |\n| `measureRowEl(row, el)` | Auto-measure row height via ResizeObserver. Returns disconnect fn |\n| `measureColEl(col, el)` | Auto-measure column width via ResizeObserver. Returns disconnect fn |\n| `refresh()` | Rebuild offset tables from current measurements |\n| `invalidate()` | Clear all measurements and rebuild from estimates |\n| `scrollToCell(row, col, options?)` | Scroll to bring a cell into view; no-op when `rowCount === 0` or `colCount === 0` |\n| `scrollToRow(row, options?)` | Scroll to bring a row into view; `rowAlign` controls alignment |\n| `scrollToColumn(col, options?)` | Scroll to bring a column into view; `colAlign` controls alignment |\n| `prependRows(n)` | Add `n` rows at the top; adjusts scroll offset to keep viewport stable |\n| `dispose()` | Teardown; idempotent |\n| `[Symbol.dispose]()` | Delegates to `dispose()` |\n\n`measureRowEl`/`measureColEl`'s `ResizeObserver` is also disconnected automatically on `dispose()` —\nthe returned disconnect function is only needed to stop observing a specific element early.\n\n### `ScrollToCellOptions`\n\n```ts\ninterface ScrollToCellOptions {\n behavior?: ScrollBehavior;\n colAlign?: 'auto' | 'center' | 'end' | 'start';\n rowAlign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n## `createReactiveGroupedVirtualizer(target, options)`\n\n```ts\ncreateReactiveGroupedVirtualizer<T>(\n target: ScrollTarget,\n options: Omit<GroupVirtualizerOptions<T>, 'onChange'>,\n): ReactiveGroupVirtualizer<T>;\n```\n\nWraps `createGroupedVirtualizer` and exposes state as a `Signal<GroupVirtualizerState<T>>` from `@vielzeug/ripple`. All `GroupVirtualizer<T>` methods and live getters are available. `onChange` must not be provided — it is wired internally.\n\n```ts\nimport { createReactiveGroupedVirtualizer } from '@vielzeug/scroll';\nimport { effect } from '@vielzeug/ripple';\n\nconst virt = createReactiveGroupedVirtualizer<Contact>(scrollEl, {\n estimateHeaderSize: 32,\n estimateItemSize: 48,\n sections,\n});\n\neffect(() => {\n const { headers, items, stickyHeader, totalSize } = virt.state.value;\n render(headers, items, stickyHeader, totalSize);\n});\n\nvirt.update(nextSections);\nvirt.dispose();\n```\n\n### `ReactiveGroupVirtualizer<T>`\n\n```ts\ninterface ReactiveGroupVirtualizer<T> extends GroupVirtualizer<T> {\n readonly state: Signal<GroupVirtualizerState<T>>;\n}\n```\n\nThe `state` signal is updated synchronously on every render cycle. All live getters remain current through copied property descriptors.\n\n## `createReactiveVirtualizer(target, options)`\n\n```ts\ncreateReactiveVirtualizer(\n target: ScrollTarget,\n options: Omit<VirtualizerOptions, 'onChange'>,\n): ReactiveVirtualizer;\n```\n\nWraps `createVirtualizer` and exposes state as a `Signal<VirtualizerState>` from `@vielzeug/ripple`. All `Virtualizer` methods and live getters are available on the returned object. `onChange` must not be provided — it is wired internally.\n\n```ts\nimport { createReactiveVirtualizer } from '@vielzeug/scroll';\nimport { effect } from '@vielzeug/ripple';\n\nconst virt = createReactiveVirtualizer(scrollEl, {\n count: 1000,\n estimateSize: 40,\n});\n\neffect(() => {\n const { items, totalSize } = virt.state.value;\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;height:${item.size}px;`;\n listEl.appendChild(el);\n }\n});\n\nvirt.update({ count: 2000 });\nvirt.dispose();\n```\n\n### `ReactiveVirtualizer`\n\n```ts\ninterface ReactiveVirtualizer extends Virtualizer {\n readonly state: Signal<VirtualizerState>;\n}\n```\n\nThe `state` signal is updated synchronously whenever the visible window changes. All live getters (`count`, `items`, `totalSize`, `scrollOffset`, `stickyItems`) remain current.\n\n## Types\n\n### `VirtualItem`\n\n```ts\ninterface VirtualItem {\n end: number;\n index: number;\n size: number;\n start: number;\n}\n```\n\n### `VirtualizerState`\n\n```ts\ninterface VirtualizerState {\n readonly items: VirtualItem[];\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n}\n```\n\n### `ScrollToIndexOptions`\n\n```ts\ninterface ScrollToIndexOptions {\n align?: 'auto' | 'center' | 'end' | 'start';\n behavior?: ScrollBehavior;\n /** Called when the scroll animation completes (instant scrolls: next microtask). */\n onComplete?: () => void;\n}\n```\n\n### `Overscan`\n\n```ts\ntype Overscan = number | { end?: number; start?: number };\n```\n\nPassing a number is shorthand for symmetric overscan on both sides.\n\n### `VirtualKey`\n\n```ts\ntype VirtualKey = number | string;\n```\n\n### `VirtualRenderItem<T>`\n\n```ts\ntype VirtualRenderItem<T> = VirtualItem & { readonly data: T };\n```\n\n### `ScrollTarget`\n\n```ts\ntype ScrollTarget = HTMLElement | Window;\n```\n\n### `MeasurementCache`\n\n```ts\ntype MeasurementCache = Map<VirtualKey, number>;\n```\n\nUse `createMeasurementCache()` to create an empty cache:\n\n```ts\nimport { createMeasurementCache } from '@vielzeug/scroll';\n\nconst cache = createMeasurementCache();\nconst virt1 = createVirtualizer(el1, { count: 100, measurementCache: cache });\nconst virt2 = createVirtualizer(el2, { count: 100, measurementCache: cache });\n```\n\n### `RecycleFn`\n\n```ts\ntype RecycleFn = (key: VirtualKey, create: () => HTMLElement) => HTMLElement;\n```\n\n### `VirtualizerUpdateOptions`\n\n```ts\ninterface VirtualizerUpdateOptions {\n count?: number;\n estimateSize?: number | ((index: number) => number);\n gap?: number;\n getItemKey?: (index: number) => VirtualKey;\n /** Replace the active measurement cache. Existing entries are used immediately on the next rebuild. */\n measurementCache?: MeasurementCache;\n overscan?: Overscan;\n sticky?: (index: number) => boolean;\n}\n```\n\n### `VirtualScrollerOptions<T>`\n\n`DomVirtualListOptions<T>` minus `listElement` and `scrollElement`, plus:\n\n```ts\ntype VirtualScrollerOptions<T> = Omit<DomVirtualListOptions<T>, 'listElement' | 'scrollElement'> & {\n /** CSS class applied to the generated scroll container element. */\n containerClass?: string;\n};\n```\n\n### `GridVirtualizerUpdateOptions`\n\n```ts\ninterface GridVirtualizerUpdateOptions {\n colCount?: number;\n colGap?: number;\n estimateColSize?: number | ((col: number) => number);\n estimateRowSize?: number | ((row: number) => number);\n overscanX?: { end?: number; start?: number };\n overscanY?: { end?: number; start?: number };\n rowCount?: number;\n rowGap?: number;\n}\n```\n\n### `GridRangeChangeEvent`\n\nFired by `onRangeChange` on `createGridVirtualizer`. Zero-allocation alternative to `onChange` — no `rows`/`cols` arrays are allocated.\n\n```ts\ninterface GridRangeChangeEvent {\n firstCol: number;\n firstRow: number;\n lastCol: number;\n lastRow: number;\n}\n```\n\n## Errors\n\n| Class | Thrown when | Notable properties |\n| --- | --- | --- |\n| `ScrollError` | Base class for every Scroll error. | `ScrollError.is(error)` narrows errors from this package. |\n| `ScrollConfigurationError` | A constructor or `update()` receives invalid static configuration. | Extends `ScrollError`; malformed JavaScript values also use this class. |\n| `ScrollRangeError` | A DOM virtual-list render detects that a caller mutated its items array without calling `setItems()` again. | Extends `ScrollError`; message includes stale index and current item count. |\n\nRuntime estimator failures, stale measurements, and out-of-range navigation remain resilient: they fall back, no-op, or clamp as documented.\n\n### Constants\n\n```ts\nconst DEFAULT_ESTIMATE_SIZE = 36; // default estimateSize\nconst DEFAULT_OVERSCAN = 3; // default overscan on each side\n```\n",
|
|
6
|
+
"usage": "---\ntitle: Scroll — Usage Guide\ndescription: Fixed and variable heights, measurement, programmatic scrolling, and framework integration for Scroll.\n---\n\n[[toc]]\n\n## Basic Usage\n\nRender only visible rows by passing a scroll container, a total item count, and a size estimate. Scroll calls `onChange` with the visible window whenever it changes.\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\n\nconst scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;\nconst listEl = document.querySelector<HTMLElement>('.list')!;\n\nconst virt = createVirtualizer(scrollEl, {\n count: 10_000,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = `Row ${item.index}`;\n listEl.appendChild(el);\n }\n },\n});\n\n// Cleanup\nvirt.dispose();\n```\n\n```html\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;position:relative;\">\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## DOM Layout Requirements\n\nScroll uses **absolute positioning** for rendered items inside a relative container that stretches to the full list height. Your HTML needs three elements:\n\n```html\n<!-- 1. Scroll container — has a fixed height and overflow:auto/scroll -->\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;position:relative;\">\n <!-- 2. Spacer — height set to totalSize so the scrollbar is correct -->\n <div class=\"spacer\" style=\"position:relative;\">\n <!-- 3. Item container — items positioned absolutely inside here -->\n <div class=\"items\"></div>\n </div>\n</div>\n```\n\nA common alternative is to make the spacer and item container the same element:\n\n```html\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;\">\n <!-- Single relative container; items are absolute children -->\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## DOM Adapter for Dropdowns and Listboxes\n\nIf your component already has a dropdown scroll container and a listbox element, use `createDomVirtualList`. It wraps the `Virtualizer` lifecycle and keeps the integration surface small. Items arrive as `VirtualRenderItem<T>` — a `VirtualItem` enriched with a `.data` field. Use `recycle` for efficient DOM node reuse.\n\nThe virtualizer is created lazily on the first non-empty `setItems()` call and destroyed automatically when `setItems([])` is called (clearing list styles in the process).\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\ntype Option = { disabled?: boolean; label: string; value: string };\n\nlet options: Option[] = [];\n\nconst domVirtualList = createDomVirtualList<Option>({\n estimateSize: 36,\n gap: 6,\n getItemKey: (_index, option) => option.value,\n listElement: listboxEl,\n overscan: { end: 4, start: 4 },\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const row = recycle(item.data.value, () => document.createElement('button'));\n row.type = 'button';\n row.className = 'option';\n row.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`;\n row.textContent = item.data.label;\n row.disabled = !!item.data.disabled;\n listEl.appendChild(row);\n }\n },\n scrollElement: dropdownEl,\n});\n\n// Keep in sync when options change\ndomVirtualList.setItems(options);\n\n// Open: setItems populates the list\n// Close: setItems([]) destroys the virtualizer and clears list styles\ndomVirtualList.setItems(isOpen ? options : []);\n\n// Keyboard nav\ndomVirtualList.scrollToIndex(focusedIndex, { align: 'auto' });\n\n// Component teardown\ndomVirtualList.dispose();\n```\n\nFor variable-height rows, pass `getItemKey` so measurements survive `setItems()` calls when items reorder or are filtered.\n\nWhen multiple sizes are available at once, use `measureBatch` to coalesce into a single rebuild:\n\n```ts\ndomVirtualList.measureBatch(\n entries.map((e) => ({ index: Number(e.target.dataset.index), size: e.contentRect.height })),\n);\n```\n\nUse `domVirtualList.invalidate()` to discard all cached measurements.\n\n## Fixed Heights\n\nPass a single number to `estimateSize` when all rows are the same height. This is the simplest and most performant case — the offset table never needs to be rebuilt during scrolling.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: 10_000,\n estimateSize: 36, // every row is 36px\n onChange: ({ items, totalSize }) => {\n list.style.height = `${totalSize}px`;\n list.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = data[item.index].name;\n list.appendChild(el);\n }\n },\n});\n```\n\n## Variable Heights — Estimator\n\nPass a **per-index function** to `estimateSize` when rows have predictable but non-uniform heights (e.g. group headers vs. regular rows). The offset table is built once at attach time using these estimates.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: flatList.length,\n estimateSize: (i) => (flatList[i].type === 'header' ? 48 : 36),\n onChange: ({ items, totalSize }) => {\n // render...\n },\n});\n```\n\n## Variable Heights — Measured\n\nFor truly dynamic heights (e.g. text wrapping, embedded images), render items at their estimated size first, then report the actual measured height with `measure()`. Scroll will coalesce all measurement calls within a single microtask tick into one offset rebuild.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 60, // initial estimate\n onChange: ({ items, totalSize }) => {\n list.style.height = `${totalSize}px`;\n list.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.dataset.index = String(item.index);\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.textContent = rows[item.index].body;\n list.appendChild(el);\n }\n\n // Measure after the DOM has painted\n requestAnimationFrame(() => {\n for (const item of items) {\n const el = list.querySelector<HTMLElement>(`[data-index=\"${item.index}\"]`);\n if (el) virt.measure(item.index, el.offsetHeight);\n }\n });\n },\n});\n```\n\n::: tip Measurement is idempotent\n`measure(index, height)` is a no-op when the new height matches the current effective height (measured or estimated). It is safe to call on every render without triggering unnecessary rebuilds.\n:::\n\n## Variable Heights — Batch Measurement\n\nWhen a `ResizeObserver` fires with multiple entries at once, use `measureBatch()` to apply all sizes in a single offset rebuild instead of triggering one rebuild per `measure()` call.\n\n```ts\nconst observer = new ResizeObserver((entries) => {\n virt.measureBatch(\n entries\n .filter((e) => e.target instanceof HTMLElement && e.target.dataset.index)\n .map((e) => ({\n index: Number((e.target as HTMLElement).dataset.index),\n size: e.contentRect.height,\n })),\n );\n});\n\n// Observe each rendered row\nfor (const item of virt.items) {\n const el = listEl.querySelector<HTMLElement>(`[data-index=\"${item.index}\"]`);\n if (el) observer.observe(el);\n}\n```\n\n## Overscan\n\n`overscan` controls how many extra items render outside the visible viewport on each side. Higher values reduce the chance of blank rows during fast scrolling; lower values keep the DOM smaller.\n\n```ts\ncreateVirtualizer(scrollEl, {\n count: 1_000,\n estimateSize: 36,\n overscan: 5, // symmetric shorthand — same as { start: 5, end: 5 } (default: 3)\n onChange: () => {\n /* ... */\n },\n});\n```\n\nAsymmetric overscan:\n\n```ts\ncreateVirtualizer(scrollEl, {\n count: 1_000,\n estimateSize: 36,\n overscan: { start: 8, end: 2 },\n onChange: () => {\n /* ... */\n },\n});\n```\n\n## Horizontal Lists\n\nSet `horizontal: true` to virtualize along the X axis.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: chips.length,\n estimateSize: 120,\n horizontal: true,\n onChange: ({ items, totalSize }) => {\n list.style.width = `${totalSize}px`;\n\n for (const item of items) {\n const chip = document.createElement('button');\n chip.style.cssText = `position:absolute;left:${item.start}px;top:0;width:${item.size}px;`;\n chip.textContent = chips[item.index].label;\n list.appendChild(chip);\n }\n },\n});\n```\n\n## Window Scroll Target\n\n`createVirtualizer` accepts `window` as the scroll target.\n\n```ts\nconst virt = createVirtualizer(window, {\n count: rows.length,\n estimateSize: 40,\n initialOffset: 320,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = `${totalSize}px`;\n renderRows(items);\n },\n});\n```\n\n## Scroll State\n\nUse `virt.scrollOffset` to read the current scroll position at any time.\n\n```ts\nconst virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, onChange: render });\n\n// Accessed outside onChange\nconsole.log(virt.scrollOffset);\n```\n\n## Updating Options\n\nWhen data or render strategy changes, call `update()` with one or more option fields. Updates apply atomically and trigger re-render when needed. Counts, gaps, and overscan must be finite non-negative integers; numeric size estimates must be finite positive values; offsets and `scrollEndDelay` must be finite non-negative numbers. Invalid constructor or `update()` values throw `ScrollConfigurationError` before any change applies.\n\nRuntime layout data stays resilient: estimator callbacks that throw or return invalid sizes fall back to the default estimate, stale measurements are ignored, and out-of-range navigation clamps or no-ops.\n\n```ts\n// Load more data\ndata.push(...newItems);\nvirt.update({ count: data.length });\n```\n\n```ts\n// Change multiple options together\nvirt.update({ count: data.length, overscan: { start: 5, end: 5 } });\n\n// Rebuild after reordering/filtering stable-key rows\nvirt.refresh();\n```\n\n## Switching Row Density\n\nUpdating `estimateSize` clears all previously measured heights, rebuilds offsets, and re-renders. This makes density switching (compact / comfortable / spacious views) straightforward.\n\n```ts\nfunction setDensity(mode: 'compact' | 'comfortable') {\n virt.update({ estimateSize: mode === 'compact' ? 32 : 48 });\n}\n```\n\n## Programmatic Scrolling\n\n### `scrollToIndex(index, options?)`\n\nScroll to bring a specific item into view.\n\n| `align` | Behaviour |\n| ------------------ | ------------------------------------------------------------------------ |\n| `'start'` | Item top aligns with the container top |\n| `'end'` | Item bottom aligns with the container bottom |\n| `'center'` | Item is centered in the viewport |\n| `'auto'` (default) | No scroll if already fully visible; otherwise scrolls the minimum amount |\n\n```ts\n// Jump to item 500 at the top of the viewport\nvirt.scrollToIndex(500, { align: 'start' });\n\n// Smooth-scroll to an item, centering it\nvirt.scrollToIndex(500, { align: 'center', behavior: 'smooth' });\n\n// Scroll only if the item is not already visible\nvirt.scrollToIndex(focusedIndex, { align: 'auto' });\n```\n\nOut-of-range indices are clamped silently: negative values scroll to item `0`, values ≥ `count` scroll to the last item.\n\n### `scrollToOffset(offset, options?)`\n\nScroll to an exact pixel position, useful for restoring a previously saved scroll state.\n\n```ts\n// Restore scroll position\nconst savedOffset = sessionStorage.getItem('scrollOffset');\nif (savedOffset) virt.scrollToOffset(Number(savedOffset));\n\n// Save on scroll\nscrollEl.addEventListener('scroll', () => {\n sessionStorage.setItem('scrollOffset', String(scrollEl.scrollTop));\n});\n```\n\n### `scrollToTop(options?)` / `scrollToBottom(options?)`\n\nConvenience wrappers to jump directly to the start or end of the list.\n\n```ts\n// Jump to the top\nvirt.scrollToTop();\n\n// Jump to the bottom with smooth scroll\nvirt.scrollToBottom({ behavior: 'smooth' });\n```\n\n### Chat \"stick to bottom on new message\"\n\n`createDomVirtualList`'s `stickToBottom` option automates the common chat/log pattern: follow new messages while the user is at the bottom, but never yank them away from history they scrolled up to read.\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\nconst chat = createDomVirtualList<Message>({\n estimateSize: 48,\n getItemKey: (_, m) => m.id,\n listElement: listEl,\n render: renderMessages,\n scrollElement: scrollEl,\n stickToBottom: true, // or { threshold: 80 } to widen the \"still at bottom\" tolerance\n});\n\nchat.setItems(messages);\n\n// New message arrives — follows only if the user hasn't scrolled up.\nsocket.on('message', (msg) => {\n messages = [...messages, msg];\n chat.setItems(messages);\n});\n```\n\nIt also follows a **streaming** last message that grows in place (tokens appended to the same message object, array length unchanged) — every `setItems()` call re-checks \"was the list at the end before this update?\", not just count changes. Build `isAtEnd()` from `createVirtualizer` directly for custom cases (e.g. showing a \"jump to latest\" button only while scrolled away):\n\n```ts\nconst showJumpButton = !virt.isAtEnd();\n```\n\n## Infinite Scroll — Loading More at the End\n\nUse `isAtEnd(threshold)` to fetch the next page as the user nears the bottom. `isAtEnd()` reports scroll position only — it keeps returning `true` while a fetch is in flight — so guard it with your own `loading` flag to avoid firing the same request twice.\n\n```ts\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\nlet rows = await fetchPage(0);\nlet loading = false;\n\nlet virt: Virtualizer;\nvirt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n\n if (!loading && virt.isAtEnd(200)) {\n loading = true;\n fetchPage(rows.length).then((nextRows) => {\n rows = [...rows, ...nextRows];\n virt.update({ count: rows.length });\n loading = false;\n });\n }\n },\n});\n```\n\n`isAtEnd(200)` fires once the viewport is within 200px of the bottom — tune the threshold to your row height and fetch latency. `loading` is the only guard needed: it's cleared once the new page lands, and `update({ count })` re-triggers `onChange`, which re-checks `isAtEnd()` against the new total on the next scroll.\n\n## Shared Measurement Cache\n\nWhen the same items are displayed across multiple virtualizer instances (e.g. a list and a detail panel that share row heights), pass a shared `MeasurementCache` created by `createMeasurementCache()`. Measurements recorded by one virtualizer are immediately available to all others using the same cache.\n\n```ts\nimport { createMeasurementCache, createVirtualizer } from '@vielzeug/scroll';\n\nconst cache = createMeasurementCache();\n\nconst listVirt = createVirtualizer(listScrollEl, {\n count: rows.length,\n estimateSize: 36,\n measurementCache: cache,\n onChange: renderList,\n});\n\nconst previewVirt = createVirtualizer(previewScrollEl, {\n count: rows.length,\n estimateSize: 36,\n measurementCache: cache,\n onChange: renderPreview,\n});\n\n// A measurement on listVirt is reflected in previewVirt immediately.\nlistVirt.measure(0, 72);\n```\n\nThe cache is a plain `Map<VirtualKey, number>` — you can pre-populate it from server data or persist it across sessions.\n\n```ts\n// Pre-populate from server-sent sizes\nconst cache = createMeasurementCache();\nfor (const { id, height } of serverSizes) cache.set(id, height);\n```\n\n## Invalidating Measurements\n\nCall `invalidate()` after an event that changes item heights without a data change — for example, a font load, a viewport width change that causes text to reflow, or toggling between a grid and list layout.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\nOn variable-height lists, `scrollToIndex()` uses the current estimate/measured cache. If you need an exact post-layout position after heights change, call `invalidate()` before scrolling again.\n\nFor same-length updates, call `setItems()` (DOM adapter) or `update()` (core). If the rendered height of rows changed, call `invalidate()` before scrolling again.\n\n## Lifecycle — create and dispose\n\n`createVirtualizer(el, options)` attaches immediately to the provided scroll container. If your container is replaced, dispose the old instance and create a new one.\n\n```ts\nlet virt = createVirtualizer(scrollContainerEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: render,\n});\n\nfunction remount(nextScrollContainerEl: HTMLElement) {\n virt.dispose();\n virt = createVirtualizer(nextScrollContainerEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: render,\n });\n}\n```\n\n`dispose()` is idempotent and safe to call multiple times.\n\n### Explicit Resource Management\n\n```ts\n// The `using` keyword calls virt.dispose() automatically at block exit\n{\n using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render });\n // ... use virt ...\n} // → virt.dispose() called here\n```\n\n## Framework Integration\n\nScroll is rendering-layer agnostic. The pattern is always the same: create the virtualizer when your scroll container is mounted, re-render your DOM in `onChange`, and call `dispose()` on unmount.\n\n::: code-group\n\n```tsx [React]\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\nimport { useEffect, useLayoutEffect, useRef } from 'react';\n\ninterface Row {\n id: number;\n label: string;\n}\n\nfunction VirtualList({ rows }: { rows: Row[] }) {\n const scrollRef = useRef<HTMLDivElement>(null);\n const listRef = useRef<HTMLDivElement>(null);\n const virtRef = useRef<Virtualizer | null>(null);\n\n useEffect(() => {\n const scrollEl = scrollRef.current;\n const listEl = listRef.current;\n if (!scrollEl || !listEl) return;\n\n const virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n virtRef.current = virt;\n return () => virt.dispose();\n }, []); // attach once\n\n // useLayoutEffect, not useEffect: syncs count before paint. With useEffect,\n // the DOM (and anything reading `rows`) paints once with the new length before\n // the virtualizer's internal count catches up, which can render stale/out-of-bounds indices.\n useLayoutEffect(() => {\n virtRef.current?.update({ count: rows.length });\n }, [rows.length]);\n\n return (\n <div ref={scrollRef} style={{ height: 400, overflow: 'auto', position: 'relative' }}>\n <div ref={listRef} style={{ position: 'relative' }} />\n </div>\n );\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\nimport { onMounted, onUnmounted, ref, watch } from 'vue';\n\nconst props = defineProps<{ rows: { id: number; label: string }[] }>();\nconst scrollRef = ref<HTMLElement | null>(null);\nconst listRef = ref<HTMLElement | null>(null);\nlet virt: Virtualizer | null = null;\n\nonMounted(() => {\n if (!scrollRef.value || !listRef.value) return;\n const listEl = listRef.value;\n virt = createVirtualizer(scrollRef.value, {\n count: props.rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = props.rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n});\nwatch(\n () => props.rows.length,\n (n) => {\n virt?.update({ count: n });\n },\n);\nonUnmounted(() => virt?.dispose());\n</script>\n\n<template>\n <div ref=\"scrollRef\" style=\"height:400px;overflow:auto;position:relative;\">\n <div ref=\"listRef\" style=\"position:relative;\" />\n </div>\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\n let { rows }: { rows: { id: number; label: string }[] } = $props();\n let scrollEl: HTMLElement;\n let listEl: HTMLElement;\n let virt: Virtualizer;\n\n $effect(() => {\n virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n return () => virt.dispose();\n });\n\n $effect(() => { virt?.update({ count: rows.length }); });\n</script>\n\n<div bind:this={scrollEl} style=\"height:400px;overflow:auto;position:relative;\">\n <div bind:this={listEl} style=\"position:relative;\" />\n</div>\n```\n\n```ts [Web Components]\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\n@customElement('virtual-list')\nclass VirtualList extends LitElement {\n static styles = css`\n .scroll {\n height: 400px;\n overflow: auto;\n position: relative;\n }\n .list {\n position: relative;\n }\n `;\n\n @property({ type: Array }) rows: { label: string }[] = [];\n #virt: Virtualizer | null = null;\n\n firstUpdated() {\n const scrollEl = this.renderRoot.querySelector<HTMLElement>('.scroll')!;\n const listEl = this.renderRoot.querySelector<HTMLElement>('.list')!;\n this.#virt = createVirtualizer(scrollEl, {\n count: this.rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = this.rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n }\n\n updated() {\n this.#virt?.update({ count: this.rows.length });\n }\n disconnectedCallback() {\n this.#virt?.dispose();\n super.disconnectedCallback();\n }\n render() {\n return html`<div class=\"scroll\"><div class=\"list\"></div></div>`;\n }\n}\n```\n\n:::\n\n### Pitfalls\n\n- **React:** Putting `rows` in the `useEffect` dependency array causes the virtualizer to be destroyed and recreated on every data update. Only include the scroll element reference. Call `virt.update({ count })` from a separate `useEffect` for data changes.\n- **React:** Use `useLayoutEffect`, not `useEffect`, for the `count`-sync effect. `useEffect` fires after paint — a new `count` can reach the DOM (e.g. via other state derived from `rows`) before `update({ count })` runs, rendering stale or out-of-bounds indices for one frame.\n- **Vue 3:** `ref.value` is `null` inside `setup()` — the DOM doesn't exist yet. Always create the virtualizer inside `onMounted`, not in `setup()`.\n- **Svelte:** In Svelte 5, `$effect` with `bind:this` runs after the DOM is painted. The `bind:this` variable is available when the `$effect` runs — no extra tick needed.\n- **Web Components:** `firstUpdated` fires once after the first render. Use `updated()` for subsequent prop changes — Lit calls it every time `rows` changes.\n\n## Working with Other Vielzeug Libraries\n\n### With Ore\n\nBuild a virtualizing custom element using Ore for the component shell and Scroll for the rendering engine.\n\n```ts\nimport { define, html, onMounted, ref } from '@vielzeug/ore';\nimport { createVirtualizer } from '@vielzeug/scroll';\n\ndefine('virtual-list', {\n setup() {\n const scrollRef = ref<HTMLElement>();\n const listRef = ref<HTMLElement>();\n\n onMounted(() => {\n if (!scrollRef.value || !listRef.value) return;\n const listEl = listRef.value;\n const virt = createVirtualizer(scrollRef.value, {\n count: 1000,\n estimateSize: 40,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const row = document.createElement('div');\n\n row.style.cssText = `position:absolute;top:${item.start}px;height:40px;`;\n row.textContent = `Row ${item.index}`;\n listEl.appendChild(row);\n }\n },\n },\n },\n });\n return () => virt.dispose();\n });\n\n return () => html`\n <div ref=${scrollRef} style=\"height:400px;overflow:auto;position:relative\">\n <div ref=${listRef} style=\"position:relative\"></div>\n </div>\n `;\n },\n});\n```\n\n## Best Practices\n\n- Always provide `count` and `estimateSize` as a starting point, even for variable-height lists — measurements refine the estimates.\n- Call `dispose()` in the framework cleanup callback (useEffect return, onUnmounted, onDestroy) to free resize observers.\n- Use `overscan` to pre-render rows above and below the visible area to reduce blank flicker during fast scrolling.\n- Prefer `scrollToIndex()` with `align: 'start'` for programmatic navigation; use `align: 'center'` for focus management.\n- Use `createDomVirtualList()` for comboboxes, listboxes, and selects — it manages the virtualizer lifecycle and DOM node pooling for you.\n- Invalidate measurements with `invalidate()` when item content changes size (e.g., after expanding an accordion row).\n- For very large lists (>100k items), set a narrower `overscan` to limit DOM node count at any one time.\n- Use `refresh()` when item data or sizes may have changed; it rebuilds the offset table and re-emits.\n",
|
|
7
7
|
"examples": "---\ntitle: Scroll — Examples\ndescription: Practical examples and recipes for scroll.\n---\n\n## Examples\n\n- [Basic Fixed Height List](./examples/basic-fixed-height-list.md)\n- [Variable Height With Measurement](./examples/variable-height-with-measurement.md)\n- [Grouped List Headers Plus Rows](./examples/grouped-list-headers-plus-rows.md)\n- [Infinite Scroll Load More](./examples/infinite-scroll-load-more.md)\n- [Keyboard Navigation](./examples/keyboard-navigation.md)\n- [Restore Scroll Position](./examples/restore-scroll-position.md)\n- [Density Toggle Compact Comfortable](./examples/density-toggle-compact-comfortable.md)\n- [DOM Virtual List Combobox Pattern](./examples/dom-virtual-list-combobox-pattern.md)\n- [Grid Virtualizer](./examples/grid-virtualizer.md)\n- [Reactive Virtualizer](./examples/reactive-virtualizer.md)\n- [Infinite Scroll with Analytics and Prefetch](./examples/on-range-change.md)\n- [Sticky Items in DOM Virtual List](./examples/dom-virtual-list-sticky.md)\n- [Recreate on Remount](./examples/using-virtualizer-directly-without-createvirtualizer.md)\n- [Explicit Resource Management (`using`)](./examples/explicit-resource-management-using.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
|
11
11
|
"id": "basic-list",
|
|
12
|
-
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 100\nconst ROW_HEIGHT = 40\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:400px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;margin:1rem;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: ROW_HEIGHT,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.
|
|
12
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 100\nconst ROW_HEIGHT = 40\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:400px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;margin:1rem;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: ROW_HEIGHT,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 16px;border-bottom:1px solid #f0f0f0;background:${index % 2 ? '#fafafa' : '#fff'};`\n row.textContent = `Row #${index + 1} of ${ITEM_COUNT}`\n content.appendChild(row)\n })\n },\n})\n\nconsole.log(`✓ Virtualizer created with ${ITEM_COUNT} rows`)\nconsole.log('Rendered DOM nodes:', virtualizer.items.length, '(out of', ITEM_COUNT, ')')",
|
|
13
13
|
"name": "Virtualizer - Basic List"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"id": "dynamic-count",
|
|
17
|
-
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nlet items = ['Alpha', 'Beta', 'Gamma']\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:200px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: items.length,\n estimateSize: 44,\n onChange: ({ items: virtualItems, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.
|
|
17
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nlet items = ['Alpha', 'Beta', 'Gamma']\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:200px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: items.length,\n estimateSize: 44,\n onChange: ({ items: virtualItems, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n virtualItems.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;line-height:${size}px;padding:0 16px;border-bottom:1px solid #f5f5f5;`\n row.textContent = items[index]\n content.appendChild(row)\n })\n },\n})\n\nconsole.log('Initial count:', virtualizer.count)\n\n// Dynamically add more items\nsetTimeout(() => {\n items = [...items, 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta']\n virtualizer.update({ count: items.length })\n console.log('Updated count:', virtualizer.count)\n}, 300)\n\n// Reorder items with stable keys — refresh() forces rebuild while preserving sizes\nsetTimeout(() => {\n items = [...items].sort(() => Math.random() - 0.5)\n virtualizer.refresh()\n console.log('Items reordered — refresh() called (sizes preserved by key)')\n}, 700)",
|
|
18
18
|
"name": "Virtualizer - Dynamic Count"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"id": "grid-virtualizer",
|
|
22
|
-
"code": "import { createGridVirtualizer } from '@vielzeug/scroll'\n\n// Two-dimensional grid virtualization — only the visible rows × cols\n// cross-product is mounted, independent of total grid size.\n\nconst ROW_COUNT = 10_000\nconst COL_COUNT = 20\nconst ROW_H = 32\nconst COL_W = 100\n\nconst scrollEl = document.createElement('div')\nscrollEl.style.cssText = 'height:320px;overflow:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(scrollEl)\n\nconst container = document.createElement('div')\nscrollEl.appendChild(container)\n\nconst grid = createGridVirtualizer(scrollEl, {\n colCount: COL_COUNT,\n estimateColSize: COL_W,\n estimateRowSize: ROW_H,\n rowCount: ROW_COUNT,\n onChange: ({ cols, rows, totalHeight, totalWidth }) => {\n container.style.cssText = `position:relative;height:${totalHeight}px;width:${totalWidth}px;`\n container.
|
|
22
|
+
"code": "import { createGridVirtualizer } from '@vielzeug/scroll'\n\n// Two-dimensional grid virtualization — only the visible rows × cols\n// cross-product is mounted, independent of total grid size.\n\nconst ROW_COUNT = 10_000\nconst COL_COUNT = 20\nconst ROW_H = 32\nconst COL_W = 100\n\nconst scrollEl = document.createElement('div')\nscrollEl.style.cssText = 'height:320px;overflow:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(scrollEl)\n\nconst container = document.createElement('div')\nscrollEl.appendChild(container)\n\nconst grid = createGridVirtualizer(scrollEl, {\n colCount: COL_COUNT,\n estimateColSize: COL_W,\n estimateRowSize: ROW_H,\n rowCount: ROW_COUNT,\n onChange: ({ cols, rows, totalHeight, totalWidth }) => {\n container.style.cssText = `position:relative;height:${totalHeight}px;width:${totalWidth}px;`\n container.replaceChildren()\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createElement('div')\n cell.style.cssText = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;box-sizing:border-box;border-right:1px solid #f0f0f0;border-bottom:1px solid #f0f0f0;line-height:${row.size}px;padding:0 8px;overflow:hidden;white-space:nowrap;font-size:12px;`\n cell.textContent = 'R' + row.index + 'C' + col.index\n container.appendChild(cell)\n }\n }\n },\n})\n\nconsole.log('Grid:', ROW_COUNT, 'rows x', COL_COUNT, 'cols')\nconsole.log('Visible cells this frame:', grid.rows.length * grid.cols.length)\n\n// Jump to a specific cell\ngrid.scrollToCell(500, 10, { colAlign: 'start', rowAlign: 'center' })\n\n// Cleanup\nwindow.addEventListener('beforeunload', () => grid.dispose())",
|
|
23
23
|
"name": "Grid Virtualizer"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"id": "grouped-list",
|
|
27
|
-
"code": "import { createGroupedVirtualizer } from '@vielzeug/scroll'\n\n// Grouped contact list with sticky section headers\n\ntype Contact = { id: number; name: string }\n\nconst sections = [\n { label: 'A', items: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Andrew' }] },\n { label: 'B', items: [{ id: 3, name: 'Bob' }, { id: 4, name: 'Brenda' }] },\n { label: 'C', items: [{ id: 5, name: 'Carol' }, { id: 6, name: 'Charlie' }, { id: 7, name: 'Chloe' }] },\n { label: 'D', items: [{ id: 8, name: 'David' }, { id: 9, name: 'Diana' }] },\n]\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;max-width:360px;margin:1rem;'\ndocument.body.appendChild(app)\n\nconst label = document.createElement('div')\nlabel.style.cssText = 'font-size:11px;font-weight:600;color:#6b7280;margin-bottom:6px;'\nlabel.textContent = 'CONTACTS'\napp.appendChild(label)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:320px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:8px;position:relative;background:#fff;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\n// Sticky header overlay — floats above the list\nconst stickyEl = document.createElement('div')\nstickyEl.style.cssText = 'position:sticky;top:0;z-index:1;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;height:32px;line-height:32px;font-size:12px;font-weight:700;color:#374151;display:none;'\ncontainer.appendChild(stickyEl)\n\nconst virt = createGroupedVirtualizer<Contact>(container, {\n estimateHeaderSize: 32,\n estimateItemSize: 48,\n sections,\n onChange: ({ headers, items, stickyHeader, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.
|
|
27
|
+
"code": "import { createGroupedVirtualizer } from '@vielzeug/scroll'\n\n// Grouped contact list with sticky section headers\n\ntype Contact = { id: number; name: string }\n\nconst sections = [\n { label: 'A', items: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Andrew' }] },\n { label: 'B', items: [{ id: 3, name: 'Bob' }, { id: 4, name: 'Brenda' }] },\n { label: 'C', items: [{ id: 5, name: 'Carol' }, { id: 6, name: 'Charlie' }, { id: 7, name: 'Chloe' }] },\n { label: 'D', items: [{ id: 8, name: 'David' }, { id: 9, name: 'Diana' }] },\n]\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;max-width:360px;margin:1rem;'\ndocument.body.appendChild(app)\n\nconst label = document.createElement('div')\nlabel.style.cssText = 'font-size:11px;font-weight:600;color:#6b7280;margin-bottom:6px;'\nlabel.textContent = 'CONTACTS'\napp.appendChild(label)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:320px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:8px;position:relative;background:#fff;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\n// Sticky header overlay — floats above the list\nconst stickyEl = document.createElement('div')\nstickyEl.style.cssText = 'position:sticky;top:0;z-index:1;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;height:32px;line-height:32px;font-size:12px;font-weight:700;color:#374151;display:none;'\ncontainer.appendChild(stickyEl)\n\nconst virt = createGroupedVirtualizer<Contact>(container, {\n estimateHeaderSize: 32,\n estimateItemSize: 48,\n sections,\n onChange: ({ headers, items, stickyHeader, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n headers.forEach(({ start, size, label: text }) => {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;line-height:${size}px;font-size:12px;font-weight:700;color:#374151;`\n el.textContent = text\n content.appendChild(el)\n })\n items.forEach(({ start, size, data }) => {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 14px;border-bottom:1px solid #f3f4f6;font-size:14px;color:#111827;`\n el.textContent = data.name\n content.appendChild(el)\n })\n if (stickyHeader) {\n stickyEl.textContent = stickyHeader.label\n stickyEl.style.display = 'block'\n } else {\n stickyEl.style.display = 'none'\n }\n },\n})\n\nconsole.log('Sections:', sections.length, '| Total items:', sections.reduce((n, s) => n + s.items.length, 0))\nconsole.log('Flat item count:', virt.count)",
|
|
28
28
|
"name": "Grouped List with Sticky Headers"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"id": "measurement-cache",
|
|
32
|
-
"code": "import { createMeasurementCache, createVirtualizer } from '@vielzeug/scroll'\n\n// Shared cache — measurements from listA flow into listB automatically.\nconst cache = createMeasurementCache()\n\nconst makeList = (label, left) => {\n const heading = document.createElement('p')\n heading.textContent = label\n heading.style.cssText = `position:absolute;top:0;left:${left}px;width:220px;margin:0;font-weight:600;font-size:13px;`\n document.body.appendChild(heading)\n\n const container = document.createElement('div')\n container.style.cssText = `position:absolute;top:24px;left:${left}px;width:220px;height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:absolute;`\n document.body.appendChild(container)\n\n const spacer = document.createElement('div')\n const content = document.createElement('div')\n content.style.cssText = 'position:absolute;top:0;left:0;right:0;'\n container.appendChild(spacer)\n container.appendChild(content)\n\n return { container, content, spacer }\n}\n\nconst { container: containerA, content: contentA, spacer: spacerA } = makeList('List A (measures items)', 16)\nconst { container: containerB, content: contentB, spacer: spacerB } = makeList('List B (reads shared cache)', 260)\n\nconst COUNT = 200\n\nconst virtA = createVirtualizer(containerA, {\n count: COUNT,\n estimateSize: 40,\n measurementCache: cache,\n onChange: ({ items, totalSize }) => {\n spacerA.style.height = totalSize + 'px'\n contentA.
|
|
32
|
+
"code": "import { createMeasurementCache, createVirtualizer } from '@vielzeug/scroll'\n\n// Shared cache — measurements from listA flow into listB automatically.\nconst cache = createMeasurementCache()\n\nconst makeList = (label, left) => {\n const heading = document.createElement('p')\n heading.textContent = label\n heading.style.cssText = `position:absolute;top:0;left:${left}px;width:220px;margin:0;font-weight:600;font-size:13px;`\n document.body.appendChild(heading)\n\n const container = document.createElement('div')\n container.style.cssText = `position:absolute;top:24px;left:${left}px;width:220px;height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:absolute;`\n document.body.appendChild(container)\n\n const spacer = document.createElement('div')\n const content = document.createElement('div')\n content.style.cssText = 'position:absolute;top:0;left:0;right:0;'\n container.appendChild(spacer)\n container.appendChild(content)\n\n return { container, content, spacer }\n}\n\nconst { container: containerA, content: contentA, spacer: spacerA } = makeList('List A (measures items)', 16)\nconst { container: containerB, content: contentB, spacer: spacerB } = makeList('List B (reads shared cache)', 260)\n\nconst COUNT = 200\n\nconst virtA = createVirtualizer(containerA, {\n count: COUNT,\n estimateSize: 40,\n measurementCache: cache,\n onChange: ({ items, totalSize }) => {\n spacerA.style.height = totalSize + 'px'\n contentA.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;min-height:${size}px;padding:8px 12px;border-bottom:1px solid #f0f0f0;word-wrap:break-word;font-size:13px;`\n row.textContent = `Row ${index} — ${'word '.repeat((index % 4) + 1).trim()}`\n contentA.appendChild(row)\n // Report actual height after paint\n requestAnimationFrame(() => virtA.measure(index, row.offsetHeight))\n })\n },\n})\n\nconst virtB = createVirtualizer(containerB, {\n count: COUNT,\n estimateSize: 40,\n measurementCache: cache,\n onChange: ({ items, totalSize }) => {\n spacerB.style.height = totalSize + 'px'\n contentB.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 12px;border-bottom:1px solid #f0f0f0;font-size:13px;`\n row.textContent = `Row ${index} (size: ${size}px)`\n contentB.appendChild(row)\n })\n },\n})\n\nconsole.log('✓ Two virtualizers share one MeasurementCache')\nconsole.log('Scroll List A to measure rows — List B reflects the same sizes')",
|
|
33
33
|
"name": "createMeasurementCache - Shared Cache"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"id": "on-range-change",
|
|
37
|
-
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\n// Infinite scroll: detect when the user is near the bottom\n// inside onChange and load more data.\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;padding:16px;max-width:480px;'\ndocument.body.appendChild(app)\n\nconst badge = document.createElement('div')\nbadge.style.cssText = 'background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;padding:8px 12px;margin-bottom:12px;font-size:13px;color:#0369a1;'\nbadge.textContent = 'Scroll to the bottom — more items load automatically'\napp.appendChild(badge)\n\nconst rangeEl = document.createElement('div')\nrangeEl.style.cssText = 'font-size:12px;font-weight:600;color:#6b7280;margin-bottom:8px;'\nrangeEl.textContent = 'Visible: —'\napp.appendChild(rangeEl)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:6px;position:relative;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nlet count = 50\nlet loading = false\n\nconst virt = createVirtualizer(container, {\n count,\n estimateSize: 44,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.
|
|
37
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\n// Infinite scroll: detect when the user is near the bottom\n// inside onChange and load more data.\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;padding:16px;max-width:480px;'\ndocument.body.appendChild(app)\n\nconst badge = document.createElement('div')\nbadge.style.cssText = 'background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;padding:8px 12px;margin-bottom:12px;font-size:13px;color:#0369a1;'\nbadge.textContent = 'Scroll to the bottom — more items load automatically'\napp.appendChild(badge)\n\nconst rangeEl = document.createElement('div')\nrangeEl.style.cssText = 'font-size:12px;font-weight:600;color:#6b7280;margin-bottom:8px;'\nrangeEl.textContent = 'Visible: —'\napp.appendChild(rangeEl)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:6px;position:relative;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nlet count = 50\nlet loading = false\n\nconst virt = createVirtualizer(container, {\n count,\n estimateSize: 44,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 14px;border-bottom:1px solid #f3f4f6;font-size:13px;`\n row.textContent = `Row ${index + 1} of ${count}`\n content.appendChild(row)\n })\n const first = items[0]?.index ?? -1\n const last = items.at(-1)?.index ?? -1\n if (first >= 0) rangeEl.textContent = `Visible: ${first} – ${last}`\n if (!loading && last >= count - 10) {\n loading = true\n setTimeout(() => { count += 50; virt.update({ count }); loading = false }, 300)\n }\n },\n})",
|
|
38
38
|
"name": "Infinite Scroll"
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"id": "reactive-grouped-list",
|
|
42
|
-
"code": "import { createReactiveGroupedVirtualizer } from '@vielzeug/scroll'\n\n// Reactive grouped virtualizer — state exposed as a Signal\n// Useful for frameworks/systems built on @vielzeug/ripple\n\ntype Contact = { id: number; name: string }\n\nconst sections = [\n { label: 'A', items: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Andrew' }] },\n { label: 'B', items: [{ id: 3, name: 'Bob' }, { id: 4, name: 'Brenda' }] },\n { label: 'C', items: [{ id: 5, name: 'Carol' }, { id: 6, name: 'Charlie' }] },\n]\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;max-width:360px;margin:1rem;'\ndocument.body.appendChild(app)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:280px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:8px;position:relative;background:#fff;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst stickyEl = document.createElement('div')\nstickyEl.style.cssText = 'position:sticky;top:0;z-index:1;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;height:32px;line-height:32px;font-size:12px;font-weight:700;color:#374151;display:none;'\ncontainer.appendChild(stickyEl)\n\nconst virt = createReactiveGroupedVirtualizer<Contact>(container, {\n estimateHeaderSize: 32,\n estimateItemSize: 44,\n sections,\n})\n\n// virt.state is a Signal<GroupVirtualizerState<Contact>>\n// In a reactive system, subscribe to it via effect().\n// Here we simulate with a manual render triggered by state reads.\nfunction render() {\n const { headers, items, stickyHeader, totalSize } = virt.state.value\n spacer.style.height = totalSize + 'px'\n content.
|
|
42
|
+
"code": "import { createReactiveGroupedVirtualizer } from '@vielzeug/scroll'\n\n// Reactive grouped virtualizer — state exposed as a Signal\n// Useful for frameworks/systems built on @vielzeug/ripple\n\ntype Contact = { id: number; name: string }\n\nconst sections = [\n { label: 'A', items: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Andrew' }] },\n { label: 'B', items: [{ id: 3, name: 'Bob' }, { id: 4, name: 'Brenda' }] },\n { label: 'C', items: [{ id: 5, name: 'Carol' }, { id: 6, name: 'Charlie' }] },\n]\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;max-width:360px;margin:1rem;'\ndocument.body.appendChild(app)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:280px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:8px;position:relative;background:#fff;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst stickyEl = document.createElement('div')\nstickyEl.style.cssText = 'position:sticky;top:0;z-index:1;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;height:32px;line-height:32px;font-size:12px;font-weight:700;color:#374151;display:none;'\ncontainer.appendChild(stickyEl)\n\nconst virt = createReactiveGroupedVirtualizer<Contact>(container, {\n estimateHeaderSize: 32,\n estimateItemSize: 44,\n sections,\n})\n\n// virt.state is a Signal<GroupVirtualizerState<Contact>>\n// In a reactive system, subscribe to it via effect().\n// Here we simulate with a manual render triggered by state reads.\nfunction render() {\n const { headers, items, stickyHeader, totalSize } = virt.state.value\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n headers.forEach(({ start, size, label: text }) => {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;line-height:${size}px;font-size:12px;font-weight:700;color:#374151;`\n el.textContent = text\n content.appendChild(el)\n })\n items.forEach(({ start, size, data }) => {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;padding:0 14px 0 22px;line-height:${size}px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;`\n el.textContent = data.name\n content.appendChild(el)\n })\n if (stickyHeader) {\n stickyEl.style.display = 'block'\n stickyEl.textContent = stickyHeader.label\n } else {\n stickyEl.style.display = 'none'\n }\n}\n\n// Initial render from signal value\nrender()\ncontainer.addEventListener('scroll', render)\n\n// --- Live update demo ---\nconst btn = document.createElement('button')\nbtn.textContent = 'Add section D'\nbtn.style.cssText = 'margin-top:10px;padding:6px 14px;font-size:13px;border:1px solid #d1d5db;border-radius:6px;cursor:pointer;background:#fff;'\nbtn.onclick = () => {\n virt.update([\n ...sections,\n { label: 'D', items: [{ id: 8, name: 'David' }, { id: 9, name: 'Diana' }] },\n ])\n render()\n btn.disabled = true\n btn.style.opacity = '0.5'\n}\napp.appendChild(btn)\n\n// Cleanup\nwindow.addEventListener('beforeunload', () => virt.dispose())",
|
|
43
43
|
"name": "Reactive Grouped Virtualizer"
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"id": "reactive-virtualizer",
|
|
47
|
-
"code": "import { createReactiveVirtualizer } from '@vielzeug/scroll'\n\n// createReactiveVirtualizer exposes state as a Signal<VirtualizerState> from\n// @vielzeug/ripple instead of an onChange callback. In a real app you would\n// read virt.state inside effect() — here we simulate that with a manual\n// render() call on every scroll event so the demo has no extra imports.\n\nconst rows = Array.from({ length: 50_000 }, (_, i) => ({ id: i, label: 'Row ' + i }))\n\nconst scrollEl = document.createElement('div')\nscrollEl.style.cssText = 'height:280px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(scrollEl)\n\nconst listEl = document.createElement('div')\nlistEl.style.cssText = 'position:absolute;top:0;left:0;right:0;'\nscrollEl.appendChild(listEl)\n\nconst virt = createReactiveVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 32,\n})\n\nfunction render() {\n const { items, totalSize } = virt.state.value\n listEl.style.height = totalSize + 'px'\n listEl.
|
|
47
|
+
"code": "import { createReactiveVirtualizer } from '@vielzeug/scroll'\n\n// createReactiveVirtualizer exposes state as a Signal<VirtualizerState> from\n// @vielzeug/ripple instead of an onChange callback. In a real app you would\n// read virt.state inside effect() — here we simulate that with a manual\n// render() call on every scroll event so the demo has no extra imports.\n\nconst rows = Array.from({ length: 50_000 }, (_, i) => ({ id: i, label: 'Row ' + i }))\n\nconst scrollEl = document.createElement('div')\nscrollEl.style.cssText = 'height:280px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(scrollEl)\n\nconst listEl = document.createElement('div')\nlistEl.style.cssText = 'position:absolute;top:0;left:0;right:0;'\nscrollEl.appendChild(listEl)\n\nconst virt = createReactiveVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 32,\n})\n\nfunction render() {\n const { items, totalSize } = virt.state.value\n listEl.style.height = totalSize + 'px'\n listEl.replaceChildren()\n for (const item of items) {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:32px;line-height:32px;padding:0 12px;border-bottom:1px solid #f0f0f0;`\n el.textContent = rows[item.index].label\n listEl.appendChild(el)\n }\n}\n\nrender()\nscrollEl.addEventListener('scroll', render)\n\nconsole.log('Reactive virtualizer wired to', rows.length, 'rows')\nconsole.log('Live getter (not a snapshot):', virt.state.value.items.length, 'items visible')\n\n// Standard virtualizer methods remain available directly on the returned object\nvirt.scrollToIndex(rows.length - 1, { align: 'end', behavior: 'smooth' })\n\n// Cleanup\nwindow.addEventListener('beforeunload', () => virt.dispose())",
|
|
48
48
|
"name": "Reactive Virtualizer"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"id": "scroll-to-index",
|
|
52
|
-
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 1_000\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:300px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: 48,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.
|
|
52
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 1_000\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:300px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: 48,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;line-height:${size}px;padding:0 16px;border-bottom:1px solid #f5f5f5;`\n row.textContent = `Item ${index}`\n content.appendChild(row)\n })\n },\n})\n\n// Scroll to specific indexes\nsetTimeout(() => {\n console.log('Scrolling to index 500 (start align)')\n virtualizer.scrollToIndex(500, { align: 'start', behavior: 'smooth' })\n}, 200)\n\nsetTimeout(() => {\n console.log('Scrolling to index 999 (end align)')\n virtualizer.scrollToIndex(999, { align: 'end', behavior: 'smooth' })\n}, 800)\n\nsetTimeout(() => {\n console.log('Scrolling to index 250 (center align)')\n virtualizer.scrollToIndex(250, { align: 'center', behavior: 'smooth' })\n}, 1400)",
|
|
53
53
|
"name": "Virtualizer - scrollToIndex"
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
"id": "scroll-to-top-bottom",
|
|
57
|
-
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 500\n\nconst wrapper = document.createElement('div')\nwrapper.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:1rem;'\ndocument.body.appendChild(wrapper)\n\nconst btnRow = document.createElement('div')\nbtnRow.style.cssText = 'display:flex;gap:8px;'\n\nconst btnTop = document.createElement('button')\nbtnTop.textContent = '⬆ scrollToTop'\nbtnTop.style.cssText = 'padding:6px 12px;cursor:pointer;border:1px solid #ccc;border-radius:4px;'\n\nconst btnBottom = document.createElement('button')\nbtnBottom.textContent = '⬇ scrollToBottom'\nbtnBottom.style.cssText = 'padding:6px 12px;cursor:pointer;border:1px solid #ccc;border-radius:4px;'\n\nbtnRow.appendChild(btnTop)\nbtnRow.appendChild(btnBottom)\nwrapper.appendChild(btnRow)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\nwrapper.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virt = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: 40,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.
|
|
57
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 500\n\nconst wrapper = document.createElement('div')\nwrapper.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:1rem;'\ndocument.body.appendChild(wrapper)\n\nconst btnRow = document.createElement('div')\nbtnRow.style.cssText = 'display:flex;gap:8px;'\n\nconst btnTop = document.createElement('button')\nbtnTop.textContent = '⬆ scrollToTop'\nbtnTop.style.cssText = 'padding:6px 12px;cursor:pointer;border:1px solid #ccc;border-radius:4px;'\n\nconst btnBottom = document.createElement('button')\nbtnBottom.textContent = '⬇ scrollToBottom'\nbtnBottom.style.cssText = 'padding:6px 12px;cursor:pointer;border:1px solid #ccc;border-radius:4px;'\n\nbtnRow.appendChild(btnTop)\nbtnRow.appendChild(btnBottom)\nwrapper.appendChild(btnRow)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\nwrapper.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virt = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: 40,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 16px;border-bottom:1px solid #f0f0f0;background:${index % 2 ? '#fafafa' : '#fff'};`\n row.textContent = `Row ${index + 1} / ${ITEM_COUNT}`\n content.appendChild(row)\n })\n },\n})\n\nbtnTop.addEventListener('click', () => {\n console.log('scrollToTop()')\n virt.scrollToTop({ behavior: 'smooth' })\n})\n\nbtnBottom.addEventListener('click', () => {\n console.log('scrollToBottom()')\n virt.scrollToBottom({ behavior: 'smooth' })\n})\n\nconsole.log(`✓ ${ITEM_COUNT} rows — use the buttons to jump to top or bottom`)",
|
|
58
58
|
"name": "Virtualizer - scrollToTop / scrollToBottom"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"id": "variable-height",
|
|
62
|
-
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst items = Array.from({ length: 500 }, (_, i) => ({\n id: i,\n text: 'Item ' + i + ': ' + 'lorem ipsum '.repeat(Math.floor(Math.random() * 3) + 1).trim(),\n}))\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:400px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: items.length,\n estimateSize: 60,\n getItemKey: (index) => items[index]?.id ?? index,\n onChange: ({ items: virtualItems, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.
|
|
62
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst items = Array.from({ length: 500 }, (_, i) => ({\n id: i,\n text: 'Item ' + i + ': ' + 'lorem ipsum '.repeat(Math.floor(Math.random() * 3) + 1).trim(),\n}))\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:400px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: items.length,\n estimateSize: 60,\n getItemKey: (index) => items[index]?.id ?? index,\n onChange: ({ items: virtualItems, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n virtualItems.forEach(({ index, start }) => {\n const row = document.createElement('div')\n row.dataset.index = String(index)\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;padding:12px 16px;border-bottom:1px solid #f0f0f0;word-wrap:break-word;`\n row.textContent = items[index].text\n content.appendChild(row)\n })\n // Batch-report all measured heights in a single rebuild\n requestAnimationFrame(() => {\n const measurements = []\n virtualItems.forEach(({ index }) => {\n const row = content.querySelector(`[data-index=\"${index}\"]`)\n if (row) measurements.push({ index, size: row.offsetHeight })\n })\n if (measurements.length) virtualizer.measureBatch(measurements)\n })\n },\n})\n\nconsole.log('Variable height list with', items.length, 'items')\nconsole.log('Initial rendered:', virtualizer.items.length, 'rows (estimates)')",
|
|
63
63
|
"name": "Virtualizer - Variable Height"
|
|
64
64
|
}
|
|
65
65
|
],
|
|
66
66
|
"typeSignatures": {
|
|
67
|
-
"
|
|
68
|
-
"
|
|
67
|
+
"ScrollConfigurationError": "export { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';",
|
|
68
|
+
"ScrollError": "export { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';",
|
|
69
|
+
"ScrollRangeError": "export { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';",
|
|
69
70
|
"createDomVirtualList": "export { createDomVirtualList, createVirtualScroller } from './dom-virtual-list';",
|
|
70
71
|
"createVirtualScroller": "export { createDomVirtualList, createVirtualScroller } from './dom-virtual-list';",
|
|
71
72
|
"DomVirtualListController": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';",
|