---
id: "packages/active-virtual/anchoring-and-scrolling"
title: "Anchoring and scrolling"
description: "What keeps its place when the geometry changes, and how scroll requests converge."
url: "/packages/active-virtual/anchoring-and-scrolling"
locale: "en"
revision: "dfc4a51a2ff9684d"
---

# Anchoring and scrolling

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

## Anchoring

When geometry changes (a measurement, an insertion, a new estimate, a reset), one item keeps its
distance to the viewport, and the axis compensates the scroll offset.

| `anchor` | Keeps in place |
| --- | --- |
| `{kind: 'start-edge'}` (the default) | the first item starting at or after the viewport start |
| `{kind: 'end-edge'}` | the last item ending at or before the viewport end |
| `{kind: 'keyed', identity, fallback?}` | one item you name |
| `{kind: 'focus', fallback?}` | the item that holds focus |
| `{kind: 'none'}` | nothing |

`startEdge` and `endEdge` decide what happens when the viewport sits at an edge: `follow` stays at
that edge (a chat that follows new messages at the end), `preserve` keeps the item anchor. In the
example above `startEdge: 'preserve'` keeps the first visible message in place even at the very top.

When the anchored item is removed, the nearest surviving successor takes its place, else the nearest
predecessor, else the edge.

## Scroll requests

- `align`: `start`, `center`, `end`, or `auto` (no movement when the item is already fully visible).
- `behavior`: `instant` (default) or `smooth`.
- The latest request wins. A request to an item converges as its surroundings are measured; it gives
  up after a bounded number of writes and reports `intent-gave-up`.
- A user scroll (wheel, touch, the scrollbar) ends a request.
- A target the browser cannot reach is reported (`scroll-unreachable`) and never claimed as reached.

## Diagnostics

Pass `onDiagnostic` (or a `sink` to `createAxisStore`) to receive warnings such as
`intent-target-missing`. Without one, diagnostics are dropped; the package never writes to the
console.
