---
id: "packages/active-virtual/api"
title: "API reference"
description: "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."
url: "/packages/active-virtual/api"
locale: "en"
revision: "9bb8549d22b1cad7"
---

# API reference

active-virtual has two entry points. Most applications use **one hook** from
`@workspace/active-virtual/react`; a host without React uses the framework-neutral **axis store**
and the **browser adapter** from `@workspace/active-virtual`. 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 | `useVirtualAxis` | [React](/packages/active-virtual/react) |
| virtualize without React, or in another framework | `createAxisStore`, `createScrollHost` | [Without React](/packages/active-virtual/without-react) |
| keep an item in place while the list changes | an anchor policy, keyed items | [Anchoring and scrolling](/packages/active-virtual/anchoring-and-scrolling) |
| scroll to an item, an offset or an edge | a scroll request | [Anchoring and scrolling](/packages/active-virtual/anchoring-and-scrolling) |

## React

`useVirtualAxis` 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. `inspectAxis` returns what a mount holds right now, for leak and
lifecycle tests. Two axes on one container make a two-dimensional view.

**Example: Ten thousand rows of varying height**

Client: Runs entirely in your browser. Source: `examples/virtual/long-list.tsx`.

```tsx
// 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>
  );
}
```

**useVirtualAxis**: `(options: VirtualAxisOptions) => VirtualAxis`

Virtualizes one axis of a scroll container.

Returns an object with:

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `projection` | `Projection` | yes | What to render: the committed state with this render's inputs applied. |
| `controller` | `AxisController` | yes | Commands and queries; the same object for the lifetime of the mount. |
| `itemRef` | `(identity: Key) => ItemRef` | yes | 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. |
| `isRendered` | `(identity: Key) => boolean` | yes | Whether `identity` is rendered in this render's projection. |

**inspectAxis**: `(controller: AxisController) => AxisOwnership`

What the mount behind `controller` holds right now.

**VirtualAxis**

```ts
interface VirtualAxis {
  readonly projection: Projection;
  readonly controller: AxisController;
  itemRef(identity: Key): ItemRef;
  isRendered(identity: Key): boolean;
}
```

**VirtualAxisOptions**

```ts
type VirtualAxisOptions = CommonOptions &
  (
    | {readonly count: number; readonly keys?: undefined}
    | {readonly keys: readonly Key[]; readonly count?: undefined}
  );
```

The options of {@link useVirtualAxis}: a count or keys, and the rest.

**AxisController**

```ts
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;
}
```

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.

**AxisOwnership**

```ts
interface AxisOwnership {
  readonly attached: boolean;
  readonly active: boolean;
  readonly subscribers: number;
  readonly registrations: number;
  readonly observed: number;
  readonly refs: number;
  readonly generation: number;
}
```

What a mount holds, for ownership checks.

**ContainerSource**

```ts
type ContainerSource =
  ScrollContainer | null | (() => ScrollContainer | null);
```

Where the scroll container comes from: a value, or a function read at commit.

**ItemRef**

```ts
type ItemRef = (element: Element | null) => (() => void) | undefined;
```

A ref callback that associates one element with one item.

## The axis store, without React

`createAxisStore` 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.

**createAxisStore**: `(init: AxisInit, options?: AxisStoreOptions) => AxisStore`

An axis over the initial values `init`. The handle carries the public
members only; a browser scroll host reaches the engine behind it.

**AxisStore**

```ts
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;
}
```

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.

**AxisStoreOptions**

```ts
interface AxisStoreOptions {
  readonly sink?: DiagnosticSink;
}
```

**AxisInit**

```ts
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]>;
  };
}
```

The initial values of an axis.

**AxisInput**

```ts
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'};
```

**CollectionEdit**

```ts
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[];
    };
```

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 at `at`.
- `remove`: `count` items at `at` leave.
- `move`: `count` items at `from` move so that the first of them ends at
position `to` of the resulting collection. They keep their sizes.
- `replace`: the items at `at` are replaced by new identities, one for one;
the new ones never inherit a size.

**EstimateProvider**

```ts
type EstimateProvider =
  number | ((position: number, identity: Key) => number);
```

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.

**LayoutConfig**

```ts
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;
}
```

Layout configuration of one axis. Every length is finite and >= 0.

**SizeAuthority**

```ts
type SizeAuthority = 'estimated' | 'seeded' | 'measured' | 'stale';
```

Who decided a slot's size.

**Key**

```ts
type Key = string | number | bigint;
```

