@vielzeug/codex 2.2.9 → 2.3.0
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 +73 -30
- package/data/llms-full.txt +1472 -370
- package/data/llms.txt +2 -1
- package/data/manifest.json +1 -1
- package/data/packages/clockwork.json +2 -2
- package/data/packages/conduit.json +1 -1
- package/data/packages/courier.json +7 -6
- package/data/packages/dnd.json +1 -1
- package/data/packages/familiar.json +1 -1
- package/data/packages/forge.json +1 -1
- package/data/packages/gesture.json +1 -1
- package/data/packages/herald.json +18 -18
- package/data/packages/keymap.json +2 -2
- package/data/packages/lingua.json +1 -1
- package/data/packages/necromancer.json +1 -1
- package/data/packages/ore.json +1 -1
- package/data/packages/postmaster.json +45 -0
- package/data/packages/pulse.json +31 -30
- package/data/packages/scout.json +13 -12
- package/data/packages/scroll.json +1 -1
- package/data/packages/sentinel.json +1 -1
- package/data/packages/spell.json +1 -1
- package/data/packages/vault.json +22 -28
- package/data/packages/ward.json +28 -28
- package/data/packages/wayfinder.json +5 -5
- package/data/refine.json +3926 -3926
- package/data/search.json +76 -54
- package/package.json +2 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"apiSource": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';\nexport { createDomVirtualList, createVirtualScroller } from './dom-virtual-list';\nexport { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';\nexport type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';\nexport { createGridVirtualizer } from './grid-virtualizer';\nexport type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';\nexport { createGroupedVirtualizer } from './grouped-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';\nexport { createMeasurementCache, createVirtualizer, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN } from './virtualizer';\n",
|
|
3
3
|
"docs": {
|
|
4
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 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- **Reactive state** — provide a `signal` factory to expose current state as a Ripple `Signal`\n- **Keyboard navigation** — enable `keyboardScroll` for Arrow/Page/Home/End key support\n- **Auto-measurement** — enable `autoMeasure` to automatically measure visible items via `ResizeObserver`\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 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\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 createMeasurementCache,\n DEFAULT_ESTIMATE_SIZE,\n DEFAULT_OVERSCAN,\n ScrollError,\n ScrollConfigurationError,\n ScrollRangeError,\n type Virtualizer,\n type VirtualItem,\n type VirtualizerState,\n type VirtualizerOptions,\n type VirtualizerUpdateOptions,\n type ScrollToIndexOptions,\n type Overscan,\n type VirtualKey,\n type MeasurementCache,\n type ScrollTarget,\n type DomVirtualListOptions,\n type DomVirtualListController,\n type DomVirtualListRenderArgs,\n type RecycleFn,\n type VirtualRenderItem,\n type StickToBottomOptions,\n type VirtualScrollerOptions,\n type GroupSection,\n type GroupVirtualizer,\n type GroupVirtualizerOptions,\n type GroupVirtualizerState,\n type GroupVirtualizerUpdateOptions,\n type GroupVirtualHeader,\n type GroupVirtualItem,\n type GridVirtualizer,\n type GridVirtualizerOptions,\n type GridVirtualizerState,\n type GridVirtualizerUpdateOptions,\n type GridRangeChangeEvent,\n type ScrollToCellOptions,\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| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\n| `autoMeasure` | `boolean` | `false` | Automatically measure visible items via ResizeObserver |\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| `signal` | `(init: VirtualizerState) => Signal<VirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\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| `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called |\n| `disposed` | `boolean` | `true` after `dispose()` is called |\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| `[Symbol.dispose]` | `() => void` | Delegates to `dispose()` — enables `using` declarations |\n\n### `update(next)`\n\nAtomically updates one or more live options. Accepts: `autoMeasure`, `count`, `estimateSize`, `gap`, `getItemKey`, `keyboardScroll`, `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| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\n| `measurementCache` | `MeasurementCache` | — | External measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |\n| `signal` | `(init: VirtualizerState) => Signal<VirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\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| `disposalSignal` | `AbortSignal` aborted on `dispose()` |\n| `isScrolling` | `true` while the user is scrolling; `false` once settled (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| `signal` | `(init: GroupVirtualizerState<T>) => Signal<GroupVirtualizerState<T>>` | — | Optional signal factory to expose state as a reactive Signal |\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| `count` | Total flat item count (live getter) |\n| `disposalSignal` | `AbortSignal` aborted on `dispose()` |\n| `isScrolling` | `true` while the user is scrolling; `false` once scroll settles |\n| `items` | Currently rendered group items (live getter) |\n| `scrollOffset` | Current scroll position in pixels (live getter) |\n| `stickyItems` | Sticky items pinned at viewport top (live getter) |\n| `totalSize` | Total list size 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| `onChange` | `(state: GroupVirtualizerState<T>) => void` | Replace the active onChange callback |\n| `onScrollEnd` | `(offset: number) => void` | Replace the active onScrollEnd callback |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | Replace the active onScrollingChange callback |\n| `overscan` | `number \\| { start?, end? }` | New overscan count |\n| `scrollEndDelay` | `number` | New debounce delay (ms) for scroll-end detection |\n\n> `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| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\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| `signal` | `(init: GridVirtualizerState) => Signal<GridVirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\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`, `disposalSignal`, `disposed`\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## 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 autoMeasure?: boolean;\n count?: number;\n estimateSize?: number | ((index: number) => number);\n gap?: number;\n getItemKey?: ((index: number) => VirtualKey) | undefined;\n keyboardScroll?: boolean;\n /** Replace the active measurement cache. Existing entries are used immediately on the next rebuild. */\n measurementCache?: MeasurementCache;\n onChange?: ((state: VirtualizerState) => void) | undefined;\n onScrollEnd?: ((offset: number) => void) | undefined;\n onScrollingChange?: ((isScrolling: boolean) => void) | undefined;\n overscan?: Overscan;\n scrollEndDelay?: number;\n sticky?: ((index: number) => boolean) | undefined;\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 keyboardScroll?: boolean;\n onChange?: ((state: GridVirtualizerState) => void) | undefined;\n onRangeChange?: ((range: GridRangeChangeEvent) => void) | undefined;\n overscanX?: Overscan;\n overscanY?: Overscan;\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### `VirtualizerOptions`\n\n```ts\ninterface VirtualizerOptions {\n autoMeasure?: boolean;\n count: number;\n estimateSize?: number | ((index: number) => number);\n gap?: number;\n getItemKey?: (index: number) => VirtualKey;\n horizontal?: boolean;\n initialOffset?: number;\n keyboardScroll?: boolean;\n measurementCache?: MeasurementCache;\n onChange?: (state: VirtualizerState) => void;\n onScrollEnd?: (offset: number) => void;\n onScrollingChange?: (isScrolling: boolean) => void;\n overscan?: Overscan;\n scrollEndDelay?: number;\n signal?: (init: VirtualizerState) => Signal<VirtualizerState>;\n sticky?: (index: number) => boolean;\n}\n```\n\n### `Virtualizer`\n\n```ts\ninterface Virtualizer {\n readonly count: number;\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n isAtEnd: (threshold?: number) => boolean;\n readonly isScrolling: boolean;\n readonly items: VirtualItem[];\n measure: (index: number, size: number) => void;\n measureBatch: (entries: Array<{ index: number; size: number }>) => void;\n measureEl: (index: number, el: HTMLElement) => () => void;\n prepend: (additionalCount: number) => void;\n refresh: () => void;\n readonly scrollOffset: number;\n scrollToBottom: (options?: { behavior?: ScrollBehavior }) => void;\n scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;\n scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void;\n scrollToTop: (options?: { behavior?: ScrollBehavior }) => void;\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n update: (next: VirtualizerUpdateOptions) => void;\n [Symbol.dispose]: () => void;\n}\n```\n\n### `StickToBottomOptions`\n\n```ts\ntype StickToBottomOptions = {\n enabled?: boolean;\n threshold?: number;\n};\n```\n\n### `DomVirtualListOptions<T>`\n\n```ts\ntype DomVirtualListOptions<T> = {\n clear?: (listEl: HTMLElement) => void;\n estimateSize?: number | ((index: number, item: T) => number);\n gap?: number;\n getItemKey?: (index: number, item: T) => VirtualKey;\n horizontal?: boolean;\n keyboardScroll?: boolean;\n listElement: HTMLElement;\n measurementCache?: MeasurementCache;\n overscan?: Overscan;\n render: (args: DomVirtualListRenderArgs<T>) => void;\n scrollElement: HTMLElement | Window;\n stickToBottom?: boolean | StickToBottomOptions;\n sticky?: (index: number, item: T) => boolean;\n signal?: (init: VirtualizerState) => Signal<VirtualizerState>;\n};\n```\n\n### `DomVirtualListController<T>`\n\n`Virtualizer` minus `prepend` and `update`, plus `setItems()`.\n\n```ts\ntype DomVirtualListController<T> = Omit<Virtualizer, 'prepend' | 'update'> & {\n setItems: (items: T[]) => void;\n};\n```\n\n### `DomVirtualListRenderArgs<T>`\n\n```ts\ntype DomVirtualListRenderArgs<T> = {\n items: Array<VirtualRenderItem<T>>;\n listEl: HTMLElement;\n recycle: RecycleFn;\n stickyItems: Array<VirtualRenderItem<T>>;\n totalSize: number;\n};\n```\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### `GroupVirtualItem<T>`\n\n```ts\ninterface GroupVirtualItem<T> extends VirtualItem {\n data: T;\n itemIndex: number;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualHeader`\n\n```ts\ninterface GroupVirtualHeader extends VirtualItem {\n label: string;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualizerOptions<T>`\n\n```ts\ninterface GroupVirtualizerOptions<T> {\n estimateHeaderSize?: number | ((section: GroupSection<T>, sectionIndex: number) => number);\n estimateItemSize?: number | ((item: T, itemIndex: number, sectionIndex: number) => number);\n getItemKey?: (item: T, itemIndex: number, sectionIndex: number) => VirtualKey;\n horizontal?: boolean;\n measurementCache?: MeasurementCache;\n onChange?: (state: GroupVirtualizerState<T>) => void;\n onScrollEnd?: (offset: number) => void;\n onScrollingChange?: (isScrolling: boolean) => void;\n overscan?: Overscan;\n scrollEndDelay?: number;\n sections: Array<GroupSection<T>>;\n signal?: (init: GroupVirtualizerState<T>) => Signal<GroupVirtualizerState<T>>;\n}\n```\n\n### `GroupVirtualizerUpdateOptions<T>`\n\n```ts\ninterface GroupVirtualizerUpdateOptions<T> {\n estimateHeaderSize?: number | ((section: GroupSection<T>, sectionIndex: number) => number);\n estimateItemSize?: number | ((item: T, itemIndex: number, sectionIndex: number) => number);\n getItemKey?: (item: T, itemIndex: number, sectionIndex: number) => VirtualKey;\n measurementCache?: MeasurementCache;\n onChange?: ((state: GroupVirtualizerState<T>) => void) | undefined;\n onScrollEnd?: ((offset: number) => void) | undefined;\n onScrollingChange?: ((isScrolling: boolean) => void) | undefined;\n overscan?: Overscan;\n scrollEndDelay?: number;\n}\n```\n\n### `GroupVirtualizer<T>`\n\n```ts\ninterface GroupVirtualizer<T> {\n readonly count: number;\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n readonly isScrolling: boolean;\n readonly items: ReadonlyArray<GroupVirtualItem<T>>;\n measure: (index: number, size: number) => void;\n measureBatch: (entries: Array<{ index: number; size: number }>) => void;\n measureEl: (index: number, el: HTMLElement) => () => void;\n refresh: () => void;\n readonly scrollOffset: number;\n scrollToBottom: (options?: { behavior?: ScrollBehavior }) => void;\n scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;\n scrollToItem: (sectionIndex: number, itemIndex: number, options?: ScrollToIndexOptions) => void;\n scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void;\n scrollToSection: (sectionIndex: number, options?: ScrollToIndexOptions) => void;\n scrollToTop: (options?: { behavior?: ScrollBehavior }) => void;\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n update: (sections: Array<GroupSection<T>>, opts?: GroupVirtualizerUpdateOptions<T>) => void;\n [Symbol.dispose]: () => void;\n}\n```\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### `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### `GridVirtualizerOptions`\n\n```ts\ninterface GridVirtualizerOptions {\n colCount: number;\n colGap?: number;\n colMeasurementCache?: Map<number, number>;\n estimateColSize?: number | ((col: number) => number);\n estimateRowSize?: number | ((row: number) => number);\n initialScrollLeft?: number;\n initialScrollTop?: number;\n keyboardScroll?: boolean;\n onChange?: (state: GridVirtualizerState) => void;\n onRangeChange?: (range: GridRangeChangeEvent) => void;\n overscanX?: Overscan;\n overscanY?: Overscan;\n rowCount: number;\n rowGap?: number;\n rowMeasurementCache?: Map<number, number>;\n signal?: (init: GridVirtualizerState) => Signal<GridVirtualizerState>;\n}\n```\n\n### `GridVirtualizer`\n\n```ts\ninterface GridVirtualizer {\n readonly cols: VirtualItem[];\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n measureBatch: (rows: Array<{ index: number; size: number }>, cols: Array<{ index: number; size: number }>) => void;\n measureColEl: (col: number, el: HTMLElement) => () => void;\n measureColumn: (col: number, size: number) => void;\n measureRow: (row: number, size: number) => void;\n measureRowEl: (row: number, el: HTMLElement) => () => void;\n prependRows: (additionalRowCount: number) => void;\n refresh: () => void;\n readonly rows: VirtualItem[];\n readonly scrollLeft: number;\n scrollToCell: (row: number, col: number, options?: ScrollToCellOptions) => void;\n scrollToColumn: (col: number, options?: Pick<ScrollToCellOptions, 'behavior' | 'colAlign'>) => void;\n readonly scrollTop: number;\n scrollToRow: (row: number, options?: Pick<ScrollToCellOptions, 'behavior' | 'rowAlign'>) => void;\n readonly totalHeight: number;\n readonly totalWidth: number;\n update: (next: GridVirtualizerUpdateOptions) => void;\n [Symbol.dispose]: () => void;\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",
|
|
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 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\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 createMeasurementCache,\n DEFAULT_ESTIMATE_SIZE,\n DEFAULT_OVERSCAN,\n ScrollError,\n ScrollConfigurationError,\n ScrollRangeError,\n type Virtualizer,\n type VirtualItem,\n type VirtualizerState,\n type VirtualizerOptions,\n type VirtualizerUpdateOptions,\n type ScrollToIndexOptions,\n type Overscan,\n type VirtualKey,\n type MeasurementCache,\n type ScrollTarget,\n type DomVirtualListOptions,\n type DomVirtualListController,\n type DomVirtualListRenderArgs,\n type RecycleFn,\n type VirtualRenderItem,\n type StickToBottomOptions,\n type VirtualScrollerOptions,\n type GroupSection,\n type GroupVirtualizer,\n type GroupVirtualizerOptions,\n type GroupVirtualizerState,\n type GroupVirtualizerUpdateOptions,\n type GroupVirtualHeader,\n type GroupVirtualItem,\n type GridVirtualizer,\n type GridVirtualizerOptions,\n type GridVirtualizerState,\n type GridVirtualizerUpdateOptions,\n type GridRangeChangeEvent,\n type ScrollToCellOptions,\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| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\n| `autoMeasure` | `boolean` | `false` | Automatically measure visible items via ResizeObserver |\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| `signal` | `(init: VirtualizerState) => Signal<VirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\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| `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called |\n| `disposed` | `boolean` | `true` after `dispose()` is called |\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| `[Symbol.dispose]` | `() => void` | Delegates to `dispose()` — enables `using` declarations |\n\n### `update(next)`\n\nAtomically updates one or more live options. Accepts: `autoMeasure`, `count`, `estimateSize`, `gap`, `getItemKey`, `keyboardScroll`, `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| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\n| `measurementCache` | `MeasurementCache` | — | External measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |\n| `signal` | `(init: VirtualizerState) => Signal<VirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\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| `disposalSignal` | `AbortSignal` aborted on `dispose()` |\n| `isScrolling` | `true` while the user is scrolling; `false` once settled (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| `signal` | `(init: GroupVirtualizerState<T>) => Signal<GroupVirtualizerState<T>>` | — | Optional signal factory to expose state as a reactive Signal |\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| `count` | Total flat item count (live getter) |\n| `disposalSignal` | `AbortSignal` aborted on `dispose()` |\n| `isScrolling` | `true` while the user is scrolling; `false` once scroll settles |\n| `items` | Currently rendered group items (live getter) |\n| `scrollOffset` | Current scroll position in pixels (live getter) |\n| `stickyItems` | Sticky items pinned at viewport top (live getter) |\n| `totalSize` | Total list size 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| `onChange` | `(state: GroupVirtualizerState<T>) => void` | Replace the active onChange callback |\n| `onScrollEnd` | `(offset: number) => void` | Replace the active onScrollEnd callback |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | Replace the active onScrollingChange callback |\n| `overscan` | `number \\| { start?, end? }` | New overscan count |\n| `scrollEndDelay` | `number` | New debounce delay (ms) for scroll-end detection |\n\n> `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| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\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| `signal` | `(init: GridVirtualizerState) => Signal<GridVirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\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`, `disposalSignal`, `disposed`\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## 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 autoMeasure?: boolean;\n count?: number;\n estimateSize?: number | ((index: number) => number);\n gap?: number;\n getItemKey?: ((index: number) => VirtualKey) | undefined;\n keyboardScroll?: boolean;\n /** Replace the active measurement cache. Existing entries are used immediately on the next rebuild. */\n measurementCache?: MeasurementCache;\n onChange?: ((state: VirtualizerState) => void) | undefined;\n onScrollEnd?: ((offset: number) => void) | undefined;\n onScrollingChange?: ((isScrolling: boolean) => void) | undefined;\n overscan?: Overscan;\n scrollEndDelay?: number;\n sticky?: ((index: number) => boolean) | undefined;\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 keyboardScroll?: boolean;\n onChange?: ((state: GridVirtualizerState) => void) | undefined;\n onRangeChange?: ((range: GridRangeChangeEvent) => void) | undefined;\n overscanX?: Overscan;\n overscanY?: Overscan;\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### `VirtualizerOptions`\n\n```ts\ninterface VirtualizerOptions {\n autoMeasure?: boolean;\n count: number;\n estimateSize?: number | ((index: number) => number);\n gap?: number;\n getItemKey?: (index: number) => VirtualKey;\n horizontal?: boolean;\n initialOffset?: number;\n keyboardScroll?: boolean;\n measurementCache?: MeasurementCache;\n onChange?: (state: VirtualizerState) => void;\n onScrollEnd?: (offset: number) => void;\n onScrollingChange?: (isScrolling: boolean) => void;\n overscan?: Overscan;\n scrollEndDelay?: number;\n signal?: (init: VirtualizerState) => Signal<VirtualizerState>;\n sticky?: (index: number) => boolean;\n}\n```\n\n### `Virtualizer`\n\n```ts\ninterface Virtualizer {\n readonly count: number;\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n isAtEnd: (threshold?: number) => boolean;\n readonly isScrolling: boolean;\n readonly items: VirtualItem[];\n measure: (index: number, size: number) => void;\n measureBatch: (entries: Array<{ index: number; size: number }>) => void;\n measureEl: (index: number, el: HTMLElement) => () => void;\n prepend: (additionalCount: number) => void;\n refresh: () => void;\n readonly scrollOffset: number;\n scrollToBottom: (options?: { behavior?: ScrollBehavior }) => void;\n scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;\n scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void;\n scrollToTop: (options?: { behavior?: ScrollBehavior }) => void;\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n update: (next: VirtualizerUpdateOptions) => void;\n [Symbol.dispose]: () => void;\n}\n```\n\n### `StickToBottomOptions`\n\n```ts\ntype StickToBottomOptions = {\n enabled?: boolean;\n threshold?: number;\n};\n```\n\n### `DomVirtualListOptions<T>`\n\n```ts\ntype DomVirtualListOptions<T> = {\n clear?: (listEl: HTMLElement) => void;\n estimateSize?: number | ((index: number, item: T) => number);\n gap?: number;\n getItemKey?: (index: number, item: T) => VirtualKey;\n horizontal?: boolean;\n keyboardScroll?: boolean;\n listElement: HTMLElement;\n measurementCache?: MeasurementCache;\n overscan?: Overscan;\n render: (args: DomVirtualListRenderArgs<T>) => void;\n scrollElement: HTMLElement | Window;\n stickToBottom?: boolean | StickToBottomOptions;\n sticky?: (index: number, item: T) => boolean;\n signal?: (init: VirtualizerState) => Signal<VirtualizerState>;\n};\n```\n\n### `DomVirtualListController<T>`\n\n`Virtualizer` minus `prepend` and `update`, plus `setItems()`.\n\n```ts\ntype DomVirtualListController<T> = Omit<Virtualizer, 'prepend' | 'update'> & {\n setItems: (items: T[]) => void;\n};\n```\n\n### `DomVirtualListRenderArgs<T>`\n\n```ts\ntype DomVirtualListRenderArgs<T> = {\n items: Array<VirtualRenderItem<T>>;\n listEl: HTMLElement;\n recycle: RecycleFn;\n stickyItems: Array<VirtualRenderItem<T>>;\n totalSize: number;\n};\n```\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### `GroupVirtualItem<T>`\n\n```ts\ninterface GroupVirtualItem<T> extends VirtualItem {\n data: T;\n itemIndex: number;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualHeader`\n\n```ts\ninterface GroupVirtualHeader extends VirtualItem {\n label: string;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualizerOptions<T>`\n\n```ts\ninterface GroupVirtualizerOptions<T> {\n estimateHeaderSize?: number | ((section: GroupSection<T>, sectionIndex: number) => number);\n estimateItemSize?: number | ((item: T, itemIndex: number, sectionIndex: number) => number);\n getItemKey?: (item: T, itemIndex: number, sectionIndex: number) => VirtualKey;\n horizontal?: boolean;\n measurementCache?: MeasurementCache;\n onChange?: (state: GroupVirtualizerState<T>) => void;\n onScrollEnd?: (offset: number) => void;\n onScrollingChange?: (isScrolling: boolean) => void;\n overscan?: Overscan;\n scrollEndDelay?: number;\n sections: Array<GroupSection<T>>;\n signal?: (init: GroupVirtualizerState<T>) => Signal<GroupVirtualizerState<T>>;\n}\n```\n\n### `GroupVirtualizerUpdateOptions<T>`\n\n```ts\ninterface GroupVirtualizerUpdateOptions<T> {\n estimateHeaderSize?: number | ((section: GroupSection<T>, sectionIndex: number) => number);\n estimateItemSize?: number | ((item: T, itemIndex: number, sectionIndex: number) => number);\n getItemKey?: (item: T, itemIndex: number, sectionIndex: number) => VirtualKey;\n measurementCache?: MeasurementCache;\n onChange?: ((state: GroupVirtualizerState<T>) => void) | undefined;\n onScrollEnd?: ((offset: number) => void) | undefined;\n onScrollingChange?: ((isScrolling: boolean) => void) | undefined;\n overscan?: Overscan;\n scrollEndDelay?: number;\n}\n```\n\n### `GroupVirtualizer<T>`\n\n```ts\ninterface GroupVirtualizer<T> {\n readonly count: number;\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n readonly isScrolling: boolean;\n readonly items: ReadonlyArray<GroupVirtualItem<T>>;\n measure: (index: number, size: number) => void;\n measureBatch: (entries: Array<{ index: number; size: number }>) => void;\n measureEl: (index: number, el: HTMLElement) => () => void;\n refresh: () => void;\n readonly scrollOffset: number;\n scrollToBottom: (options?: { behavior?: ScrollBehavior }) => void;\n scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;\n scrollToItem: (sectionIndex: number, itemIndex: number, options?: ScrollToIndexOptions) => void;\n scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void;\n scrollToSection: (sectionIndex: number, options?: ScrollToIndexOptions) => void;\n scrollToTop: (options?: { behavior?: ScrollBehavior }) => void;\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n update: (sections: Array<GroupSection<T>>, opts?: GroupVirtualizerUpdateOptions<T>) => void;\n [Symbol.dispose]: () => void;\n}\n```\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### `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### `GridVirtualizerOptions`\n\n```ts\ninterface GridVirtualizerOptions {\n colCount: number;\n colGap?: number;\n colMeasurementCache?: Map<number, number>;\n estimateColSize?: number | ((col: number) => number);\n estimateRowSize?: number | ((row: number) => number);\n initialScrollLeft?: number;\n initialScrollTop?: number;\n keyboardScroll?: boolean;\n onChange?: (state: GridVirtualizerState) => void;\n onRangeChange?: (range: GridRangeChangeEvent) => void;\n overscanX?: Overscan;\n overscanY?: Overscan;\n rowCount: number;\n rowGap?: number;\n rowMeasurementCache?: Map<number, number>;\n signal?: (init: GridVirtualizerState) => Signal<GridVirtualizerState>;\n}\n```\n\n### `GridVirtualizer`\n\n```ts\ninterface GridVirtualizer {\n readonly cols: VirtualItem[];\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n measureBatch: (rows: Array<{ index: number; size: number }>, cols: Array<{ index: number; size: number }>) => void;\n measureColEl: (col: number, el: HTMLElement) => () => void;\n measureColumn: (col: number, size: number) => void;\n measureRow: (row: number, size: number) => void;\n measureRowEl: (row: number, el: HTMLElement) => () => void;\n prependRows: (additionalRowCount: number) => void;\n refresh: () => void;\n readonly rows: VirtualItem[];\n readonly scrollLeft: number;\n scrollToCell: (row: number, col: number, options?: ScrollToCellOptions) => void;\n scrollToColumn: (col: number, options?: Pick<ScrollToCellOptions, 'behavior' | 'colAlign'>) => void;\n readonly scrollTop: number;\n scrollToRow: (row: number, options?: Pick<ScrollToCellOptions, 'behavior' | 'rowAlign'>) => void;\n readonly totalHeight: number;\n readonly totalWidth: number;\n update: (next: GridVirtualizerUpdateOptions) => void;\n [Symbol.dispose]: () => void;\n}\n```\n\n## Errors\n\n| Class | Thrown when | Notable properties |\n| --- | --- | --- |\n| `ScrollError` | Base class for every Scroll error. | Use `instanceof ScrollError` to narrow unknown errors 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
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## Keyboard Navigation\n\nEnable keyboard-based scrolling with the `keyboardScroll` option. Users can navigate lists using Arrow keys, Page Up/Down, Home, and End.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: 1000,\n estimateSize: 36,\n keyboardScroll: true, // Enable keyboard navigation\n onChange: render,\n});\n```\n\n**Supported keys:**\n- **Arrow Up/Down** (or Left/Right for horizontal lists) — Scroll by one estimated item height\n- **Page Up/Down** — Scroll by ~80% of viewport height\n- **Home** — Jump to the start of the list\n- **End** — Jump to the end of the list\n\n**Requirements:**\n- The scroll container (or a descendant) must have keyboard focus for events to fire\n- Works with all factories: `createVirtualizer`, `createDomVirtualList`, `createGroupedVirtualizer`, `createGridVirtualizer`\n- Arrow key step size is automatically calculated from your `estimateSize` (or `estimateRowSize`/`estimateColSize` for grids)\n\n## Auto-Measurement\n\nEnable automatic item measurement for dynamic or user-generated content that changes size. When `autoMeasure` is enabled, the virtualizer measures visible items via `ResizeObserver` and updates layout in real time.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: messages.length,\n estimateSize: 36, // Initial guess; will be measured\n autoMeasure: true, // Automatically measure visible items\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 // IMPORTANT: Set data-vz-key for auto-measure to find the element\n el.setAttribute('data-vz-key', String(item.index));\n el.textContent = messages[item.index]?.text ?? '';\n listEl.appendChild(el);\n }\n },\n});\n```\n\n**Requirements:**\n- Every rendered item must have a `data-vz-key` attribute with a unique value\n- Must use a DOM scroll target (not `Window`)\n- Elements must be in the DOM by the time `ResizeObserver` fires (usually the next microtask)\n\n**Use cases:**\n- Chat lists where messages expand on load\n- Expandable sections with collapsing text\n- Lazy-loaded thumbnails that arrive with unknown heights\n- User-resizable rows or dynamic content (videos, iframes)\n\n**Performance notes:**\n- Auto-measurement queries the DOM every render cycle — avoid with very large visible windows (100+ items)\n- For finer control, use the manual `measureEl()` method instead\n- Enable only on lists with truly variable-height items\n\n## Reactive Integration\n\nExpose virtualizer state to a reactive `Signal` from `@vielzeug/ripple` using the `signal` option. This works on all factories and pairs with your existing `onChange` callback.\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\nimport { signal, effect } from '@vielzeug/ripple';\n\n// Create an empty signal with the initial state shape\nconst scrollState = signal({ items: [], stickyItems: [], totalSize: 0 });\n\nconst virt = createVirtualizer(scrollEl, {\n count: 1000,\n estimateSize: 36,\n signal: () => scrollState, // Return the signal on each init\n onChange: render, // Both signal and callback get the state\n});\n\n// React to state changes\neffect(() => {\n const { totalSize, items } = scrollState.value;\n console.log(`Visible: ${items.length} items, total height: ${totalSize}px`);\n});\n```\n\n**Why a signal factory instead of a direct signal?**\nThe `signal` option receives a factory function so that if your component mounts/unmounts and recreates the virtualizer, the signal is also recreated with a fresh initial state. If you want to share state across multiple virtualizers or preserve it across disposal, create the signal in outer scope and return it from the factory:\n\n```ts\n// Shared signal across remounts\nconst scrollState = signal({ items: [], stickyItems: [], totalSize: 0 });\n\nfunction createList() {\n return createVirtualizer(scrollEl, {\n count: 1000,\n signal: () => scrollState, // Always return the same instance\n });\n}\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
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"apiSource": "export { createElementSize } from './element-size.ts';\nexport { SentinelError, SentinelUnavailableError } from './errors.ts';\nexport type { CreateIntersectionOptions } from './intersection.ts';\nexport { createIntersection } from './intersection.ts';\nexport { createMediaQuery } from './media-query.ts';\nexport { createNetwork } from './network.ts';\nexport type {\n ElementSizeState,\n IntersectionState,\n MediaQueryState,\n NetworkConnectionSnapshot,\n NetworkState,\n Sentinel,\n SentinelOptions,\n ViewportState,\n WindowSentinelOptions,\n} from './types.ts';\nexport { createViewport } from './viewport.ts';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Sentinel — Reactive environment state\ndescription: Reactive browser and DOM observations for viewport, network, media query, element size, and intersection state.\npackage: sentinel\ncategory: Environment\nkeywords: [reactive, browser, viewport, network, media-query, resize-observer, intersection-observer]\nrelated: [ripple, ore, focus, gesture]\nexports: [createViewport, createNetwork, createMediaQuery, createElementSize, createIntersection, SentinelError, SentinelUnavailableError, Sentinel]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"sentinel\" />\n\n## Why Sentinel?\n\nBrowser environment APIs use different events, observer callbacks, initial states, and cleanup methods. Sentinel gives them one explicit handle shape and exposes current values as Ripple `Readable<T>` signals.\n\n```ts\n// Before\n{\n const panel = document.querySelector<HTMLElement>('[data-panel]');\n if (!panel) throw new Error('Panel not found');\n\n const observer = new ResizeObserver(([entry]) => {\n console.log(entry?.contentRect.width);\n });\n observer.observe(panel);\n\n // Later\n observer.disconnect();\n}\n\n// After\nimport { createElementSize } from '@vielzeug/sentinel';\n\n{\n const panel = document.querySelector<HTMLElement>('[data-panel]');\n if (!panel) throw new Error('Panel not found');\n\n const size = createElementSize(panel);\n const unsubscribe = size.subscribe(() => {\n console.log(size.value?.width);\n });\n\n // Later\n unsubscribe();\n size.dispose();\n}\n```\n\n| Feature | Sentinel | Native observer APIs | Ad hoc event listeners |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"sentinel\" type=\"size\" /> | Built in | Application-defined |\n| Zero dependencies | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Reactive current state | <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| Consistent disposable handle | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Shared abort ownership | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| Ripple composition | <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\n<div class=\"decision-callout\">\n\n**Use Sentinel when** browser or DOM observations need reactive state, consistent ownership, and composition with Ripple.\n\n**Consider native APIs when** one isolated observer is sufficient and adding Ripple as a peer dependency is not justified.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/sentinel @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/sentinel @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/sentinel @vielzeug/ripple\n```\n\n:::\n\n## Quick Start\n\nCreate a viewport Sentinel, render its initial state, then react to changes until the page lifetime ends.\n\n```ts\nimport { createViewport } from '@vielzeug/sentinel';\n\nfunction observeViewport(): () => void {\n const viewport = createViewport();\n const render = () => {\n const { dpr, height, width } = viewport.value;\n console.log(`${width}×${height} at ${dpr}dpr`);\n };\n\n render();\n const unsubscribe = viewport.subscribe(render);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n}\n\nconst stopObserving = observeViewport();\n// Call stopObserving() when the owning view unmounts.\n```\n\n<div class=\"features-grid\">\n\n## Features\n\n- `createViewport()` — Observe viewport dimensions and device pixel ratio.\n- `createNetwork()` — Track online status and optional connection details.\n- `createMediaQuery()` — Observe one media query.\n- `createElementSize()` — Read content-box dimensions from `ResizeObserver`.\n- `createIntersection()` — Track normalized intersection state.\n- `dispose()` — Release owned browser observers and listeners.\n- `SentinelOptions.signal` — Abort several Sentinels through one external lifetime.\n\n</div>\n\n<div class=\"doc-links\">\n\n## Documentation\n\n- [**Usage Guide**](./usage.md)
|
|
4
|
+
"index": "---\ntitle: Sentinel — Reactive environment state\ndescription: Reactive browser and DOM observations for viewport, network, media query, element size, and intersection state.\npackage: sentinel\ncategory: Environment\nkeywords: [reactive, browser, viewport, network, media-query, resize-observer, intersection-observer]\nrelated: [ripple, ore, focus, gesture]\nexports: [createViewport, createNetwork, createMediaQuery, createElementSize, createIntersection, SentinelError, SentinelUnavailableError, Sentinel]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"sentinel\" />\n\n## Why Sentinel?\n\nBrowser environment APIs use different events, observer callbacks, initial states, and cleanup methods. Sentinel gives them one explicit handle shape and exposes current values as Ripple `Readable<T>` signals.\n\n```ts\n// Before\n{\n const panel = document.querySelector<HTMLElement>('[data-panel]');\n if (!panel) throw new Error('Panel not found');\n\n const observer = new ResizeObserver(([entry]) => {\n console.log(entry?.contentRect.width);\n });\n observer.observe(panel);\n\n // Later\n observer.disconnect();\n}\n\n// After\nimport { createElementSize } from '@vielzeug/sentinel';\n\n{\n const panel = document.querySelector<HTMLElement>('[data-panel]');\n if (!panel) throw new Error('Panel not found');\n\n const size = createElementSize(panel);\n const unsubscribe = size.subscribe(() => {\n console.log(size.value?.width);\n });\n\n // Later\n unsubscribe();\n size.dispose();\n}\n```\n\n| Feature | Sentinel | Native observer APIs | Ad hoc event listeners |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"sentinel\" type=\"size\" /> | Built in | Application-defined |\n| Zero dependencies | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Reactive current state | <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| Consistent disposable handle | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Shared abort ownership | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| Ripple composition | <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\n<div class=\"decision-callout\">\n\n**Use Sentinel when** browser or DOM observations need reactive state, consistent ownership, and composition with Ripple.\n\n**Consider native APIs when** one isolated observer is sufficient and adding Ripple as a peer dependency is not justified.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/sentinel @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/sentinel @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/sentinel @vielzeug/ripple\n```\n\n:::\n\n## Quick Start\n\nCreate a viewport Sentinel, render its initial state, then react to changes until the page lifetime ends.\n\n```ts\nimport { createViewport } from '@vielzeug/sentinel';\n\nfunction observeViewport(): () => void {\n const viewport = createViewport();\n const render = () => {\n const { dpr, height, width } = viewport.value;\n console.log(`${width}×${height} at ${dpr}dpr`);\n };\n\n render();\n const unsubscribe = viewport.subscribe(render);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n}\n\nconst stopObserving = observeViewport();\n// Call stopObserving() when the owning view unmounts.\n```\n\n<div class=\"features-grid\">\n\n## Features\n\n- `createViewport()` — Observe viewport dimensions and device pixel ratio.\n- `createNetwork()` — Track online status and optional connection details.\n- `createMediaQuery()` — Observe one media query.\n- `createElementSize()` — Read content-box dimensions from `ResizeObserver`.\n- `createIntersection()` — Track normalized intersection state.\n- `dispose()` — Release owned browser observers and listeners.\n- `SentinelOptions.signal` — Abort several Sentinels through one external lifetime.\n\n</div>\n\n<div class=\"doc-links\">\n\n## Documentation\n\n- [**Usage Guide**](./usage.md)\n- [**API Reference**](./api.md)\n- [**Examples**](./examples.md)\n\n</div>\n\n<div class=\"see-also\">\n\n## See Also\n\n- [@vielzeug/ripple](../ripple/) — Derive and watch values from Sentinel state.\n- [@vielzeug/ore](../ore/) — Bind Sentinels to web-component mount and cleanup lifecycles.\n- [@vielzeug/focus](../focus/) — Manage keyboard focus alongside observed UI state.\n- [@vielzeug/gesture](../gesture/) — Handle pointer gestures alongside environmental observations.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
5
|
"api": "---\ntitle: Sentinel — API Reference\ndescription: Factory signatures, options, state types, lifecycle handles, and errors for Sentinel.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createViewport()` | Observe layout viewport dimensions and device pixel ratio | Sync | Requires a browser Window |\n| `createNetwork()` | Observe online status and optional connection information | Sync | `connection` is often `null` |\n| `createMediaQuery()` | Observe one media query | Sync | Throws when `matchMedia` is unavailable |\n| `createElementSize()` | Observe element content-box dimensions | Sync | Value is `null` before the first delivery |\n| `createIntersection()` | Observe element intersection state | Sync | Value is `null` before the first delivery |\n| `Sentinel<T>` | Combine a Ripple readable with explicit browser-resource ownership | Sync | Subscriptions and the Sentinel have separate cleanup |\n| `SentinelError` | Base class for package-defined errors | Sync | Catch a subtype when recovery is specific |\n| `SentinelUnavailableError` | Report an unavailable browser API | Sync | Invalid observer inputs retain their native errors |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/sentinel` | All factories, state types, option types, and error classes |\n\n## Factories\n\n### `createViewport()`\n\n```ts\nfunction createViewport(options?: WindowSentinelOptions): Sentinel<ViewportState>;\n```\n\nReturns a Sentinel initialized from the layout viewport's `innerWidth`, `innerHeight`, and `devicePixelRatio`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.target` | `Window` | Window to observe instead of the global browser window |\n| `options.runtime` | `Pick<Ripple, 'signal'>` | Ripple runtime that owns the internal signal |\n| `options.signal` | `AbortSignal` | External signal that disposes the Sentinel |\n\n**Returns:** `Sentinel<ViewportState>`.\n\n**Example**\n\n```ts\nimport { createViewport } from '@vielzeug/sentinel';\n\nconst viewport = createViewport();\nconsole.log(viewport.value.width);\nviewport.dispose();\n```\n\n---\n\n### `createNetwork()`\n\n```ts\nfunction createNetwork(options?: WindowSentinelOptions): Sentinel<NetworkState>;\n```\n\nReturns a Sentinel initialized from `navigator.onLine` and the optional Network Information API.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.target` | `Window` | Window whose navigator and events are observed |\n| `options.runtime` | `Pick<Ripple, 'signal'>` | Ripple runtime that owns the internal signal |\n| `options.signal` | `AbortSignal` | External signal that disposes the Sentinel |\n\n**Returns:** `Sentinel<NetworkState>`.\n\n**Example**\n\n```ts\nimport { createNetwork } from '@vielzeug/sentinel';\n\nconst network = createNetwork();\nconsole.log(network.value.online);\nnetwork.dispose();\n```\n\n---\n\n### `createMediaQuery()`\n\n```ts\nfunction createMediaQuery(query: string, options?: WindowSentinelOptions): Sentinel<MediaQueryState>;\n```\n\nReturns a Sentinel initialized from `matchMedia(query).matches`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `query` | `string` | CSS media query to observe |\n| `options.target` | `Window` | Window whose `matchMedia` method is used |\n| `options.runtime` | `Pick<Ripple, 'signal'>` | Ripple runtime that owns the internal signal |\n| `options.signal` | `AbortSignal` | External signal that disposes the Sentinel |\n\n**Returns:** `Sentinel<MediaQueryState>`.\n\n**Example**\n\n```ts\nimport { createMediaQuery } from '@vielzeug/sentinel';\n\nconst darkMode = createMediaQuery('(prefers-color-scheme: dark)');\nconsole.log(darkMode.value.matches);\ndarkMode.dispose();\n```\n\n---\n\n### `createElementSize()`\n\n```ts\nfunction createElementSize(element: Element, options?: SentinelOptions): Sentinel<ElementSizeState | null>;\n```\n\nReturns a Sentinel containing the latest `ResizeObserverEntry.contentRect` dimensions.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `element` | `Element` | Element to observe |\n| `options.runtime` | `Pick<Ripple, 'signal'>` | Ripple runtime that owns the internal signal |\n| `options.signal` | `AbortSignal` | External signal that disposes the Sentinel |\n\n**Returns:** `Sentinel<ElementSizeState | null>`. The initial value is `null`.\n\n**Example**\n\n```ts\nimport { createElementSize } from '@vielzeug/sentinel';\n\nconst size = createElementSize(document.body);\nconst unsubscribe = size.subscribe(() => {\n console.log(size.value?.width);\n});\n\nunsubscribe();\nsize.dispose();\n```\n\n---\n\n### `createIntersection()`\n\n```ts\nfunction createIntersection(\n element: Element,\n options?: CreateIntersectionOptions,\n): Sentinel<IntersectionState | null>;\n```\n\nReturns a Sentinel containing normalized fields from the latest IntersectionObserver entry.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `element` | `Element` | Element to observe |\n| `options.root` | `Element \\| Document \\| null` | Intersection root |\n| `options.rootMargin` | `string` | Margin applied to the root |\n| `options.scrollMargin` | `string` | Margin applied to nested scroll containers |\n| `options.threshold` | `number \\| number[]` | Intersection ratio threshold or thresholds |\n| `options.runtime` | `Pick<Ripple, 'signal'>` | Ripple runtime that owns the internal signal |\n| `options.signal` | `AbortSignal` | External signal that disposes the Sentinel |\n\n**Returns:** `Sentinel<IntersectionState | null>`. The initial value is `null`.\n\n**Example**\n\n```ts\nimport { createIntersection } from '@vielzeug/sentinel';\n\nconst intersection = createIntersection(document.body, { threshold: 0.5 });\nconst unsubscribe = intersection.subscribe(() => {\n console.log(intersection.value?.isIntersecting);\n});\n\nunsubscribe();\nintersection.dispose();\n```\n\n## Types\n\n### `Sentinel<T>`\n\n```ts\ninterface Sentinel<T> extends Readable<T> {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n [Symbol.dispose](): void;\n}\n```\n\n`value`, `peek()`, and `subscribe()` follow Ripple's `Readable<T>` contract. `dispose()` stops the underlying browser observation. A subscription's returned function remains independently owned by the subscriber.\n\n| Member | Type | Description |\n| --- | --- | --- |\n| `value` | `T` | Current reactive snapshot |\n| `peek()` | `() => T` | Read the snapshot without reactive tracking |\n| `subscribe(listener)` | `(listener: () => void) => () => void` | Subscribe to invalidations and return an independent unsubscribe function |\n| `disposed` | `boolean` | Whether observation has ended |\n| `disposalSignal` | `AbortSignal` | Aborts when observation ends |\n| `dispose()` | `() => void` | Stop observation and release owned browser resources |\n| `[Symbol.dispose]()` | `() => void` | Dispose through the explicit resource-management protocol |\n\n---\n\n### `SentinelOptions`\n\n```ts\ninterface SentinelOptions {\n readonly runtime?: Pick<Ripple, 'signal'>;\n readonly signal?: AbortSignal;\n}\n```\n\n---\n\n### `WindowSentinelOptions`\n\n```ts\ninterface WindowSentinelOptions extends SentinelOptions {\n readonly target?: Window;\n}\n```\n\n---\n\n### `CreateIntersectionOptions`\n\n```ts\ninterface CreateIntersectionOptions extends SentinelOptions {\n readonly root?: Element | Document | null;\n readonly rootMargin?: string;\n readonly scrollMargin?: string;\n readonly threshold?: number | number[];\n}\n```\n\n---\n\n### `ViewportState`\n\n```ts\ninterface ViewportState {\n readonly dpr: number;\n readonly height: number;\n readonly width: number;\n}\n```\n\n---\n\n### `NetworkConnectionSnapshot`\n\n```ts\ninterface NetworkConnectionSnapshot {\n readonly downlink?: number;\n readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';\n readonly rtt?: number;\n readonly saveData?: boolean;\n}\n```\n\n---\n\n### `NetworkState`\n\n```ts\ninterface NetworkState {\n readonly connection: NetworkConnectionSnapshot | null;\n readonly online: boolean;\n}\n```\n\n---\n\n### `MediaQueryState`\n\n```ts\ninterface MediaQueryState {\n readonly matches: boolean;\n}\n```\n\n---\n\n### `ElementSizeState`\n\n```ts\ninterface ElementSizeState {\n readonly height: number;\n readonly width: number;\n}\n```\n\n---\n\n### `IntersectionState`\n\n```ts\ninterface IntersectionState {\n readonly intersectionRatio: number;\n readonly isIntersecting: boolean;\n}\n```\n\n## Errors\n\n### `SentinelError`\n\n```ts\nclass SentinelError extends Error {\n constructor(message: string, options?: ErrorOptions);\n}\n```\n\nBase class for package-defined errors.\n\n---\n\n### `SentinelUnavailableError`\n\n```ts\nclass SentinelUnavailableError extends SentinelError {}\n```\n\nThrown when a required browser API or Window is unavailable:\n\n- `createViewport()` and `createNetwork()` when no browser Window is available.\n- `createMediaQuery()` when `matchMedia` is unavailable.\n- `createElementSize()` when the element has no Window or `ResizeObserver` is unavailable.\n- `createIntersection()` when the element has no Window or `IntersectionObserver` is unavailable.\n\nNative setup errors remain unchanged, including invalid observer options or targets.\n",
|
|
6
6
|
"usage": "---\ntitle: Sentinel — Usage Guide\ndescription: Observe browser and DOM state with explicit reactive lifecycles.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate a Sentinel, read its current state, subscribe to invalidations, and release both resources when the owner ends.\n\n```ts\nimport { createViewport } from '@vielzeug/sentinel';\n\nfunction observeViewport(): () => void {\n const viewport = createViewport();\n\n const render = () => {\n const { dpr, height, width } = viewport.value;\n console.log(`${width}×${height} at ${dpr}dpr`);\n };\n\n render();\n const unsubscribe = viewport.subscribe(render);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n}\n\nconst stopObserving = observeViewport();\n// Call stopObserving() when the owning view unmounts.\n```\n\n`subscribe()` notifies you that the value changed; read the new snapshot from `.value` inside the listener. Disposing a Sentinel stops its browser observer or event listeners. It does not unsubscribe consumers from the Ripple readable.\n\n## Observe Window State\n\nUse `createViewport()` for viewport dimensions and device pixel ratio.\n\n```ts\nimport { createViewport } from '@vielzeug/sentinel';\n\nconst viewport = createViewport();\nconsole.log(viewport.value.width);\nconsole.log(viewport.value.height);\nconsole.log(viewport.value.dpr);\n```\n\nUse `createNetwork()` for online status and the optional Network Information API snapshot.\n\n```ts\nimport { createNetwork } from '@vielzeug/sentinel';\n\nconst network = createNetwork();\nconsole.log(network.value.online);\nconsole.log(network.value.connection);\n```\n\n`connection` is `null` when `navigator.connection` is unavailable.\n\n## Observe Media Queries\n\nUse `createMediaQuery()` to react to a browser media query.\n\n```ts\nimport { createMediaQuery, SentinelUnavailableError } from '@vielzeug/sentinel';\n\nfunction observeReducedMotion(): () => void {\n try {\n const reducedMotion = createMediaQuery('(prefers-reduced-motion: reduce)');\n\n const applyPreference = () => {\n document.documentElement.classList.toggle('reduce-motion', reducedMotion.value.matches);\n };\n\n applyPreference();\n const unsubscribe = reducedMotion.subscribe(applyPreference);\n\n return () => {\n unsubscribe();\n reducedMotion.dispose();\n };\n } catch (error) {\n if (!(error instanceof SentinelUnavailableError)) throw error;\n return () => {};\n }\n}\n\nconst stopObserving = observeReducedMotion();\n// Call stopObserving() when the owning view unmounts.\n```\n\n`createMediaQuery()` throws `SentinelUnavailableError` when `matchMedia` is unavailable.\n\n## Observe Elements\n\n### Element Size\n\nUse `createElementSize()` after the target element exists.\n\n```ts\nimport { createElementSize } from '@vielzeug/sentinel';\n\nconst panel = document.querySelector<HTMLElement>('[data-panel]');\nif (!panel) throw new Error('Panel not found');\n\nconst size = createElementSize(panel);\nconst unsubscribe = size.subscribe(() => {\n const current = size.value;\n if (current) panel.dataset.width = String(current.width);\n});\n```\n\nThe initial state is `null` until `ResizeObserver` reports its first measurement.\n\n### Intersection\n\nUse `createIntersection()` to observe visibility relative to the viewport or a custom root.\n\n```ts\nimport { createIntersection } from '@vielzeug/sentinel';\n\nconst target = document.querySelector<HTMLElement>('[data-lazy-section]');\nif (!target) throw new Error('Section not found');\n\nconst intersection = createIntersection(target, {\n rootMargin: '100px',\n threshold: [0, 0.5, 1],\n});\n\nconst unsubscribe = intersection.subscribe(() => {\n target.hidden = !intersection.value?.isIntersecting;\n});\n```\n\nThe initial state is `null` until `IntersectionObserver` reports its first entry.\n\n## Control Ownership\n\nCall `dispose()` to stop observation. Disposal is idempotent.\n\n```ts\nconst viewport = createViewport();\n\nviewport.dispose();\nviewport.dispose();\n```\n\nPass an `AbortSignal` when several Sentinels share one lifetime.\n\n```ts\nconst controller = new AbortController();\nconst viewport = createViewport({ signal: controller.signal });\nconst network = createNetwork({ signal: controller.signal });\n\ncontroller.abort();\n```\n\nAn injected Ripple runtime creates the state signal. Runtime disposal and Sentinel disposal remain separate responsibilities.\n\n```ts\nimport { createRipple } from '@vielzeug/ripple';\nimport { createViewport } from '@vielzeug/sentinel';\n\nconst ripple = createRipple();\nconst viewport = createViewport({ runtime: ripple });\n\nviewport.dispose();\nripple.dispose();\n```\n\n## Handle Unavailable APIs\n\n`createMediaQuery()`, `createElementSize()`, and `createIntersection()` report unavailable platform APIs with `SentinelUnavailableError`.\n\n```ts\nimport { createElementSize, SentinelUnavailableError } from '@vielzeug/sentinel';\n\ntry {\n const size = createElementSize(document.body);\n size.dispose();\n} catch (error) {\n if (error instanceof SentinelUnavailableError) {\n console.warn(error.message);\n } else {\n throw error;\n }\n}\n```\n\nInvoke all factories only in a browser client lifecycle. Package imports are safe during SSR, but factories require browser or DOM APIs.\n\n## Framework Integration\n\nCreate the Sentinel after the component mounts, mirror its current value into framework state, and unsubscribe and dispose on unmount.\n\n::: code-group\n\n```tsx [React]\nimport { createViewport, type ViewportState } from '@vielzeug/sentinel';\nimport { useEffect, useState } from 'react';\n\nexport function ViewportSize() {\n const [viewportState, setViewportState] = useState<ViewportState | null>(null);\n\n useEffect(() => {\n const viewport = createViewport();\n const update = () => setViewportState(viewport.value);\n\n update();\n const unsubscribe = viewport.subscribe(update);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n }, []);\n\n return <output>{viewportState ? `${viewportState.width}×${viewportState.height}` : 'Measuring…'}</output>;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { createViewport, type Sentinel, type ViewportState } from '@vielzeug/sentinel';\nimport { onMounted, onUnmounted, ref } from 'vue';\n\nconst viewportState = ref<ViewportState | null>(null);\nlet viewport: Sentinel<ViewportState> | undefined;\nlet unsubscribe: (() => void) | undefined;\n\nonMounted(() => {\n viewport = createViewport();\n const update = () => {\n viewportState.value = viewport?.value ?? null;\n };\n\n update();\n unsubscribe = viewport.subscribe(update);\n});\n\nonUnmounted(() => {\n unsubscribe?.();\n viewport?.dispose();\n});\n</script>\n\n<template>\n <output>\n {{ viewportState ? `${viewportState.width}×${viewportState.height}` : 'Measuring…' }}\n </output>\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createViewport, type ViewportState } from '@vielzeug/sentinel';\n import { onMount } from 'svelte';\n\n let viewportState: ViewportState | null = null;\n\n onMount(() => {\n const viewport = createViewport();\n const update = () => {\n viewportState = viewport.value;\n };\n\n update();\n const unsubscribe = viewport.subscribe(update);\n\n return () => {\n unsubscribe();\n viewport.dispose();\n };\n });\n</script>\n\n<output>\n {viewportState ? `${viewportState.width}×${viewportState.height}` : 'Measuring…'}\n</output>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Sentinel + Ripple\n\nUse Ripple to derive values from one or more Sentinel states. Dispose the watcher separately from the Sentinels.\n\n```ts\nimport { computed, watch } from '@vielzeug/ripple';\nimport { createMediaQuery, createViewport } from '@vielzeug/sentinel';\n\nconst viewport = createViewport();\nconst mobileQuery = createMediaQuery('(max-width: 768px)');\nconst compact = computed(() => mobileQuery.value.matches || viewport.value.width < 400);\nconst compactWatcher = watch(compact, (value) => console.log('Compact layout:', value), { immediate: true });\n\ncompactWatcher.dispose();\nmobileQuery.dispose();\nviewport.dispose();\n```\n\n### Sentinel + Ore\n\nCreate DOM-dependent Sentinels in `onMounted()` and register both subscription and Sentinel cleanup with the component.\n\n```ts\nimport { define, html, onCleanup, onMounted, ref } from '@vielzeug/ore';\nimport { createElementSize } from '@vielzeug/sentinel';\n\ndefine('measured-panel', {\n setup() {\n const panel = ref<HTMLElement>();\n\n onMounted(() => {\n const element = panel.value;\n if (!element) return;\n\n const size = createElementSize(element);\n const update = () => {\n element.dataset.width = String(size.value?.width ?? 0);\n };\n const unsubscribe = size.subscribe(update);\n\n onCleanup(() => {\n unsubscribe();\n size.dispose();\n });\n });\n\n return html`<section ref=${panel}>Measured panel</section>`;\n },\n});\n```\n\n## Best Practices\n\n- **Create** DOM-dependent Sentinels only after their target elements exist.\n- **Read** the latest snapshot from `.value` inside subscription listeners.\n- **Unsubscribe** Ripple listeners when their owner ends.\n- **Dispose** every Sentinel to release browser observers and event listeners.\n- **Share** an `AbortSignal` when multiple Sentinels have the same lifetime.\n- **Guard** APIs that can throw `SentinelUnavailableError`.\n- **Treat** `NetworkState.connection` as optional browser enhancement data.\n- **Invoke** factories only in browser client lifecycles.\n",
|
|
7
7
|
"examples": "---\ntitle: Sentinel — Examples\ndescription: Focused browser and DOM observation examples for Sentinel.\n---\n\n## Examples\n\n- [Responsive Viewport Tracking](./examples/responsive-viewport-tracking.md)\n- [Monitor Network Condition](./examples/monitor-network-condition.md)\n- [Respect Reduced Motion Preference](./examples/respect-reduced-motion-preference.md)\n- [Responsive Column Layout](./examples/responsive-column-layout.md)\n- [Lazy Load Images on Intersection](./examples/lazy-load-images-on-intersection.md)\n"
|