116. A scroll view is a clip, an offset and two extents
Date: 2026-08-18
Status
Accepted. Builds on ADR-0114 (the clip), ADR-0115 (the wheel) and ADR-0068 (the transform stack). Answers the geometry question ADR-0080 and ADR-0097 both stopped at.
Context
scroll is docs/core-widgets.md §1, and book/src/TODO.md named it three
times in three unrelated entries: a menu taller than the work area is clamped
and loses its bottom, a tab strip wider than its window overflows it, and
select over a realistic option list cannot be written at all. One missing
widget behind three pieces of blocked work is the definition of what to build
next.
It is also the widget that finally forces a question the toolkit has walked
around twice. A scroll view is arithmetic on two rectangles — how much
taller its content is than its viewport — and a widget cannot measure either
one. build and render both run before Yoga, which is ADR-0080’s finding;
ADR-0097 hit the same wall from the other side, wanting the distance between two
segments and finding that “a stylesheet cannot write it, because segments are as
wide as their labels; and a widget cannot compute it, because build/render run
before Yoga”.
Both of those ADRs found a way to avoid needing the number. scroll cannot: the
clamp is the widget.
Decision
Three nodes, each of them one idea
scroll the viewport. Clips, takes the wheel and the keys
└── scroll-content the moving box. Translated by the offset
└── … whatever was written inside
scroll itself is a composition node: stateful, styling nothing, holding
the offset. scroll as a CSS type is the viewport it builds, for exactly
ADR-0109’s reason — a
stateful widget that was also styled would put two scroll nodes in the cascade,
one inside the other, and every rule would apply to both.
The offset is a transform, not a layout
The content is moved with transform: translate, which costs no layout. §1.7
already refuses to transition width and height because “animating width/height
would run Yoga per frame”; an offset expressed as top or as a margin would run
Yoga over the whole subtree on every wheel notch to move a box that did not
change size.
It also makes hit testing come out right for free. The painter carries the accumulated matrix and the router inverts it (ADR-0068), so a row scrolled up by 200px is clicked where it looks, with nothing in the scroll view arranging that. And it makes the clip correct without a second mechanism: ADR-0114’s clip is intersected in the same walk, so content translated out of the viewport is cut at the viewport’s edge rather than painted over its neighbours.
flex-shrink: 0 on the content is the whole difference between a scroll view
and a squashed one. Yoga’s default shrinks a child that does not fit, so content
in a too-short viewport would be compressed to fit it and there would be no
overflow left to scroll — negotiated away before it was ever measured.
The two extents arrive on the event
This is the part that is new machinery rather than assembly.
PointerEvent and KeyEvent each gained bounds() and part(), both
Extents
— a width and a height, resolved by the router out of the hit-test snapshot the
last paint left behind. bounds() is the handling widget’s own box; part() is
the box named by the existing Handles.localPart(). A scroll view names
scroll-content, so it is handed its viewport and its content in the same event
and the clamp is a subtraction.
Three things make this the right shape rather than a special case:
- It reuses a vocabulary that already exists.
localPart()was built for a slider measuring a value along its track (ADR-0080). Nothing new is being named; what was missing was the other rectangle — a widget that pointedlocal()at a part had no way back to itself. - It works for the keyboard, which is why it is on
KeyEventtoo.PageDowncarries no position and needs both extents exactly as the wheel does. Nothing had ever put a size on a key event, and a scroll view that only worked for people with a mouse would fail §1’s “keyboard (PgUp/PgDn/Home/End/arrows when focused)” outright. - It stays one frame behind, and that is honest. These are measurements of the last paint, not predictions. A viewport resized this frame clamps against last frame’s height for one frame — invisible in practice, and the alternative is a widget that computes layout, which is the thing three ADRs have now declined to build.
At the edge it lets go
A wheel or a key is consumed only when something actually moved. At the top
of a list a further scroll up is left unconsumed and bubbles, which is §2.4’s
“inner scroller consumes until its edge, then chains to the ancestor” — obtained
from the router’s ordinary bubble path rather than from anything here knowing an
ancestor exists. PointerRouter.pointerWheel now reports whether anything
consumed the event, which keyPressed already did.
What a line is worth is a constant, and that is a gap
The wheel reports lines (ADR-0115) and a viewport moves pixels, so something has
to convert. That number is ScrollViewport.LINE, a constant of 20 — three of
which is the conventional notch, which is what the rest of the desktop does.
It should be a token. §3 says metrics ship as component-token defaults that
an application may override, and this is plainly one. It is not, because nothing
lets a widget read a resolved custom property: StyleResolver computes them for
var() substitution and ComputedStyle does not carry them, so a
--gb-scroll-line would be a number an author could set and no widget could
see. Shipping a token with no reader is worse than shipping the constant, because
the token would silently do nothing. The gap is in book/src/TODO.md; closing it
is the same change that would let any widget honour any metric.
Consequences
Three blocked pieces of work are unblocked: a popup can hold a scroll view, a tab
strip can scroll its overflow, and select over a realistic option list is now
ordinary widget work. None of them is done here — this is the viewport they were
waiting on, not their integration.
What is not built, and each is in book/src/TODO.md with what it waits on:
scrollbars of any kind (§2.4’s overlay thumb, its hover-widening, its idle fade,
its drag and its track-click paging), scrollIntoView, the “always show scroll
bars” 12px gutter, and affix. The scrollbars are the largest and want a second
commit rather than a bigger one; the rest each want a decision first.
Every widget in the catalog now sees bounds() on the events it handles, and
most should ignore it. The temptation it creates is real — a control that starts
laying itself out from last frame’s measurements is a control that lags its own
content — and the rule that keeps it honest is the one ADR-0080 already wrote:
read geometry to interpret an input, never to decide a size.
Nested same-axis scrollers are banned in the canon (§2.4) and nothing enforces the ban. Chaining works, so a nested pair behaves reasonably rather than badly; what is missing is the diagnostic that would tell an author they wrote something the design system rules out.