Anchoring and scrolling
What keeps its place when the geometry changes, and how scroll requests converge.
@workspace/active-virtual 2.1.0Status unknowncontent differs from the recorded release 2.1.0baseline ce081dc3
Older items above the viewport, without a jumpClient
// 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- (the default) | the first item starting at or after the viewport start |
{kind: 'end- | 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, orauto(no movement when the item is already fully visible).behavior:instant(default) orsmooth.- 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.