API reference
Every public export of active-virtual, grouped by what you use it for, generated from the source - with the guide and the example of each group, and the package's own contract.
active-virtual has two entry points. Most applications use one hook from
@workspace/; a host without React uses the framework-neutral axis store
and the browser adapter from @workspace/. Everything below is generated from the
source, grouped by what you use it for; each export appears in exactly one group. The package's own
contract, with its concepts and guarantees, follows at the end.
| You want to | Use | Guide |
|---|---|---|
| virtualize a list in React | use | React |
| virtualize without React, or in another framework | create, create | Without React |
| keep an item in place while the list changes | an anchor policy, keyed items | Anchoring and scrolling |
| scroll to an item, an offset or an edge | a scroll request | Anchoring and scrolling |
React#
use virtualizes one collection along one direction inside a scroll container: it
returns what to render (the projection), a ref for each rendered item so its size is measured, and
a controller for scroll requests. inspect returns what a mount holds right now, for leak and
lifecycle tests. Two axes on one container make a two-dimensional view.
0 of 10,000 rows in the DOM
// apps/docs/examples/virtual/long-list.tsx'use client';
import {useState} from 'react';
import {Button} from '@workspace/active-ui/components/primitives/button';import {Input} from '@workspace/active-ui/components/primitives/input';import {Label} from '@workspace/active-ui/components/primitives/label';import {useVirtualAxis} from '@workspace/active-virtual/react';
const COUNT = 10_000;
/** Every seventh row wraps to a second line: the axis measures real heights. */function rowText(position: number): string { const base = `Row ${(position + 1).toLocaleString('en')}`; return position % 7 === 3 ? `${base}: a longer note that wraps to a second line in a narrow column, so this row is taller than its estimate.` : base;}
/** 10,000 rows of varying height; only the rows in view (plus overscan) are in the DOM. */export default function LongList() { const [container, setContainer] = useState<HTMLDivElement | null>(null); const [target, setTarget] = useState('5000'); const {projection, controller, itemRef} = useVirtualAxis({ count: COUNT, estimate: 32, container, }); return ( <div className="flex flex-col gap-3"> <form className="flex flex-wrap items-end gap-2" onSubmit={(event) => { event.preventDefault(); const row = Number(target); if (Number.isInteger(row) && row >= 1 && row <= COUNT) controller.scrollToPosition(row - 1, {align: 'center'}); }} > <div className="flex flex-col gap-1.5"> <Label htmlFor="virtual-target">Row</Label> <Input id="virtual-target" className="w-28" inputMode="numeric" value={target} onChange={(event) => setTarget(event.target.value)} /> </div> <Button type="submit" variant="outline"> Go to row </Button> </form> <div ref={setContainer} className="bg-background h-72 overflow-auto rounded-md border" tabIndex={0} role="region" aria-label="Ten thousand rows" data-virtual-container > <div style={{height: projection.extent, position: 'relative'}}> {projection.items.map((item) => ( <div key={item.identity} ref={itemRef(item.identity)} className="border-b px-3 py-1.5 text-sm" style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.offset}px)`, }} data-position={item.position} > {rowText(item.position)} </div> ))} </div> </div> <p className="text-muted-foreground text-sm" aria-live="polite"> {projection.items.length} of {COUNT.toLocaleString('en')} rows in the DOM </p> </div> );}Functions, hooks and constants
- Type
(options: VirtualAxis Options) => Virtual Axis
Virtualizes one axis of a scroll container.
Returns
An object with these fields.
- Type
Projection
What to render: the committed state with this render's inputs applied.
- Type
AxisController
Commands and queries; the same object for the lifetime of the mount.
- Type
(identity: Key) => ItemRef
The ref callback that associates an element with the item identity and has it measured. Stable for an item as long as its epoch lasts.
- Type
(identity: Key) => boolean
Whether identity is rendered in this render's projection.
- Type
(controller: AxisController) => Axis Ownership
What the mount behind controller holds right now.
Types
interface VirtualAxis {
readonly projection: Projection;
readonly controller: AxisController;
itemRef(identity: Key): ItemRef;
isRendered(identity: Key): boolean;
}The options of use: a count or keys, and the rest.
type VirtualAxisOptions = CommonOptions &
(
| {readonly count: number; readonly keys?: undefined}
| {readonly keys: readonly Key[]; readonly count?: undefined}
);The commands and queries of a mounted axis. Its identity is stable for the lifetime of the mount; commands never run during render, and queries read the committed state.
interface AxisController {
scrollToPosition(position: number, options?: ScrollOptions): void;
scrollToIdentity(identity: Key, options?: ScrollOptions): void;
scrollToOffset(
offset: number,
options?: {readonly behavior?: ScrollBehavior},
): void;
scrollBy(delta: number, options?: {readonly behavior?: ScrollBehavior}): void;
scrollToEdge(
edge: 'start' | 'end',
options?: {readonly behavior?: ScrollBehavior},
): void;
invalidate(target: 'all' | readonly Key[], strength?: 'stale' | 'void'): void;
reset(): void;
reportSize(identity: Key, size: number): void;
focus(identity: Key | null): void;
getProjection(): Projection;
positionAt(offset: number): number | null;
boundsOf(
target: {readonly position: number} | {readonly identity: Key},
): ItemBounds | null;
identityOf(element: Element): Key | null;
}What a mount holds, for ownership checks.
interface AxisOwnership {
readonly attached: boolean;
readonly active: boolean;
readonly subscribers: number;
readonly registrations: number;
readonly observed: number;
readonly refs: number;
readonly generation: number;
}Where the scroll container comes from: a value, or a function read at commit.
type ContainerSource =
ScrollContainer | null | (() => ScrollContainer | null);A ref callback that associates one element with one item.
type ItemRef = (element: Element | null) => (() => void) | undefined;The axis store, without React#
create is the axis itself, without a framework and without the browser: you give it
inputs (the collection by count or by key, estimates, measurements, the viewport) and read the
projection back. A collection is positional (count) or keyed (keys); with keys, a
measurement stays with its item through insertions and removals.
Functions, hooks and constants
An axis over the initial values init. The handle carries the public members only; a browser scroll host reaches the engine behind it.
(init: AxisInit, options?: AxisStoreOptions) => AxisStoreTypes
One virtualized axis, framework-neutral: the committed projection, the inputs that change it, and queries on the committed state. A browser scroll host attaches it to a scroll container, which observes and writes for it.
interface AxisStore {
getProjection(): Projection;
submit(batch: readonly AxisInput[]): void;
subscribe(listener: ProjectionListener): () => void;
observeRange(listener: ProjectionListener): () => void;
positionAt(offset: number): number | null;
boundsOf(
target: {readonly position: number} | {readonly identity: Key},
): ItemBounds | null;
}interface AxisStoreOptions {
readonly sink?: DiagnosticSink;
}The initial values of an axis.
interface AxisInit {
readonly collection: CollectionInit;
readonly estimate: EstimateProvider;
readonly layout?: Partial<LayoutConfig>;
readonly anchor?: Partial<AnchorPolicy>;
readonly rangePolicy?: RangePolicy;
readonly seed?: {
readonly viewportExtent?: number;
readonly offset?: number;
readonly sizes?: ReadonlyArray<readonly [Key, number]>;
};
}type AxisInput =
| {readonly type: 'viewport'; readonly extent: number}
| {
readonly type: 'scroll';
readonly offset: number;
readonly phase: ScrollPhase;
readonly echo?: boolean;
readonly time?: number;
}
| {
readonly type: 'measure';
readonly identity: Key;
readonly epoch: number;
readonly size: number;
}
| {
readonly type: 'invalidate';
readonly epoch: number;
readonly target: 'all' | readonly Key[];
readonly strength: InvalidationStrength;
}
| {readonly type: 'estimate'; readonly estimate: EstimateProvider}
| {readonly type: 'layout'; readonly layout: Partial<LayoutConfig>}
| {readonly type: 'count'; readonly count: number}
| {readonly type: 'keys'; readonly keys: readonly Key[]}
| {readonly type: 'edit'; readonly edits: readonly CollectionEdit[]}
| {readonly type: 'reset'}
| {readonly type: 'anchor'; readonly policy: Partial<AnchorPolicy>}
| {readonly type: 'range-policy'; readonly policy: RangePolicy | null}
| {readonly type: 'focus'; readonly identity: Key | null}
| {
readonly type: 'scroll-to';
readonly target: ScrollTarget;
readonly align?: ScrollAlign;
readonly behavior?: ScrollBehavior;
}
| {readonly type: 'tick'; readonly time: number; readonly visible: boolean}
| {readonly type: 'user-input'};One change of a keyed collection. Positions refer to the collection as it stands when the edit is applied, after the edits before it in the list.
insert: new identities atat.remove:countitems atatleave.move:countitems atfrommove so that the first of them ends at positiontoof the resulting collection. They keep their sizes.replace: the items atatare replaced by new identities, one for one; the new ones never inherit a size.
type CollectionEdit =
| {readonly op: 'insert'; readonly at: number; readonly keys: readonly Key[]}
| {readonly op: 'remove'; readonly at: number; readonly count: number}
| {
readonly op: 'move';
readonly from: number;
readonly count: number;
readonly to: number;
}
| {
readonly op: 'replace';
readonly at: number;
readonly keys: readonly Key[];
};The source of size estimates: one value for every item, or a function of the item's position and identity. A function is evaluated when a slot is created and when the provider is replaced, not on every read.
type EstimateProvider =
number | ((position: number, identity: Key) => number);Layout configuration of one axis. Every length is finite and >= 0.
interface LayoutConfig {
readonly paddingStart: number;
readonly paddingEnd: number;
readonly gap: number;
readonly listOrigin: number;
readonly scrollPaddingStart: number;
readonly scrollPaddingEnd: number;
readonly overscan: number | AdaptiveOverscan;
readonly lanes: number;
readonly enabled: boolean;
readonly debug: boolean;
}- Type
type Key = string | number | bigint;
An item identity: a consumer key, or a position in positional mode.
The browser adapter#
create connects axes to a real scroll container: it observes the viewport and the
item sizes and carries out the scroll writes an axis asks for. A container has one live host:
create throws when it already has one, find returns it. to and to convert between the physical scroll
position and the logical one of the reading direction, so right to left needs no special case in
your code.
Functions, hooks and constants
The scroll host of container. A container has one live host at a time.
(container: ScrollContainer, options?: ScrollHostOptions) => ScrollHostThe live scroll host of container, or null.
(container: ScrollContainer) => ScrollHost | null- Type
(plane: ScrollPlane, physical: number) => number
The logical offset of a physical scroll offset read from the browser.
- Type
(plane: ScrollPlane, logical: number) => number
The physical scroll offset to write for a logical offset.
Types
interface AxisAttachment {
observeItem(element: Element, identity: Key): () => void;
identityOf(element: Element): Key | null;
detach(): void;
}interface AxisAttachmentOptions {
readonly orientation: Orientation;
readonly direction?: 'ltr' | 'rtl';
readonly sizeOf?: SizeReader;
}- Type
type ScrollContainer = HTMLElement | Window;
A scroll container: an element, or a window that scrolls its document.
interface ScrollHost {
readonly container: ScrollContainer;
attach(store: AxisStore, options: AxisAttachmentOptions): AxisAttachment;
flush(): void;
dispose(): void;
}interface ScrollHostOptions {
readonly scrollEnd?: 'native' | 'quiet';
readonly idleDelay?: number;
readonly nativeAnchoring?: 'suppress' | 'report';
}Reads an item's size along the axis. Called from the resize observer's delivery, after layout; a reader that changes the DOM makes later reads in the same delivery force layout.
type SizeReader = (element: Element, orientation: Orientation) => number;- Type
type Orientation = 'vertical' | 'horizontal';
The scroll plane of one axis: the only place that knows how the browser's physical scroll offsets relate to the core's logical ones.
A logical offset grows from the start of the scroll content towards its end, in both text directions. Physically, a horizontal scroller in a right-to-left context follows the CSSOM View convention: 0 at the inline start (the right edge) and negative towards the inline end. Engines with the older positive right-to-left conventions are not supported.
The plane is linear: one logical unit is one CSS pixel. A scaled or segmented plane would replace this module and nothing in the core.
interface ScrollPlane {
readonly orientation: Orientation;
readonly rtl: boolean;
}The projection#
What to render, as a value: the rendered items with their offsets and sizes, the visible and rendered bounds, the extent to give the content, and a status. The range policy decides which positions are rendered: by default the visible ones and the overscan around them; it may add a pinned or focused item.
Types
An overscan that grows with the scroll speed: min items at rest and when scrolling slowly, one more item per 1 / of speed, never more than max. Speed is in logical units per 1000 time units of the scroll observations (per second when the adapter reports milliseconds).
interface AdaptiveOverscan {
readonly min: number;
readonly max: number;
readonly gain?: number;
}The kind of input that changes geometry.
type GeometryChange =
| 'viewport'
| 'measure'
| 'invalidate'
| 'estimate'
| 'layout'
| 'count'
| 'keys'
| 'edit'
| 'reset';An item's extent in logical scroll coordinates.
interface ItemBounds {
readonly position: number;
readonly start: number;
readonly end: number;
}An inclusive run of positions.
interface PositionRun {
readonly first: number;
readonly last: number;
}interface ProjectedItem {
readonly identity: Key;
readonly position: number;
readonly offset: number;
readonly size: number;
readonly end: number;
readonly authority: SizeAuthority;
readonly lane: number;
}interface Projection {
readonly status: ProjectionStatus;
readonly epoch: number;
readonly count: number;
readonly items: readonly ProjectedItem[];
readonly visible: PositionRun | null;
readonly rendered: PositionRun | null;
readonly extent: number;
readonly layoutExtent: number;
readonly phase: ScrollPhase;
readonly renderOrigin: number;
readonly slack: number;
}awaiting-: no viewport known yet; nothing rendered.viewport seeded: a seed supplies the viewport; the window follows the seed.live: a viewport has been observed.disabled: the axis is disabled; nothing rendered.
type ProjectionStatus =
'awaiting-viewport' | 'seeded' | 'live' | 'disabled';What a range policy is told about the default rendered set.
interface RangeContext {
readonly count: number;
readonly visible: PositionRun;
readonly positions: readonly number[];
readonly direction: ScrollDirection | null;
readonly overscan: {readonly before: number; readonly after: number};
}Chooses the positions to render, given the default set: it may add positions (a pinned or focused item) or leave some out. Pure; called by the core. The returned order is the rendered order; a position outside the collection or given twice is left out with a warning.
type RangePolicy = (context: RangeContext) => readonly number[];Anchoring and scrolling#
When geometry changes (a measurement arrives, items are inserted above, an estimate changes), the anchor decides which item keeps its place in the viewport. A scroll request goes to an item, an offset, a delta or an edge; the latest request wins, and a request to an item converges as its surroundings are measured.
// apps/docs/examples/virtual/keyed-prepend.tsx'use client';
import {useState} from 'react';
import {Button} from '@workspace/active-ui/components/primitives/button';import {useVirtualAxis} from '@workspace/active-virtual/react';
interface Message { key: string; text: string;}
function messages(from: number, count: number): Message[] { return Array.from({length: count}, (_, index) => { const n = from + index; return {key: `m${n}`, text: `Message ${n}`}; });}
/** * A keyed list: loading older messages above the viewport does not move what * is on screen, because the axis anchors on a key and compensates the scroll. */export default function KeyedPrepend() { const [container, setContainer] = useState<HTMLDivElement | null>(null); const [items, setItems] = useState(() => messages(100, 50)); const {projection, itemRef} = useVirtualAxis({ keys: items.map((item) => item.key), estimate: 36, container, // Keep the item at the top in place even when the list is scrolled to // its very start, instead of following that edge. startEdge: 'preserve', }); const byKey = new Map(items.map((item) => [item.key, item])); const first = Number(items[0]!.key.slice(1)); return ( <div className="flex flex-col gap-3"> <div className="flex flex-wrap items-center gap-2"> <Button variant="outline" disabled={first === 0} onClick={() => setItems((list) => [ ...messages(Math.max(0, first - 20), Math.min(20, first)), ...list, ]) } > Load 20 older messages </Button> <span className="text-muted-foreground text-sm" aria-live="polite"> {items.length} messages, from {items[0]!.text} </span> </div> <div ref={setContainer} className="bg-background h-60 overflow-auto rounded-md border" tabIndex={0} role="region" aria-label="Messages" data-virtual-container > <div style={{height: projection.extent, position: 'relative'}}> {projection.items.map((item) => ( <div key={item.identity} ref={itemRef(item.identity)} className="border-b px-3 py-2 text-sm" style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.offset}px)`, }} > {byKey.get(String(item.identity))?.text} </div> ))} </div> </div> </div> );}Types
Which item keeps its place in the viewport through a geometry change.
start-: the first item that starts at or after the viewport start (the item covering the viewport start when there is none); its start keeps its distance to the viewport start.edge end-: the last item that ends at or before the viewport end (the item covering the viewport end when there is none); its end keeps its distance to the viewport end.edge keyed: the named item; its start keeps its distance to the viewport start. When it is not in the collection,fallbackapplies.focus: askeyed, for the identity last reported as focused.none: nothing is compensated.
type AnchorChoice =
| {readonly kind: EdgeAnchorKind}
| {
readonly kind: 'keyed';
readonly identity: Key;
readonly fallback?: EdgeAnchorKind;
}
| {readonly kind: 'focus'; readonly fallback?: EdgeAnchorKind}
| {readonly kind: 'none'};What a consumer anchor function is told about one geometry change.
interface AnchorContext {
readonly change: GeometryChange;
readonly visible: readonly Key[];
}interface AnchorPolicy {
readonly anchor: AnchorChoice | AnchorChooser;
readonly startEdge: EdgeBehavior;
readonly endEdge: EdgeBehavior;
}- Type
type EdgeAnchor Kind = 'start- edge' | 'end- edge';
An anchor that follows one edge of the viewport.
How an item is aligned in the viewport; auto moves as little as possible.
type ScrollAlign = 'start' | 'center' | 'end' | 'auto';- Type
type ScrollBehavior = 'instant' | 'smooth';
- Type
type ScrollDirection = 'forward' | 'backward';
Where a scroll request goes.
type ScrollTarget =
| {readonly kind: 'identity'; readonly identity: Key}
| {readonly kind: 'position'; readonly position: number}
| {readonly kind: 'offset'; readonly offset: number}
| {readonly kind: 'delta'; readonly delta: number}
| {readonly kind: 'edge'; readonly edge: 'start' | 'end'};Diagnostic types#
Misuse and limits are reported as diagnostics: a level (warn or debug), a stable code, a message
and primitive data. The codes are listed in the contract below.
Types
interface Diagnostic {
readonly level: DiagnosticLevel;
readonly code: DiagnosticCode;
readonly message: string;
readonly data: Readonly<Record<string, Key | boolean | null>>;
}type DiagnosticCode =
| 'invalid-observation'
| 'unknown-identity'
| 'stale-epoch'
| 'invalid-request'
| 'intent-target-missing'
| 'intent-target-removed'
| 'intent-gave-up'
| 'anchor-missing'
| 'anchor-replaced'
| 'invalid-range'
| 'scroll-unreachable'
| 'native-anchoring-active'
| 'measurement-unavailable';type DiagnosticSink = (diagnostic: Diagnostic) => void;The package's contract#
The text below is the package's docs/, included unchanged: its
concepts, the contract of each part, and its guarantees and limits.
The supported contract of @workspace/, a workspace-private package: only what the two entry points export is API. The export lists are pinned by src/__; per-symbol TSDoc lives in the sources, and the compiler is authoritative on types. How the engine works is in ARCHITECTURE.; the release history in CHANGELOG..
Entry points#
| Import | Contents |
|---|---|
@workspace/ | create (the framework-neutral axis), create and find (the browser adapter), to and to (the scroll-plane conversion), every type |
@workspace/ | use (the React adapter), inspect, and the types of the hook, its controller and its item refs |
react and react- (^19) are optional peers, needed only for ./. Importing either entry needs no DOM: nothing reads a browser global at import time.
// Root: valuesexport { createAxisStore, createScrollHost, findScrollHost, toLogical, toPhysical,};// Root: typesexport type { AdaptiveOverscan, AnchorChoice, AnchorContext, AnchorPolicy, AxisAttachment, AxisAttachmentOptions, AxisInit, AxisInput, AxisStore, AxisStoreOptions, CollectionEdit, Diagnostic, DiagnosticCode, DiagnosticSink, EdgeAnchorKind, EstimateProvider, GeometryChange, ItemBounds, Key, LayoutConfig, Orientation, PositionRun, ProjectedItem, Projection, ProjectionStatus, RangeContext, RangePolicy, ScrollAlign, ScrollBehavior, ScrollContainer, ScrollDirection, ScrollHost, ScrollHostOptions, ScrollPlane, ScrollTarget, SizeAuthority, SizeReader,};
// ./reactexport {useVirtualAxis, inspectAxis};export type { AxisController, AxisOwnership, ContainerSource, ItemRef, VirtualAxis, VirtualAxisOptions,};Concepts#
One axis. An axis virtualizes one ordered collection along one direction. Two-dimensional use is two axes on one scroll container: they share the container's scroll host and nothing else.
Identity. A collection is either positional (
count: the position is the identity, within a collection epoch) or keyed (keys: a consumer key stays with its item through every change). Measurements belong to the identity. A reset starts a new epoch; anything issued under an older epoch no longer names an item. Within an epoch a key names the item that carries it now: a key that is removed and inserted again (also within one edit list), or replaced under the same key, is a new item that starts from its estimate. A measurement that arrives after the reinsertion, from whichever element, is the new item's; use a new key or a reset where that must not happen.Estimates and measurements. Every item has a size: its estimate until a measurement arrives, the measurement afterwards. Measurements arrive asynchronously, through the browser adapter or
report.Size Projection. What to render: the rendered items with their offsets and sizes, the visible and rendered bounds, the extent to give the content, and a status. A projection is a value; no array or object identity is part of the contract.
Anchor. Which item keeps its place in the viewport when geometry changes (a measurement, an insertion, a new estimate, a reset). The compensation is a scroll write.
Scroll request. A request to an item, an offset, a delta or an edge. The latest request wins; a request to an item converges on it as its surroundings are measured, within a bounded budget.
Coordinates: projected item offsets and the extent are in the content box (the start of the list's leading padding is 0). Scroll offsets are logical: the content box plus the list origin, growing from the inline start in both text directions. The browser adapter alone knows physical offsets and the right-to-left convention.
Framework-neutral use (root entry)#
create Axis Store(init, options?)#
function createAxisStore(init: AxisInit, options?: AxisStoreOptions): AxisStore;Creates one axis. Throws Range for invalid configuration (a negative or non-integer count, duplicate keys, an invalid length, overscan, lane count, estimate or seed).
Axis:
- Type
{mode: 'positional', count}or{mode: 'keyed', keys}
The identity mode and the collection
- Type
Estimate: a number, orProvider (position, identity) => number
The size of an unmeasured item. A function is evaluated when a slot is created or the provider is replaced
- Type
Partial<LayoutConfig>
See below; every field has a default
- Type
Partial<AnchorPolicy>
See Anchoring; default: start edge, preserve at both edges
- Type
RangePolicy
- Type
{viewportExtent?, offset?, sizes?: [Key, number][]}
Values for a first projection before any observation, for example on a server
Layout (every length finite and >= 0):
- Default
0
Space before the first and after the last item, part of the extent
- Default
0
Space between two consecutive items of a lane
- Default
0
Where the list starts inside the scroll content (content before the list)
- Default
0
Space kept free before and after an item a scroll request aligns
- Default
1
Items rendered beyond the visible ones per side and lane: an integer, or Adaptive
- Default
1
Lanes the items are laid out in (masonry)
- Default
true
A disabled axis renders nothing and is observed by nothing
- Default
false
Whether debug-level diagnostics are produced
Adaptive is {min, max, gain?}: min items at rest, one more per 1 / of scroll speed (logical units per second when the adapter reports milliseconds), never more than max; gain defaults to 0.01. While scrolling, the full count goes ahead and a smaller share, at least one item, stays behind.
Axis: {sink?: Diagnostic. Without a sink, diagnostics are dropped; the engine never writes to the console.
Axis Store#
The committed projection
Applies a batch of Axiss as one transition. A batch submitted from a listener is queued and applied next. Scroll requests submitted while no adapter is attached are held and carried out after the next adapter's first observation. Throws Range for invalid configuration; the state stays as it was
Called with the projection after every transition whose projection changed in value; returns the unsubscribe function
Called after every transition whose range changed: the epoch, the count, the visible bounds or the rendered positions. A change of geometry or scroll phase alone does not call it
Hit-testing at a logical offset: of the items covering it, the lowest position; in a gap, the latest item before it; null for an empty collection
{position, start, end} in logical coordinates, or null when the item is not in the collection
The handle carries exactly these members; only create makes one, and only such a handle can be attached to a scroll host.
Inputs (Axis Input)#
type | Carries | Meaning |
|---|---|---|
count | count | Positional mode: the count changed at the tail. Measurements of surviving positions stay |
keys | keys | Keyed mode: the new key sequence. The engine rebuilds the collection from it; surviving identities keep their sizes |
edit | edits: Collection | Keyed mode: insert, remove, move, replace, applied in order |
estimate | estimate | The estimate provider was replaced: every unmeasured size and the extent follow at once |
layout | Partial<Layout | A layout change |
reset | โ | Every measurement is void and a new epoch starts |
invalidate | epoch, target: 'all' | Key[], strength | stale keeps the size and asks for measuring again; void returns to the estimate. Either withdraws a measurement held back for the item. Identity never changes |
measure | identity, epoch, size | A measured size. An invalid size warns and is ignored; an unknown identity or an older epoch is dropped |
anchor | policy: Partial<Anchor | The anchor policy changed |
focus | identity | null | The identity holding focus, for a focus anchor |
range- | policy | null | The range policy changed |
scroll- | target: Scroll, align?, behavior? | A scroll request; see Scroll requests |
viewport, scroll, tick, user- | observations | Delivered by the browser adapter; a custom host may deliver them too |
Scroll is {kind: 'position', position}, {kind: 'identity', identity}, {kind: 'offset', offset}, {kind: 'delta', delta} or {kind: 'edge', edge: 'start' | 'end'}.
The projection#
awaiting- (no viewport known yet: nothing rendered), seeded (a seed supplies the viewport), live, disabled
The collection epoch and the item count
Projected: identity, position, offset, size, end, authority (estimated, seeded, measured, stale), lane. In collection order, or in the order a range policy returned
{first, last} of the visible positions, or null. With one lane every position between them intersects the viewport, except that a viewport edge in a gap, a padding or beyond the content takes in the nearest item on that side. With several lanes they are bounds only
{first, last} of the rendered positions, or null. With one lane and no range policy, contiguous
The extent to give the scroll content: frozen during a scroll episode against measurement refinement
The current extent, never frozen
idle or scrolling
How far the viewport reaches beyond content shorter than it, for end-aligned rendering
The logical offset of the content box's physical zero; always 0
A projection is published only when a value a renderer can read changed; scrolling inside an unchanged window publishes nothing.
Extent semantics. A structural change (collection, estimate, layout, reset, void invalidation) re-bases the presented extent at once, also during a scroll episode. A measurement during a scroll episode leaves the presented extent frozen until the episode ends. The declared extent is authoritative: scroll requests and compensations are clamped to list. Content that follows the list inside the container is declared as padding, content before it as list (DECISIONS. D-6).
Anchoring#
Anchor is {anchor, start:
anchor: anAnchoror a functionChoice (context: Anchor, called for each geometry change withContext) => Anchor Choice {change: Geometry.Change, visible: Key[]} Geometryis one ofChange viewport,measure,invalidate,estimate,layout,count,keys,edit,reset.Anchor:Choice {kind: 'start-(the first item starting at or after the viewport start keeps its distance to it),edge'} {kind: 'end-(the last item ending at or before the viewport end),edge'} {kind: 'keyed', identity, fallback?},{kind: 'focus', fallback?}, or{kind: 'none'}.start,Edge end:Edge followkeeps an attached viewport at that edge (append-follow for a feed),preservekeeps the item anchor. An edge is attached within 1 logical unit.
When the anchored item is removed, the nearest surviving successor takes its place, else the nearest surviving predecessor, else the edge (DECISIONS. D-5). While a scroll request is active it owns the offset: nothing is compensated.
A function passed as anchor is compared by identity: a new function is a new policy.
Scroll requests#
align:start,center,end, orauto(the default): no movement when the item is fully inside the padded viewport, otherwise the nearest edge. An item that ends the content, alignedend, targets the end of the scroll range.behavior:instant(the default) orsmooth.The latest request wins, also when it is submitted from a listener during a transition. A request that ends without settling (superseded, interrupted, its target missing or removed, given up) before its write has reached the browser writes nothing: its pending write is withdrawn, a compensation it replaced still lands, and the other axis's writes are untouched. A smooth animation the browser is already running for a request ends with that request's authority: when a newer request supersedes it, a collection change removes its target, or input interrupts it, the browser adapter stops the animation, also when the newer request writes nothing (a missing item, or
autoto an item already visible). It stops at the offset the axis last observed, the one the newer request was resolved against; on input the adapter sees itself, where the container is. A newer smooth request replaces the animation with its own write. Only the ended request's direction loses its motion: the other axis's smooth request carries on. An animation is not stopped when its request settles or gives up, nor when the user takes over by moving the container.A request to an item or an edge converges: it settles when the offset has reached a target that stayed put for 2 ticks, and gives up with an
intent-warning after 10 writes or 2 000 time units of visible time. Hidden time does not count. A smooth re-target of an animation still on its way does not count as a write.gave- up A request whose instant write lands short of its target keeps that target and never settles short of it. It writes again when its target moves, and when the container's scroll range has grown since the write landed (a layout that caught up with a grown extent after the write): the same target, for the same active request, only on growth and never because time passed. A retry is no new request: the write and visible-time budgets run on unchanged, and a target that stays out of reach gives up as before.
A user scroll (wheel, touch, a press on the scrollbar, or an offset the request cannot explain) ends it.
A request belongs to the axis, not to its container. A request that has not written yet is carried out on the next container the axis attaches to. A request that has written is tested against the new container's first observation like any observation: an offset it cannot explain ends it as interrupted; otherwise it carries on and writes again only when its target moves.
A request for an item not in the collection warns (
intent-); a collection change that removes the target ends it (target- missing intent-).target- removed During a smooth request, measurements far from the target are held back and applied when the request ends, however it ends. A held measurement keeps only the authority it had when it arrived: a newer accepted measurement of the item, an invalidation of it (either strength), a reset, or the item leaving the collection withdraws it before it is applied.
Range policy and observation#
A Range receives {count, visible, positions, direction, overscan} (the default rendered positions, ascending) and returns the positions to render, in rendered order: it may add a pinned or focused item or leave items out. A position outside the collection or a repeat is left out with an invalid- warning. Axis notifies range changes; the engine fetches nothing.
Lanes#
With lanes > 1, items are placed in collection order into the first empty lane, then into the lane that ends first (ties to the lowest lane), and keep their lane when measured. Visibility and overscan are per lane; the rendered set is in collection order and may be sparse. A lane-count change keeps the measurements, which belong to the identity; a consumer whose sizes depend on the lane width invalidates them.
Browser adapter (root entry)#
create Scroll Host(container, options?), find Scroll Host(container)#
function createScrollHost( container: HTMLElement | Window, options?: ScrollHostOptions,): ScrollHost;function findScrollHost(container: HTMLElement | Window): ScrollHost | null;One live host per container; create throws when the container has one, find returns it. Every browser object comes from the container's own window, so an iframe's window is served through itself.
Scroll:
- Default
'quiet'
How a scroll episode's end is detected: a quiet period, or the browser's scrollend event ('native') where it exists
- Default
150
The quiet period, in milliseconds
- Default
'suppress'
suppress sets overflow- on the container while an axis is attached and restores it after; report warns instead (native-)
Scroll: attach(axis, options), flush() (applies pending writes now), dispose(), container.
attach(axis, {orientation, direction?, size returns an Axis. The axis's first input is the container's current offset (an already scrolled container keeps its position), and the scroll requests held while nothing was attached follow in the same transition. direction (ltr or rtl) is read from the container when omitted; size replaces the default size, the border box along the axis rounded to whole pixels. Throws Type for an axis create did not make.
Axis: observe measures the element as that item under the current epoch and returns the function that ends exactly that association; identity; detach().
Observation is routed per direction: a vertical scroll reaches only vertical axes. The scroll writes of one task are merged into one physical write per container, so a row and a column request of the same turn both land. After every instant write the axis takes the offset the browser actually reached, also when the write moved nothing and no scroll event follows, so the projection shows what the viewport shows. A write that lands short warns (scroll-); its request keeps its target, is written again only when that target moves, never settles, and gives up at its budget. Listeners, the resize observer, the frame and the idle timer exist only while an axis is attached.
to Logical(plane, physical), to Physical(plane, logical)#
interface ScrollPlane { orientation: 'vertical' | 'horizontal'; rtl: boolean;}function toLogical(plane: ScrollPlane, physical: number): number;function toPhysical(plane: ScrollPlane, logical: number): number;The one conversion between the browser's physical scroll offset and the logical offset the core works in (D-12). A logical offset grows from the start of the scroll content toward its end in both directions; a horizontal right-to-left scroller follows the CSSOM View convention (0 at the inline start, negative toward the inline end). A vertical plane and a left-to-right plane are the identity. Published so a consumer that reads or writes scroll beside an attached axis (active-grid's header/body sync, ADR-053) uses this conversion instead of a second one.
React adapter (./ react)#
use Virtual Axis(options)#
const {projection, controller, itemRef, isRendered} = useVirtualAxis({ count: rows.length, estimate: 36, container: scrollElement,});Options: count or keys (exactly one), estimate, orientation (vertical by default), the Layout fields (padding, padding, gap, list, scroll, scroll, overscan, lanes, enabled, debug), anchor, start, end, range, and:
The scroll container: an element, a window, or a function that returns one. Read at commit, never during render; null leaves the axis unattached
Values for the first projection before any observation (server rendering). Read once, when the axis is created
The text direction of a horizontal axis; read from the container when omitted
A custom size reader for measured items
Scroll, used when this axis creates the container's host
When the element that holds focus unmounts because its item left the rendered set, focus moves to the (focusable) container and returns when the item mounts again
Receives every diagnostic
Called after every committed range change
Contract:
Render-time reads are current. The render reads the committed state with this render's inputs applied: a new count, keys, estimate, layout field or enablement shows in the render that passes it. Every change of the window or the extent re-renders the component.
Inputs are compared by value where they are data and by identity where they are functions: a new estimate function, anchor chooser or range policy is a change. Keep them stable (module level or
use).Callback A change of identity mode or orientation starts a new axis.
Invalid options throw a
Rangein the render that passes them.Error Nothing happens during render besides computing. The axis attaches to the container's scroll host (shared with the other axes of that container) for the committed lifetime of the mount; development StrictMode leaves one attachment, and an unmount leaves nothing.
No identity of
projectionor its arrays is promised; derive stability from values.
Virtual:
What to render in this render
Axis; the same object for the lifetime of the mount, safe in effect and callback dependencies
The ref callback that associates an element with that item and has it measured; stable for an item while its epoch lasts
Whether the item is in this render's projection (for aria-)
Axis: scroll, scroll, scroll, scroll, scroll, invalidate(target, strength = 'stale'), reset(), report, focus(identity | null), and the queries get, position, bounds, identity on the committed state. Commands never run during render; a scroll request made before the axis is attached is carried out after it attaches.
inspect returns Axis, what the mount holds right now (attached, active, subscribers, registrations, observed, refs, generation), for leak and lifecycle tests.
Diagnostics#
A Diagnostic is {level: 'warn' | 'debug', code, message, data}; data holds primitives only. Codes: invalid-, unknown-, stale-, invalid-, intent-, intent-, intent-, anchor-, anchor-, invalid-, scroll-, native-, measurement-. Debug diagnostics exist only with debug: true.
Guarantees and limits#
Guaranteed in Chromium, Firefox and WebKit, proven by the browser suite in e2e/ (pnpm --filter @workspace/):
The viewport follows an element, a window or an iframe's window; attaching keeps an offset the container was already scrolled to.
Items are measured by identity and epoch; a stale element or an older epoch never changes the current item.
An item that grows above the viewport, and a prepend, leave the visible item in place.
A request to an item converges on it under poor estimates; the latest request wins, and a request superseded before its write reaches the browser leaves the container where it was; a user wheel or an offset the request cannot explain ends it, after which it writes nothing. The browser's smooth animation of a request stops when a newer request supersedes it, also one that writes nothing, and when input the adapter sees interrupts it; the other axis's animation lands.
Measurements held back during a smooth request are applied when it ends; hidden time does not count against its budget.
A row and a column request of the same turn become one physical write, and both land.
Right to left on horizontal axes; CSS sticky content keeps working (the adapter applies no transform or containment).
A target beyond the browser's scroll range is reported (
scroll-) and never claimed as reached; the axis follows the offset the browser reached, also when the write moved nothing.unreachable After detach no listener, observation, frame or timer remains.
The React adapter: one attachment under StrictMode, nothing left after unmount, the same guarantees through the hook.
Best effort, measured 2026-09-25 with Playwright 1.63.0 on Windows (Chromium 153, Firefox 155, Playwright's WebKit 26.6); not promised:
| Behaviour | Chromium | Firefox | WebKit |
|---|---|---|---|
| Largest content extent laid out as declared | 33 554 429 px, clamped beyond | 10 693 127 px, larger dropped | 33 554 429 px, clamped |
Native scroll anchoring left on (report) | no double compensation seen | none seen | none seen |
| A prepend during a touch scroll gesture | engine and browser agree; part of the travel lost | not provable in the harness | not provable |
| Programmatic smooth scroll over about 100 000 px | about 1.5 s of the 2 000 ms visible-time budget | about 1.2 s | about 0.3 s |
Not proven: touch and momentum in Firefox and WebKit, real tab hiding (the visibility signal is simulated), a Safari build, nested scroll containers, and vertical writing modes: the default measurement assumes a horizontal writing mode. Content beyond a browser's practical extent limit is not hidden; a write that lands short is reported and the request never settles. A write that lands short because the container has not laid out a grown extent yet is written again once the scroll range has grown, within the request's budget.