Optionaloptions: ViewportOptionsThe 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.
Readiness for a SINGLE axis — measured client + real content along that axis only. A composed viewport is ready when each of its two source controllers is ready on the axis it contributes, so an own/throwaway controller with only one meaningful axis doesn't block on the other.
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).
Observe el but update ONLY the given axis of the measured client (the other axis is left as-is).
Self-contained per call like observe. Returns an unobserve fn.
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.
Declare content for ONE axis only (the other axis's content is left untouched).
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.
The scroll store + writer + frame loop. Owns scroll position as a plain external store (not React state): one synchronous source of truth that React reads via
useSyncExternalStore.Remarks
Input sets a target (setTarget); a single rAF ticker advances current toward target each frame, emits to subscribers when current moves, and self-stops when settled. Input never triggers a render directly (it only writes a number), so input rate is decoupled from render rate and bursts/jank can't flood.
Animation is per-request (
setTarget(l, t, true)), standard smooth-scroll semantics: fixed-duration, time-based ease-out — every animated scroll takes the same wall-clock time (SCROLL_ANIMATION_MS) whether it's a PageDown or a goto across 1M rows, because long jumps are clamped: the glide starts within ANIMATION_CLAMP_VIEWPORTS viewports of the target (the rows in between are never painted). Deltas smaller than the ramp finish proportionally sooner. Time-based ⇒ frame-rate independent (identical feel at 60/120Hz). Un-animated targets apply in one step (the classic immediate scroll).Owns clamping too: setBounds takes the structural bounds (the grid pushes them) and setTarget clamps every target into them — the grid no longer clamps.