An item identity: a consumer key, or a position in positional mode.

## The browser adapter

`createScrollHost` 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:
`createScrollHost` throws when it already has one, `findScrollHost` returns it. `toLogical` and `toPhysical` 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.

**createScrollHost**: `(container: ScrollContainer, options?: ScrollHostOptions) => ScrollHost`

The scroll host of `container`. A container has one live host at a time.

**findScrollHost**: `(container: ScrollContainer) => ScrollHost \| null`

The live scroll host of `container`, or `null`.

**toLogical**: `(plane: ScrollPlane, physical: number) => number`

The logical offset of a physical scroll offset read from the browser.

**toPhysical**: `(plane: ScrollPlane, logical: number) => number`

The physical scroll offset to write for a logical offset.

**AxisAttachment**

```ts
interface AxisAttachment {
  observeItem(element: Element, identity: Key): () => void;
  identityOf(element: Element): Key | null;
  detach(): void;
}
```

**AxisAttachmentOptions**

```ts
interface AxisAttachmentOptions {
  readonly orientation: Orientation;
  readonly direction?: 'ltr' | 'rtl';
  readonly sizeOf?: SizeReader;
}
```

**ScrollContainer**

```ts
type ScrollContainer = HTMLElement | Window;
```

A scroll container: an element, or a window that scrolls its document.

**ScrollHost**

```ts
interface ScrollHost {
  readonly container: ScrollContainer;
  attach(store: AxisStore, options: AxisAttachmentOptions): AxisAttachment;
  flush(): void;
  dispose(): void;
}
```

**ScrollHostOptions**

```ts
interface ScrollHostOptions {
  readonly scrollEnd?: 'native' | 'quiet';
  readonly idleDelay?: number;
  readonly nativeAnchoring?: 'suppress' | 'report';
}
```

**SizeReader**

```ts
type SizeReader = (element: Element, orientation: Orientation) => number;
```

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.

**Orientation**

```ts
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.

**ScrollPlane**

```ts
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.

**AdaptiveOverscan**

```ts
interface AdaptiveOverscan {
  readonly min: number;
  readonly max: number;
  readonly gain?: number;
}
```

An overscan that grows with the scroll speed: `min` items at rest and when
scrolling slowly, one more item per `1 / gain` 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).

**GeometryChange**

```ts
type GeometryChange =
  | 'viewport'
  | 'measure'
  | 'invalidate'
  | 'estimate'
  | 'layout'
  | 'count'
  | 'keys'
  | 'edit'
  | 'reset';
```

The kind of input that changes geometry.

**ItemBounds**

```ts
interface ItemBounds {
  readonly position: number;
  readonly start: number;
  readonly end: number;
}
```

An item's extent in logical scroll coordinates.

**PositionRun**

```ts
interface PositionRun {
  readonly first: number;
  readonly last: number;
}
```

An inclusive run of positions.

**ProjectedItem**

```ts
interface ProjectedItem {
  readonly identity: Key;
  readonly position: number;
  readonly offset: number;
  readonly size: number;
  readonly end: number;
  readonly authority: SizeAuthority;
  readonly lane: number;
}
```

**Projection**

```ts
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;
}
```

**ProjectionStatus**

```ts
type ProjectionStatus =
  'awaiting-viewport' | 'seeded' | 'live' | 'disabled';
```

- `awaiting-viewport`: no viewport known yet; nothing rendered.
- `seeded`: a seed supplies the viewport; the window follows the seed.
- `live`: a viewport has been observed.
- `disabled`: the axis is disabled; nothing rendered.

**RangeContext**

```ts
interface RangeContext {
  readonly count: number;
  readonly visible: PositionRun;
  readonly positions: readonly number[];
  readonly direction: ScrollDirection | null;
  readonly overscan: {readonly before: number; readonly after: number};
}
```

What a range policy is told about the default rendered set.

**RangePolicy**

```ts
type RangePolicy = (context: RangeContext) => readonly 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.

## 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.

**Example: Older items above the viewport, without a jump**

Client: Runs entirely in your browser. Source: `examples/virtual/keyed-prepend.tsx`.

```tsx
// 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>
  );
}
```

**AnchorChoice**

```ts
type AnchorChoice =
  | {readonly kind: EdgeAnchorKind}
  | {
      readonly kind: 'keyed';
      readonly identity: Key;
      readonly fallback?: EdgeAnchorKind;
    }
  | {readonly kind: 'focus'; readonly fallback?: EdgeAnchorKind}
  | {readonly kind: 'none'};
