---
id: "packages/active-virtual"
title: "active-virtual"
description: "Layout-stable virtualization - a headless core that decides which items of a long, changing collection belong on screen, with a browser and a React adapter."
url: "/packages/active-virtual"
locale: "en"
revision: "bce79f331286e40b"
---

# active-virtual

`@workspace/active-virtual` decides which items of a long, mutable collection belong on screen,
where they go, and what keeps its place when items are measured, inserted, removed or reordered.
You render the items; the package renders nothing. A browser adapter observes the scroll container
and carries out the scroll writes, and a React hook binds one axis.

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

## What it gives you

- **Headless, one axis at a time.** No components and no styling. A grid is two axes on one scroll
  container.
- **Identity.** Positional (the position is the identity) or keyed (a key stays with its item
  through every change). Measurements belong to the identity.
- **Estimates now, measurements later.** Items are measured through their element, and measured
  again when they grow or shrink.
- **Anchoring.** Every geometry change keeps an anchor in place: an edge, a keyed item or the
  focused item. Older items loaded above the viewport do not move what is on screen.
- **Scroll requests.** To an item, an offset, a delta or an edge; the latest request wins, a request
  to an item converges as its surroundings are measured, and a user scroll takes over.
- **Platform.** Element or window scrolling, right to left on horizontal axes, server rendering with
  a seeded first window.

## What it does not do

- It renders nothing and styles nothing: the markup, the positioning and the accessibility of the
  list are yours.
- It does not fetch or page data; it virtualizes a collection whose count or keys you give it.
- Vertical writing modes and nested scroll containers are not proven.

## Entry points

| Import | Contents |
| --- | --- |
| `@workspace/active-virtual` | `createAxisStore`, `createScrollHost`, `findScrollHost`, and every type |
| `@workspace/active-virtual/react` | `useVirtualAxis`, `inspectAxis`, and the types of the hook |

## What your application provides

- **A scroll container** with a fixed or bounded size and `overflow: auto`, and the inner element
  of `projection.extent` that gives the content its length.
- **The rendering**: one element per projected item, positioned at `item.offset`, with the
  `itemRef` that lets the item be measured.
- **Accessibility**: a list of 10,000 rows has 10,000 rows for a screen reader only if you say so
  (`aria-rowcount`, `aria-setsize` and `aria-posinset`, or a different design); the package keeps
  focus in place when you enable `focusCustody`.
- **React 19** for `./react`; nothing for the root entry.

## Dependencies

None at run time. `react` and `react-dom` are optional peers, needed only for `./react`.
`active-grid` uses this package for its row and column virtualization.

## Next

- [Getting started](/packages/active-virtual/getting-started)
- [Anchoring and scrolling](/packages/active-virtual/anchoring-and-scrolling)
