The raw cell-area client size = the measured client (uncapped by content). The grid reads this
for its own pane layout (min(client, content)); getMetrics caps it at content.
The combined viewport (position + measured sizes) as one identity-stable object — what a subscribed ScrollPane reads to size and place its scrollbars.
Convenience { left, top } of the current (rendered) scroll.
The current (rendered) scroll snapshot. The returned object is identity-stable until the scroll
changes, so it is safe as a useSyncExternalStore snapshot.
The scroll TARGET the ticker is easing toward (equals current when settled).
The zoom factor (1 = none). Part of the viewport shape so a shared/synced viewport carries it.
Whether the viewport has everything it needs to clamp + emit: a measured client (observer fired or manual sizes pushed) AND real content AND a positive client. The shared readiness signal that replaces each grid's "pending until ready" gate.
Whether the current (rendered) position has reached the target — no easing in progress.
START measuring: attach a ResizeObserver to el (the scroll-client box) so the viewport owns its
own client-size measurement and derives its clamp bounds internally. Returns an unobserve fn —
symmetric with subscribe/unsubscribe, and the ONLY teardown the viewport has (there is no
dispose; the rAF ticker is self-terminating and listeners each remove themselves).
Each call is SELF-CONTAINED (its own observer, its own detach), so multiple elements can observe the
same viewport concurrently — e.g. split peers each reporting the shared axis (they report the same
value by construction; last write wins). A caller tears down exactly what it set up, nothing else —
StrictMode mount→cleanup→mount is naturally symmetric. Guarded for absence exactly like the rAF
ticker: with no ResizeObserver (SSR / non-DOM tests) it resolves an immediate default client so
isReady still becomes true. null is a no-op (returns a no-op detach).
Relay a zoom-gesture step to the zoom's owner. The ScrollPane that runs the gesture calls this (as it calls setTarget for a scroll); the viewport does NOT apply it — zoom is a whole percent owned by a model the viewport knows nothing about — it hands the step to subscribeZoom listeners, who commit it and push the result back in through setZoom.
Set the clamp bounds (structural — total size, viewport size, frozen floors). Re-clamps the current target into the new bounds (e.g. the viewport shrank) and eases to it. Pushed by the grid; the controller, not the grid, owns the clamp.
Declare the content geometry (the host — a grid — pushes this): the total scroll extent plus the
frozen-pane floors (min*, the same minLeft/minTop a manual setBounds carries). Calling
it switches the viewport into DERIVED-bounds mode — it computes its own bounds from
content − client and no longer needs the host to push setBounds. Emits + re-derives.
Push the measured viewport sizes (the grid measures them where it pushes setBounds; a subscribed ScrollPane reads them via getViewport to size its scrollbar thumbs). Emits on change.
This is the MANUAL input path — used by a standalone ScrollPane driving arbitrary content (no grid to declare setContent). Calling it marks the viewport "manual provenance": the manually pushed sizes/bounds win over anything observe/setContent would derive.
Set the scroll TARGET (clamped) and start the ticker if needed.
Optionalanimate: boolean
Animation is a property of the REQUEST, not a global policy: true glides to the
target (fixed ~SCROLL_ANIMATION_MS ease-out, long jumps clamped to animate only the
arrival — a wheel notch, PageDown, or goto). Omit/false for position control (a thumb drag, the
touch scroller's per-frame writes, a model restore) — the classic immediate scroll.
true if the target changed (caller fires its immediate-scroll side effects), else
false (a no-op move — caller bails).
Set the zoom factor and notify subscribers on a change. Zoom is structural (it changes every
pane's geometry), so the grid re-renders when this emits. The scroll position (current + target) is
rescaled by the zoom ratio so the same cell stays anchored at the top-left and so scroll / zoom
(the zoom-independent base position) is preserved. Because zoom and scroll are mutated together
before the single emit, a listener that reads scroll on this emit sees screen px already consistent
with the new zoom.
With left/top (screen px for the NEW zoom) the position is set to them instead of the
ratio-rescale, current and target alike, in the same single emit — a focal-point zoom lands zoom
and scroll together, with no frame painted at the top-left-anchored position in between.
Optionalleft: numberOptionaltop: numberSubscribe to scroll changes. Driven by the rAF ticker, so the listener fires at most once per animation frame (and only while current is advancing toward target). Returns a remover.
Listen for zoom-gesture steps (requestZoom). Returns the unsubscribe.
Per-axis routing over (up to) two Viewports so one viewport can bind its horizontal and vertical axes to different controllers. Implements IViewport, so it is interchangeable with a plain Viewport anywhere a viewport is accepted (a grid, a ScrollPane).
Remarks
This is what lets grids share scroll WITHOUT sharing both axes:
_vis the shared viewport (followed),_his the header's own._vshared,_heach their own.Horizontal reads/writes route to
_h, vertical to_v. When both axes resolve to the SAME viewport (the common case: a standalone grid, or a main grid owning both) it collapses to 1:1 delegation. A FOLLOWED axis is read-only: nosetTarget/setBounds— its owner drives it.