```

Which item keeps its place in the viewport through a geometry change.

- `start-edge`: 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.
- `end-edge`: 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.
- `keyed`: the named item; its start keeps its distance to the viewport
start. When it is not in the collection, `fallback` applies.
- `focus`: as `keyed`, for the identity last reported as focused.
- `none`: nothing is compensated.

**AnchorContext**

```ts
interface AnchorContext {
  readonly change: GeometryChange;
  readonly visible: readonly Key[];
}
```

What a consumer anchor function is told about one geometry change.

**AnchorPolicy**

```ts
interface AnchorPolicy {
  readonly anchor: AnchorChoice | AnchorChooser;
  readonly startEdge: EdgeBehavior;
  readonly endEdge: EdgeBehavior;
}
```

**EdgeAnchorKind**

```ts
type EdgeAnchorKind = 'start-edge' | 'end-edge';
```

An anchor that follows one edge of the viewport.

**ScrollAlign**

```ts
type ScrollAlign = 'start' | 'center' | 'end' | 'auto';
```

How an item is aligned in the viewport; `auto` moves as little as possible.

**ScrollBehavior**

```ts
type ScrollBehavior = 'instant' | 'smooth';
```

**ScrollDirection**

```ts
type ScrollDirection = 'forward' | 'backward';
```

**ScrollTarget**

```ts
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'};
```

Where a scroll request goes.

## 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.

**Diagnostic**

```ts
interface Diagnostic {
  readonly level: DiagnosticLevel;
  readonly code: DiagnosticCode;
  readonly message: string;
  readonly data: Readonly<Record<string, Key | boolean | null>>;
}
```

**DiagnosticCode**

```ts
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';
```

**DiagnosticSink**

```ts
type DiagnosticSink = (diagnostic: Diagnostic) => void;
```

## The package's contract

> [!NOTE]
> **The package's own reference**
>
> The text below is the package's `docs/API.md`, included unchanged: its
> concepts, the contract of each part, and its guarantees and limits.

The supported contract of `@workspace/active-virtual`, a workspace-private package: only what the two entry points export is API. The export lists are pinned by `src/__tests__/public-api.test.ts`; per-symbol TSDoc lives in the sources, and the compiler is authoritative on types. How the engine works is in `ARCHITECTURE.md`; the release history in `CHANGELOG.md`.

## Entry points

| Import | Contents |
| --- | --- |
| `@workspace/active-virtual` | `createAxisStore` (the framework-neutral axis), `createScrollHost` and `findScrollHost` (the browser adapter), `toLogical` and `toPhysical` (the scroll-plane conversion), every type |
| `@workspace/active-virtual/react` | `useVirtualAxis` (the React adapter), `inspectAxis`, and the types of the hook, its controller and its item refs |

`react` and `react-dom` (`^19`) are optional peers, needed only for `./react`. Importing either entry needs no DOM: nothing reads a browser global at import time.

```ts
// Root: values
export {
  createAxisStore,
  createScrollHost,
  findScrollHost,
  toLogical,
  toPhysical,
};
// Root: types
export 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,
};

// ./react
export {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 `reportSize`.
- **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)

### `createAxisStore(init, options?)`

```ts
function createAxisStore(init: AxisInit, options?: AxisStoreOptions): AxisStore;
```

Creates one axis. Throws `RangeError` for invalid configuration (a negative or non-integer count, duplicate keys, an invalid length, overscan, lane count, estimate or seed).

`AxisInit`:

| Field | Type | Meaning |
| --- | --- | --- |
| `collection` | `{mode: 'positional', count}` or `{mode: 'keyed', keys}` | The identity mode and the collection |
| `estimate` | `EstimateProvider`: a number, or `(position, identity) => number` | The size of an unmeasured item. A function is evaluated when a slot is created or the provider is replaced |
| `layout` | `Partial<LayoutConfig>` | See below; every field has a default |
| `anchor` | `Partial<AnchorPolicy>` | See [Anchoring](#anchoring); default: start edge, `preserve` at both edges |
| `rangePolicy` | `RangePolicy` | See [Range policy and observation](#range-policy-and-observation) |
| `seed` | `{viewportExtent?, offset?, sizes?: [Key, number][]}` | Values for a first projection before any observation, for example on a server |

`LayoutConfig` (every length finite and `>= 0`):

| Field | Default | Meaning |
| --- | --- | --- |
| `paddingStart`, `paddingEnd` | `0` | Space before the first and after the last item, part of the extent |
| `gap` | `0` | Space between two consecutive items of a lane |
| `listOrigin` | `0` | Where the list starts inside the scroll content (content before the list) |
| `scrollPaddingStart`, `scrollPaddingEnd` | `0` | Space kept free before and after an item a scroll request aligns |
| `overscan` | `1` | Items rendered beyond the visible ones per side and lane: an integer, or `AdaptiveOverscan` |
| `lanes` | `1` | Lanes the items are laid out in (masonry) |
| `enabled` | `true` | A disabled axis renders nothing and is observed by nothing |
| `debug` | `false` | Whether debug-level diagnostics are produced |

`AdaptiveOverscan` is `{min, max, gain?}`: `min` items at rest, one more per `1 / gain` 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.

`AxisStoreOptions`: `{sink?: DiagnosticSink}`. Without a sink, diagnostics are dropped; the engine never writes to the console.

### `AxisStore`

| Member | Contract |
| --- | --- |
| `getProjection()` | The committed projection |
| `submit(batch)` | Applies a batch of `AxisInput`s 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 `RangeError` for invalid configuration; the state stays as it was |
| `subscribe(listener)` | Called with the projection after every transition whose projection changed in value; returns the unsubscribe function |
| `observeRange(listener)` | 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 |
| `positionAt(offset)` | 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 |
| `boundsOf({position} \| {identity})` | `{position, start, end}` in logical coordinates, or `null` when the item is not in the collection |

The handle carries exactly these members; only `createAxisStore` makes one, and only such a handle can be attached to a scroll host.

### Inputs (`AxisInput`)

| `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: CollectionEdit[]` | 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<LayoutConfig>` | 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<AnchorPolicy>` | The anchor policy changed |
| `focus` | `identity \| null` | The identity holding focus, for a `focus` anchor |
| `range-policy` | `policy \| null` | The range policy changed |
| `scroll-to` | `target: ScrollTarget`, `align?`, `behavior?` | A scroll request; see [Scroll requests](#scroll-requests) |
| `viewport`, `scroll`, `tick`, `user-input` | observations | Delivered by the browser adapter; a custom host may deliver them too |

`ScrollTarget` is `{kind: 'position', position}`, `{kind: 'identity', identity}`, `{kind: 'offset', offset}`, `{kind: 'delta', delta}` or `{kind: 'edge', edge: 'start' | 'end'}`.

### The projection

| Field | Meaning |
| --- | --- |
| `status` | `awaiting-viewport` (no viewport known yet: nothing rendered), `seeded` (a seed supplies the viewport), `live`, `disabled` |
| `epoch`, `count` | The collection epoch and the item count |
| `items` | `ProjectedItem[]`: `identity`, `position`, `offset`, `size`, `end`, `authority` (`estimated`, `seeded`, `measured`, `stale`), `lane`. In collection order, or in the order a range policy returned |
| `visible` | `{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 |
| `rendered` | `{first, last}` of the rendered positions, or `null`. With one lane and no range policy, contiguous |
| `extent` | The extent to give the scroll content: frozen during a scroll episode against measurement refinement |
| `layoutExtent` | The current extent, never frozen |
| `phase` | `idle` or `scrolling` |
| `slack` | How far the viewport reaches beyond content shorter than it, for end-aligned rendering |
| `renderOrigin` | 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 `listOrigin + extent - viewport`. Content that follows the list inside the container is declared as `paddingEnd`, content before it as `listOrigin` (`DECISIONS.md` D-6).

### Anchoring

`AnchorPolicy` is `{anchor, startEdge, endEdge}`:

- `anchor`: an `AnchorChoice` or a function `(context: AnchorContext) => AnchorChoice`, called for each geometry change with `{change: GeometryChange, visible: Key[]}`. `GeometryChange` is one of `viewport`, `measure`, `invalidate`, `estimate`, `layout`, `count`, `keys`, `edit`, `reset`.
- `AnchorChoice`: `{kind: 'start-edge'}` (the first item starting at or after the viewport start keeps its distance to it), `{kind: 'end-edge'}` (the last item ending at or before the viewport end), `{kind: 'keyed', identity, fallback?}`, `{kind: 'focus', fallback?}`, or `{kind: 'none'}`.
- `startEdge`, `endEdge`: `follow` keeps an attached viewport at that edge (append-follow for a feed), `preserve` keeps 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.md` 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`, or `auto` (the default): no movement when the item is fully inside the padded viewport, otherwise the nearest edge. An item that ends the content, aligned `end`, targets the end of the scroll range.
- `behavior`: `instant` (the default) or `smooth`.
- 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 `auto` to 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-gave-up` 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.
- 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-target-missing`); a collection change that removes the target ends it (`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 `RangePolicy` 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-range` warning. `AxisStore.observeRange` 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)

### `createScrollHost(container, options?)`, `findScrollHost(container)`

```ts
function createScrollHost(
  container: HTMLElement | Window,
  options?: ScrollHostOptions,
): ScrollHost;
function findScrollHost(container: HTMLElement | Window): ScrollHost | null;
```

One live host per container; `createScrollHost` throws when the container has one, `findScrollHost` returns it. Every browser object comes from the container's own window, so an iframe's window is served through itself.

`ScrollHostOptions`:

| Field | Default | Meaning |
| --- | --- | --- |
| `scrollEnd` | `'quiet'` | How a scroll episode's end is detected: a quiet period, or the browser's `scrollend` event (`'native'`) where it exists |
| `idleDelay` | `150` | The quiet period, in milliseconds |
| `nativeAnchoring` | `'suppress'` | `suppress` sets `overflow-anchor: none` on the container while an axis is attached and restores it after; `report` warns instead (`native-anchoring-active`) |

`ScrollHost`: `attach(axis, options)`, `flush()` (applies pending writes now), `dispose()`, `container`.

`attach(axis, {orientation, direction?, sizeOf?})` returns an `AxisAttachment`. 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; `sizeOf(element, orientation)` replaces the default size, the border box along the axis rounded to whole pixels. Throws `TypeError` for an axis `createAxisStore` did not make.

`AxisAttachment`: `observeItem(element, identity)` measures the element as that item under the current epoch and returns the function that ends exactly that association; `identityOf(element)`; `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-unreachable`); 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.

### `toLogical(plane, physical)`, `toPhysical(plane, logical)`

```ts
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 `scrollLeft` beside an attached axis (active-grid's header/body sync, ADR-053) uses this conversion instead of a second one.

## React adapter (`./react`)

### `useVirtualAxis(options)`

```tsx
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 `LayoutConfig` fields (`paddingStart`, `paddingEnd`, `gap`, `listOrigin`, `scrollPaddingStart`, `scrollPaddingEnd`, `overscan`, `lanes`, `enabled`, `debug`), `anchor`, `startEdge`, `endEdge`, `rangePolicy`, and:

| Option | Meaning |
| --- | --- |
| `container` | The scroll container: an element, a window, or a function that returns one. Read at commit, never during render; `null` leaves the axis unattached |
| `seed` | Values for the first projection before any observation (server rendering). Read once, when the axis is created |
| `direction` | The text direction of a horizontal axis; read from the container when omitted |
| `sizeOf` | A custom size reader for measured items |
| `host` | `ScrollHostOptions`, used when this axis creates the container's host |
| `focusCustody` | 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 |
| `onDiagnostic` | Receives every diagnostic |
| `onRangeChange` | 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 `useCallback`).
- A change of identity mode or orientation starts a new axis.
- Invalid options throw a `RangeError` in the render that passes them.
- 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 `projection` or its arrays is promised; derive stability from values.

`VirtualAxis`:

| Member | Meaning |
| --- | --- |
| `projection` | What to render in this render |
| `controller` | `AxisController`; the same object for the lifetime of the mount, safe in effect and callback dependencies |
| `itemRef(identity)` | The ref callback that associates an element with that item and has it measured; stable for an item while its epoch lasts |
| `isRendered(identity)` | Whether the item is in this render's projection (for `aria-activedescendant`) |

`AxisController`: `scrollToPosition(position, {align?, behavior?})`, `scrollToIdentity(identity, options?)`, `scrollToOffset(offset, {behavior?})`, `scrollBy(delta, {behavior?})`, `scrollToEdge(edge, {behavior?})`, `invalidate(target, strength = 'stale')`, `reset()`, `reportSize(identity, size)`, `focus(identity | null)`, and the queries `getProjection()`, `positionAt(offset)`, `boundsOf(target)`, `identityOf(element)` on the committed state. Commands never run during render; a scroll request made before the axis is attached is carried out after it attaches.

`inspectAxis(controller)` returns `AxisOwnership`, 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-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`. 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/active-virtual test:browser`):

- 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-unreachable`) and never claimed as reached; the axis follows the offset the browser reached, also when the write moved nothing.
- 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.
