Scroll System
The scroll system lets a Layout node hold content larger than its own
rect, and lets the user pan that content via wheel, drag, or programmatic
API. It lives in ScrollController, ScrollState, and a handful of
methods on Layout (in Measure.cpp and Layout.cpp).
This document covers:
ScrollState— the pure state machine.ScrollController— the orchestration layer.- How scroll interacts with
measure/arrange/draw. - Wheel, drag, and inertia input.
- Scrollbar geometry and rendering.
- The "hover freeze" behavior.
For the user-facing API, see Layout §8 and Widgets §12 (ScrollView).
1. When a node is a scroll container
bool Layout::isScrollContainer() const {
return style_.currentStyle.overflowX != Overflow::Visible ||
style_.currentStyle.overflowY != Overflow::Visible;
}
A node is a scroll container if either axis has an overflow value
other than visible. hidden / scroll / auto all qualify — even
hidden, which doesn't show a scrollbar but still clips and can
technically hold scroll state.
The computed-value rule (see Style System §1.2)
means that setting overflow-x: hidden alone promotes overflow-y to
auto, making the node a scroll container on both axes. This is the
CSS behavior and is intentional.
For the purposes of the arrange algorithm, Stack type nodes are
excluded:
if (isScrollContainer() && type != LayoutType::Stack) { ... }
else { arrangeInto(space); ... }
A Stack has no main axis, so scrolling along "the flow" doesn't make
sense. A Stack with overflow: scroll behaves like a non-scroll
container with clipping — no scroll offset, no thumb. This is a
deliberate simplification; if you need scrolling in a Stack, use
Vertical or Horizontal.
2. ScrollState — the pure state
ScrollState is a POD struct with no knowledge of Layout:
struct ScrollState {
Vec2 offset{0.0f, 0.0f}; // current scroll position
Vec2 maxScroll{0.0f, 0.0f}; // maximum offset per axis
Vec2 velocity{0.0f, 0.0f}; // inertia velocity (px/s)
float dragStartMouseY{0.0f};
float dragStartOffsetY{0.0f};
float dragStartMouseX{0.0f};
float dragStartOffsetX{0.0f};
static constexpr float WHEEL_IMPULSE = 400.0f;
static constexpr float DECAY = 0.90f;
static constexpr float VELOCITY_MIN = 5.0f;
void clamp();
void reset();
void tickInertia(float dt);
};
2.1 offset and maxScroll
offsetis the current scroll position, always in[0, maxScroll].maxScrolliscontentSize - containerSize, per axis. Zero means no scrolling needed.
Both are Vec2 so x and y scroll independently.
2.2 velocity
The inertia velocity. When a wheel event arrives, it adds an impulse to
velocity rather than directly modifying offset. Each frame,
tickInertia integrates velocity into offset and decays it.
This produces the "smooth scroll" behavior: a wheel tick adds a velocity, the velocity is applied over several frames, and it decays exponentially. The result is a decelerating scroll that settles at the target.
2.3 Drag start snapshots
When a scrollbar thumb drag starts, the current mouse position and offset are saved. On each frame of the drag, the delta is computed and mapped to a new offset. This avoids drift from accumulated small increments.
2.4 Constants
WHEEL_IMPULSE = 400.0f— pixels of velocity added per wheel tick. A typical wheel tick is±1.0in Raylib'sGetMouseWheelMove, so a single tick produces±400 px/sof velocity.DECAY = 0.90f— velocity multiplier per 60Hz frame. After 1 second (60 frames), velocity is0.9^60 ≈ 0.0018of its initial value.VELOCITY_MIN = 5.0f— below this threshold, velocity is zeroed. Prevents a slow drift that never settles.
2.5 clamp
void clamp() {
offset.x = std::clamp(offset.x, 0.0f, maxScroll.x);
offset.y = std::clamp(offset.y, 0.0f, maxScroll.y);
}
Ensures offset is within [0, maxScroll]. Called after any
modification and at the end of tickInertia.
2.6 reset
void reset() {
offset = {0.0f, 0.0f};
maxScroll = {0.0f, 0.0f};
velocity = {0.0f, 0.0f};
}
Clears everything. Called when overflow changes (see §5.4).
2.7 tickInertia
void tickInertia(float dt) {
if (std::abs(velocity.x) > VELOCITY_MIN) {
offset.x += velocity.x * dt;
velocity.x *= std::pow(DECAY, dt * 60.0f);
} else {
velocity.x = 0.0f;
}
if (std::abs(velocity.y) > VELOCITY_MIN) {
offset.y += velocity.y * dt;
velocity.y *= std::pow(DECAY, dt * 60.0f);
} else {
velocity.y = 0.0f;
}
clamp();
}
Per-axis integration:
- If
|velocity| > VELOCITY_MIN, applyvelocity * dtto offset and decay velocity. - Otherwise, zero the velocity (stop).
- Clamp offset.
The decay is DECAY^(dt * 60) — the dt * 60 factor makes the decay
rate independent of frame rate. At 60 fps, dt = 1/60, so the exponent
is 1.0 and velocity is multiplied by 0.90. At 30 fps, dt = 1/30,
exponent is 2.0, velocity is multiplied by 0.81. Same decay per
second.
The offset integration doesn't use the same frame-rate normalization —
offset += velocity * dt is frame-rate correct because it's a direct
integration of velocity over time. The velocity itself is in px/s, so
velocity * dt is the pixels scrolled this frame.
2.8 A note on units
offset, maxScroll, and velocity are all in logical pixels.
contentSize and rect.width/height (which feed maxScroll) are also
logical pixels. The DPI scale is applied by the renderer, not by the
scroll system.
3. ScrollController — the orchestrator
ScrollController is a struct with:
struct ScrollController {
ScrollState state;
Vec2 contentSize{0, 0};
Vec2 appliedOffset{0, 0};
bool arrangeInitialized{false};
bool scrolling{false};
};
It's a member of every Layout node, but only used when
isScrollContainer() is true.
3.1 Fields
state— theScrollStatefrom §2.contentSize— the uncut content extent, set bymeasure. This is whatmaxScrollis computed from.appliedOffset— the offset that has already been applied to the children viatranslateSubtree. This can differ fromstate.offsetmomentarily:state.offsetis the target,appliedOffsetis the current. The difference is applied each frame inapplyOffsetDelta.arrangeInitialized— true after the firstarrange. Used to detect the "first frame" case.scrolling— true during a drag or inertia. Used to freeze hover (see §8).
3.2 Lifecycle methods
The controller's methods are called from Layout::arrange and
Layout::update:
arrange:
├── resetIfOverflowChanged (in update, actually)
├── needsFullArrange
├── computeArrangeSpace
├── arrangeInto
├── applyOffsetDelta
└── updateMaxScroll
update:
├── resetIfOverflowChanged
├── tickInput
│ ├── (wheel, drag, click-on-track)
│ ├── tickInertia
│ └── sets scrolling
draw:
└── drawScrollbar
├── verticalThumb
└── horizontalThumb
Note that resetIfOverflowChanged is called from update, not
arrange. This means the overflow check runs before measure, which is
correct — the overflow determines whether the node measures with an
unbounded main axis.
3.3 resetIfOverflowChanged
void ScrollController::resetIfOverflowChanged(const Layout& node) {
const auto& cur = node.style_.currentStyle;
const auto& prev = node.style_.lastInherited;
if (cur.overflowX != prev.overflowX || cur.overflowY != prev.overflowY)
reset();
}
Compares the current overflow to the last snapshot. If either axis
changed, resets the scroll state. This prevents "phantom scroll" when a
container stops being scrollable — the offset would otherwise stay at
a non-zero value and clamp to zero on the next updateMaxScroll, but
only after one frame of weird rendering.
Wait, this uses lastInherited for the overflow comparison. That's
the snapshot used by propagateInheritance, which has overflowX and
overflowY as fields. So the "last seen overflow" comes from that
snapshot. It's updated in propagateInheritance after the change is
processed. So:
- Frame N:
overflowchanges fromvisibletoscroll. updateruns.resetIfOverflowChangedsees the change (current differs fromlastInherited), resets.propagateInheritanceupdateslastInherited.overflowX/Y.- Frame N+1:
resetIfOverflowChangedsees no change, no reset.
But there's a subtlety: propagateInheritance only updates
lastInherited if something changed (it has an early-out for "no
change"). Since overflow is part of the snapshot, a change to overflow
triggers the update. So the sequence works.
Actually, looking more carefully at propagateInheritance:
bool changed =
lastInherited.hovered != node.isHoveredState() ||
// ... other flags ...
!(lastInherited.overflowX == currentStyle.overflowX) ||
!(lastInherited.overflowY == currentStyle.overflowY);
if (!changed) return;
// ... update snapshot ...
lastInherited.overflowX = currentStyle.overflowX;
lastInherited.overflowY = currentStyle.overflowY;
Wait, is overflow in the changed check? Let me look at the actual
source... From StyleResolver.cpp:
bool changed =
lastInherited.hovered != node.isHoveredState() ||
lastInherited.pressed != node.isPressedState() ||
lastInherited.focused != node.isFocusedState() ||
lastInherited.enabled != node.getEnabled() ||
lastInherited.checked != node.isCheckedState() ||
lastInherited.font != currentStyle.font ||
!(lastInherited.fontSize == currentStyle.fontSize) ||
!(lastInherited.color == currentStyle.color) ||
!(lastInherited.letterSpacing == currentStyle.letterSpacing) ||
!(lastInherited.textAlign == currentStyle.textAlign);
Overflow is not in the changed check, but it is updated at the
end:
lastInherited.overflowX = currentStyle.overflowX;
lastInherited.overflowY = currentStyle.overflowY;
This is a bug: lastInherited.overflowX is only updated when some
other property changed, because the early-return skips the update
if changed is false. So the overflow snapshot can be stale.
The impact: resetIfOverflowChanged might see a spurious change (if
lastInherited.overflowX hasn't been updated since some prior
overflow value), or might miss a change (if it was updated
"accidentally" by some other change). In practice, the reset is rare
enough that this doesn't cause visible problems. But it's a known
issue in the source.
For the rest of this document, I'll describe the intended behavior (reset when overflow changes), noting that the implementation has this edge case.
4. Scroll and measure
4.1 Unbounded main axis
In measure:
if (type == LayoutType::Vertical && calcStyle.overflowY != Overflow::Visible)
innerH = 1e9f;
if (type == LayoutType::Horizontal && calcStyle.overflowX != Overflow::Visible)
innerW = 1e9f;
The main axis of a scroll container has effectively infinite space for its children. This lets children compute their natural size without being constrained by the container — which is the whole point of scrolling.
The 1e9f sentinel is large enough to never be hit in practice, but
finite to avoid inf propagation in the arithmetic.
4.2 contentSize
At the end of measure:
scroll_.contentSize = contentSize;
contentSize is the accumulated size of the children (before the
container's own measuredSize is applied). For a Vertical container,
contentSize.y is the total height of the children plus gaps.
For a node that isn't a scroll container, contentSize is still stored
(the assignment is unconditional). It's just unused.
4.3 Measured size
The container's own measuredSize is computed normally from
contentSize (via the auto rules) and clamped by min-* / max-*.
So a scroll container with height: auto measures to fit its content
(no scrolling needed). To have an actual scroll, you need to give the
container an explicit height (or a grow context that fills the
available space).
Common patterns:
/* ScrollView fills its parent */
ScrollView { height: 100%; overflow-y: auto; }
/* ScrollView grows to fill leftover space */
.settings-body { grow: 1; overflow-y: auto; }
/* ScrollView with a fixed height */
.terminal { height: 30vh; overflow-y: scroll; }
4.4 The "unbounded main axis" and content size
A subtle point: the contentSize measured with an unbounded main axis
might differ from what the same content would measure with a bounded
main axis. This is because Text with wrap: true measures to fit
its parent's width — if the parent is a scroll container, the text
measures against the container's width (bounded, cross axis), and the
height is the wrapped height (unbounded, main axis).
For a vertical scroll container, this is correct: text wraps at the container's width, and the container's height accommodates the wrapped text. But if the container is horizontal (unbounded width), text won't wrap (it has all the width it needs) and will overflow horizontally.
This is a known asymmetry — it matches CSS's "scroll container measures its content with unbounded main axis" behavior. For most UIs, this is fine because scroll containers are vertical.
5. Scroll and arrange
5.1 The arrange flow for a scroll container
if (isScrollContainer() && type != LayoutType::Stack) {
if (scroll_.needsFullArrange(*this, positionChanged)) {
Rect arrangeSpace = scroll_.computeArrangeSpace(*this, space);
arrangeInto(arrangeSpace);
scroll_.arrangeInitialized = true;
scroll_.appliedOffset = {0.0f, 0.0f};
}
scroll_.applyOffsetDelta(*this);
scroll_.updateMaxScroll(*this, space);
} else {
arrangeInto(space);
scroll_.arrangeInitialized = true;
}
Three sub-steps:
- Full arrange (if needed): expand the arrange space and lay out children.
- Apply offset delta: shift the subtree by the scroll delta.
- Update max scroll: recompute
maxScrollfrom the newcontentSizeand the container'srect.
Non-scroll containers just call arrangeInto(space).
5.2 needsFullArrange
bool ScrollController::needsFullArrange(const Layout& node, bool positionChanged) const {
eturn !arrangeInitialized || node.internal_.subtreeDirty || positionChanged;
}
A full arrange is needed if:
- This is the first arrange (
!arrangeInitialized). - The subtree is dirty (something changed).
- The container moved (position changed).
If none of these, the arrange is "incremental" — only the offset is applied, no children are repositioned.
The rationale: since scroll works by translateSubtree, the children's
positions are relative to the "base" arrange (offset 0). When the
container moves but the subtree doesn't change, the base arrange is
already correct; only the offset needs to be reapplied.
Wait, that's not quite right. If the container moves, the children's
rect positions were computed for the old container position. The
translateSubtree accumulated the scroll offset, but the base positions
are in the old container's frame.
Actually, re-reading arrange:
const bool positionChanged = (space.x != rect.x || space.y != rect.y);
rect = space;
The rect is updated first. Then needsFullArrange decides whether to
re-run arrangeInto. If positionChanged is true, it re-arranges, so
the children's positions are recomputed for the new container rect.
If positionChanged is false (container didn't move), and the subtree
isn't dirty, the children keep their positions from the last arrange,
and only the scroll delta is applied. This is the fast path: a scroll
container whose content didn't change, scrolling smoothly over many
frames.
5.3 computeArrangeSpace
Rect ScrollController::computeArrangeSpace(const Layout& node, Rect space) const {
const auto& st = node.style_.currentStyle;
float pl = st.padding.left.resolveH(space.width, space.height);
float pr = st.padding.right.resolveH(space.width, space.height);
float pt = st.padding.top.resolveV(space.width, space.height);
float pb = st.padding.bottom.resolveV(space.width, space.height);
Rect arranged = space;
if (node.type == LayoutType::Vertical && st.overflowY != Overflow::Visible) {
float contentH = contentSize.y + pt + pb;
arranged.height = std::max(space.height, contentH);
} else if (node.type == LayoutType::Horizontal && st.overflowX != Overflow::Visible) {
float contentW = contentSize.x + pl + pr;
arranged.width = std::max(space.width, contentW);
}
return arranged;
}
The arrange space is the container's rect, expanded on the main axis if the content is larger. If the content fits (contentSize < container size), the space is unchanged.
Only the main axis is expanded — the cross axis stays at the container size. So a vertical scroll container's children are arranged within the container's width, regardless of their content height.
5.4 applyOffsetDelta
bool ScrollController::applyOffsetDelta(Layout& node) {
float dx = appliedOffset.x - state.offset.x;
float dy = appliedOffset.y - state.offset.y;
if (dx == 0.0f && dy == 0.0f) return false;
node.translateSubtree(dx, dy);
appliedOffset = state.offset;
return true;
}
Computes the delta between the applied offset (from last frame) and the current offset (this frame), and translates the subtree by that delta.
The delta is appliedOffset - state.offset:
- If we're scrolling down (state.offset increased), the delta is
negative, so children move up (negative y).
- If we're scrolling up, the delta is positive, children move down.
This is the correct sign: scrolling down means content moves up.
translateSubtree:
void Layout::translateSubtree(float dx, float dy) {
for (auto& c : children) {
c->rect.x += dx;
c->rect.y += dy;
c->translateSubtree(dx, dy);
}
}
Recursively shifts every descendant's rect. The node's own rect is
not shifted — the container stays fixed, only its content moves.
Note: absolute children are translated along with the flow children, so they scroll with the content. This is usually what you want (an absolute-positioned badge inside a card should scroll with the card).
Portals, however, are not translated. They position themselves in
screen space and must be independent of the scroll offset. The
translateSubtree method skips any child whose isPortal() returns
true, so neither the portal's own rect nor its descendants are
affected by scrolling.
5.5 updateMaxScroll
void ScrollController::updateMaxScroll(const Layout& node, Rect space) {
const auto& st = node.style_.currentStyle;
float pl = st.padding.left.resolveH(space.width, space.height);
float pr = st.padding.right.resolveH(space.width, space.height);
float pt = st.padding.top.resolveV(space.width, space.height);
float pb = st.padding.bottom.resolveV(space.width, space.height);
float contentW = contentSize.x + pl + pr;
float contentH = contentSize.y + pt + pb;
state.maxScroll.x = std::max(0.0f, contentW - node.rect.width);
state.maxScroll.y = std::max(0.0f, contentH - node.rect.height);
state.clamp();
}
maxScroll is contentSize (plus padding) - container size. If
negative, clamped to zero.
Called after applyOffsetDelta, so if maxScroll shrinks (content
shrank), the offset is re-clamped. This can cause a visible "snap back"
if content is removed while scrolled past the new max. That's the
expected behavior.
Padding is added to content size — this means a container with
padding: 8px and content of height 100 has contentH = 116, so
maxScroll.y = 116 - containerHeight. The padding is part of the
scrollable content, so you can scroll to see the padding too. This
matches CSS's box-sizing: content-box default (which ZenitUI uses —
padding is added outside the content size).
6. Scroll input
ScrollController::tickInput handles all user input for scrolling:
wheel, scrollbar drag, and track click.
6.1 The entry condition
void ScrollController::tickInput(Layout& node) {
const auto& st = node.style_.currentStyle;
if (!acceptsAnyInput(st) || node.type == LayoutType::Stack)
return;
// ...
}
Returns early if:
- The container doesn't accept input (
acceptsInputreturns true only forScrollandAuto, notHidden). - The type is
Stack.
So a Hidden overflow clips but doesn't accept scroll input. A Stack
with Scroll doesn't either (the arrange path already excluded it).
acceptsInput:
static bool acceptsInput(Overflow o) {
return o == Overflow::Scroll || o == Overflow::Auto;
}
6.2 The main scroll body
const bool canScrollY = state.maxScroll.y > 0.0f &&
st.overflowY != Overflow::Hidden;
const bool canScrollX = state.maxScroll.x > 0.0f &&
st.overflowX != Overflow::Hidden;
Each axis can scroll if maxScroll > 0 on that axis, and the overflow
isn't Hidden. So Hidden on one axis still allows scrolling on the
other (if it's Scroll or Auto).
The scrollbar drag, wheel, and track click all check these flags.
6.3 Drag start and track click
if (!node.hasPointerCapture()) {
if (pointer.pressed && ctx.topmostConsumer == &node) {
bool handled = false;
// Try vertical thumb
if (canScrollY) {
Rect thumb = verticalThumb(node);
if (thumb.contains(pointer.pos)) {
node.capturePointer();
state.dragStartMouseY = pointer.pos.y;
state.dragStartOffsetY = state.offset.y;
state.velocity = {0.0f, 0.0f};
handled = true;
}
}
// Try horizontal thumb
if (!handled && canScrollX) {
Rect thumb = horizontalThumb(node);
if (thumb.contains(pointer.pos)) {
node.capturePointer();
state.dragStartMouseX = pointer.pos.x;
state.dragStartOffsetX = state.offset.x;
state.velocity = {0.0f, 0.0f};
handled = true;
}
}
// Click on track: jump
if (!handled && canScrollY && !canScrollX) {
float ratio = (rect.height > 0.0f)
? ((pointer.pos.y - rect.y) / rect.height)
: 0.0f;
state.offset.y = std::clamp(
ratio * contentSize.y - rect.height * 0.5f,
0.0f, state.maxScroll.y);
}
else if (!handled && canScrollX && !canScrollY) {
float ratio = (rect.width > 0.0f)
? ((pointer.pos.x - rect.x) / rect.width)
: 0.0f;
state.offset.x = std::clamp(
ratio * contentSize.x - rect.width * 0.5f,
0.0f, state.maxScroll.x);
}
}
}
The logic, when the pointer is pressed on this container (topmost consumer):
- Check the vertical thumb rect. If the pointer is on it, start a vertical drag.
- Otherwise, check the horizontal thumb rect. If the pointer is on it, start a horizontal drag.
- Otherwise, if only one axis is scrollable, treat the click as a
"jump to position" on the track. The new offset is
ratio * contentSize - rect.size * 0.5, so the clicked point ends up in the middle of the container.
The thumb drag snapshots the current mouse position and offset. On subsequent frames (see §6.4), the delta is applied.
The track click is only enabled if exactly one axis is scrollable. If both are, the click on the track is ignored (to avoid ambiguity about which axis to jump on). In practice, both scrollable is rare.
6.4 Drag update
else if (pointer.down) {
if (canScrollY) {
float dy = pointer.pos.y - state.dragStartMouseY;
float ratio = (rect.height > 0.0f)
? (contentSize.y / rect.height)
: 1.0f;
state.offset.y = std::clamp(
state.dragStartOffsetY + dy * ratio,
0.0f, state.maxScroll.y);
}
if (canScrollX) {
float dx = pointer.pos.x - state.dragStartMouseX;
float ratio = (rect.width > 0.0f)
? (contentSize.x / rect.width)
: 1.0f;
state.offset.x = std::clamp(
state.dragStartOffsetX + dx * ratio,
0.0f, state.maxScroll.x);
}
}
While the pointer is down (and capture is set), the offset is computed from the drag start:
new_offset = drag_start_offset + (mouse_delta * ratio)
where ratio = contentSize / containerSize. This maps the mouse delta
in container pixels to the corresponding content pixels. If the content
is 3× the container, dragging the mouse by 10px scrolls the content by
30px. This matches the "thumb is a small version of the content"
intuition.
The dragStartOffset snapshot prevents drift: each frame's offset is
computed from the original, not from the previous frame's offset. This
keeps the drag stable.
6.5 Wheel
if (!ctx.wheelConsumedThisFrame && node.config.interactive &&
!node.hasPointerCapture() && rect.contains(pointer.pos))
{
if (pointer.wheelY != 0.0f) {
if (ctx.ctx.modifiers.shift && canScrollX) {
state.velocity.x -= pointer.wheelY * ScrollState::WHEEL_IMPULSE;
ctx.wheelConsumedThisFrame = true;
}
else if (!ctx.ctx.modifiers.shift && canScrollY) {
state.velocity.y -= pointer.wheelY * ScrollState::WHEEL_IMPULSE;
ctx.wheelConsumedThisFrame = true;
}
}
}
Conditions:
!wheelConsumedThisFrame— no node has consumed the wheel this frame.node.config.interactive— the node is interactive (default true for most).!hasPointerCapture— not currently in a capture (avoid double-handling).rect.contains(pointer.pos)— the pointer is over the container.
If all four are true and there's a wheel delta:
- With
Shift: scroll horizontally. - Without
Shift: scroll vertically.
The wheel adds an impulse to velocity, not to offset. So the actual
scroll happens through tickInertia, giving smooth deceleration.
The sign is negated: velocity.y -= wheelY * IMPULSE. A positive
wheelY (scroll down / away from user) decreases velocity, which
decreases offset, which scrolls up (content moves down). This matches
the convention where scrolling down moves content up... actually, it
depends on the backend's sign convention. Raylib's
GetMouseWheelMove() returns positive for scrolling up and negative
for scrolling down (by default). So wheelY = -1 for a "scroll down"
action. Then velocity.y -= (-1) * IMPULSE = velocity.y + IMPULSE,
increasing the offset, moving content up. Correct.
The wheelConsumedThisFrame flag prevents nested scroll containers
from both scrolling. The topmostConsumer (the innermost scroll
container) gets the event first (it's deeper in the tree, updated
first), consumes it, and outer containers skip.
6.6 state.tickInertia(ctx.dt) and scrolling
state.tickInertia(ctx.dt);
scrolling = node.hasPointerCapture() ||
std::abs(state.velocity.x) > ScrollState::VELOCITY_MIN ||
std::abs(state.velocity.y) > ScrollState::VELOCITY_MIN;
tickInertia applies velocity to offset and decays it. Then scrolling
is set to true if:
- The pointer is captured (drag in progress), OR
- There's still significant velocity (inertia in progress).
This flag is propagated up to Layout::update via
ancestorScrolling, and used by updateFlags to freeze hover (see §8).
7. Scrollbar geometry
The scrollbar thumb geometry is computed on demand by
verticalThumb(node) and horizontalThumb(node). Both are const
methods that return a Rect.
7.1 verticalThumb
Rect ScrollController::verticalThumb(const Layout& node) const {
const auto& st = node.style_.currentStyle;
const Rect& rect = node.rect;
const float TRACK_W = 6.0f;
const float MARGIN = 4.0f;
const bool wantH = isBarVisible(st.overflowX, state.maxScroll.x);
const float reservedH = wantH ? (TRACK_W + MARGIN) : 0.0f;
float trackH = std::max(0.0f, rect.height - MARGIN * 2.0f - reservedH);
float contentH = contentSize.y;
float viewportRatio = (contentH > 0.0f) ? (rect.height / contentH) : 1.0f;
float thumbH = std::min(trackH, std::max(30.0f, trackH * viewportRatio));
float scrollRatio = (state.maxScroll.y > 0.0f)
? (state.offset.y / state.maxScroll.y)
: 0.0f;
float available = std::max(0.0f, trackH - thumbH);
float thumbY = rect.y + MARGIN + std::clamp(scrollRatio * available, 0.0f, available);
return {rect.x + rect.width - TRACK_W - MARGIN, thumbY, TRACK_W, thumbH};
}
Steps:
- Check if the horizontal bar is visible. If so, the vertical track
shrinks by
TRACK_W + MARGINto make room for the horizontal bar's thickness at the bottom. trackH— the vertical space available for the track, after margins and the horizontal bar.viewportRatio = rect.height / contentH— the fraction of content visible at once. If content is 3× the container, this is1/3.thumbH— the thumb height,trackH * viewportRatio, clamped to minimum 30px and maximumtrackH.scrollRatio = offset.y / maxScroll.y— the scroll position, 0 at top, 1 at bottom.available = trackH - thumbH— the free space the thumb can move through.thumbY— the thumb's top edge, offset byscrollRatio * available.
The result is a rect on the right side of the container
(rect.x + rect.width - TRACK_W - MARGIN), with the computed height
and position.
7.2 horizontalThumb
Symmetric, with trackW, reservedV, and returning a rect on the
bottom of the container.
7.3 isBarVisible
static bool isBarVisible(Overflow o, float maxScroll) {
if (o == Overflow::Scroll) return true;
if (o == Overflow::Auto) return maxScroll > 0.0f;
return false;
}
Scroll→ always visible.Auto→ visible only whenmaxScroll > 0.Hidden/Visible→ never visible.
This is the CSS behavior.
7.4 Constant dimensions
The track width (TRACK_W = 6.0f) and margin (MARGIN = 4.0f) are
hardcoded. There's no .zstyle property for them — a future extension
would make them configurable.
8. Hover freeze
The most subtle scroll behavior is the "hover freeze": during a scroll, hover state is not recomputed for any node.
8.1 The propagation
In Layout::update:
const bool scrolling = ancestorScrolling || scroll_.scrolling;
input_.updateFlags(*this, selfBlocked, scrolling);
The scrolling flag is computed as "this node is scrolling OR an
ancestor is scrolling". It propagates down through the tree.
updateFlags:
if (scrolling) {
bool focusNow = ctx.hasFocus(&node);
if (focusNow != (node.state.focused != 0)) {
node.state.focused = focusNow ? 1 : 0;
node.internal_.pendingTransition = 1;
}
return;
}
During scroll, only state.focused is updated. state.hovered and state.pressed
are not touched — they keep their previous values.
8.2 Why
Without this, scrolling a list of 100 buttons would fire onHoverEnter
and onHoverExit on each button as it passed under the stationary
cursor. For a 60fps scroll, that's 100 events per second across the
list. Catastrophic for any hover-driven behavior (tooltips, animations).
With the freeze, the hover state is "frozen" until the scroll ends.
Then the normal update runs, and the correct node (the one under the
cursor after the scroll stops) gets onHoverEnter.
8.3 Which scroll counts
scrolling is true if:
- The node has pointer capture (a drag on its scrollbar).
- The node's velocity is above
VELOCITY_MIN(inertia in progress).
A node with a scroll but no active drag or inertia (e.g. just sitting
at a scroll position) doesn't set scrolling. So the hover freeze only
applies during active scrolling.
8.4 The freeze is global, not per-subtree
Because scrolling propagates down, all descendants of a scrolling
container are frozen. A node that's a sibling of the scroll container
is not frozen — the ancestorScrolling flag comes from the container's
own update, not from the root.
So a page with a scrollable sidebar: the sidebar and its content are frozen during scroll, but the main content area is not. Hover works normally in the main area.
8.5 Focus is still updated
Focus changes (from requestFocus during updateTree) are still
applied during the freeze. So if a node is focused and then a scroll
starts, the node's :focus styling is unaffected. This is correct —
focus isn't spatial.
9. Scrollbar rendering
ScrollController::drawScrollbar(node, parentOpacity) is called from
Layout::draw after content and children:
scroll_.drawScrollbar(*this, parentOpacity);
The implementation:
void ScrollController::drawScrollbar(const Layout& node, float parentOpacity) const {
const auto& st = node.style_.currentStyle;
const Rect& rect = node.rect;
const bool wantV = isBarVisible(st.overflowY, state.maxScroll.y);
const bool wantH = isBarVisible(st.overflowX, state.maxScroll.x);
if (!wantV && !wantH) return;
auto r = UIContext::get().renderer;
if (!r) return;
float op = st.opacity * parentOpacity;
if (op <= 0.001f) return;
const float MARGIN = 4.0f;
const float TRACK_W = 6.0f;
r->pushClip(rect);
const float reservedV = wantV ? (TRACK_W + MARGIN) : 0.0f;
const float reservedH = wantH ? (TRACK_W + MARGIN) : 0.0f;
if (wantV) {
Rect track = {
rect.x + rect.width - TRACK_W - MARGIN,
rect.y + MARGIN,
TRACK_W,
rect.height - MARGIN * 2 - reservedH
};
r->fillRoundedRect(track, TRACK_W * 0.5f,
Color{40, 40, 40, 180}.withAlpha(op));
Rect thumb = verticalThumb(node);
Color thumbColor = node.hasPointerCapture()
? Color{180, 180, 180, 255}
: Color{130, 130, 130, 220};
r->fillRoundedRect(thumb, TRACK_W * 0.5f, thumbColor.withAlpha(op));
}
if (wantH) {
// ... symmetric for horizontal ...
}
r->popClip();
}
The scrollbar is drawn:
- Only if at least one bar is visible.
- With clipping to the container's rect (so the bar doesn't spill).
- With a semi-transparent track color.
- With a lighter thumb color when the container has capture (i.e. during a drag).
The thumb rect is reused from verticalThumb / horizontalThumb, so
the drawing and the input hit-test always agree on where the thumb is.
9.1 Opacity
The scrollbar's opacity is st.opacity * parentOpacity. Note the
st.opacity — the container's own opacity. So a faded container has a
faded scrollbar. If the container has opacity: 0, the scrollbar is
not drawn (early-out at op <= 0.001f).
The scrollbar color doesn't respond to the container's color
property — it's hardcoded (dark gray track, lighter gray thumb). A
future extension could make it stylable.
9.2 Clip
The scrollbar is drawn inside a clip to rect. This prevents the
bar from extending beyond the container (e.g. if the track is longer
than the container due to some edge case). The clip is pushed and
popped around the scrollbar drawing.
9.3 Order relative to children
The scrollbar is drawn after children. So it's on top of everything the container draws, including any children. This is usually what you want (the bar shouldn't be hidden by the content).
10. ScrollView widget
ScrollView is a thin wrapper:
class ScrollView : public TLayout<ScrollView> {
public:
explicit ScrollView(LayoutType t = LayoutType::Vertical)
: TLayout<ScrollView>(t)
{
setInteractive(true);
setBlocksRaycast(true);
setStyleTag("ScrollView");
getInlineDefaults().overflowX = Overflow::Scroll;
getInlineDefaults().overflowY = Overflow::Scroll;
}
};
It sets:
interactive: true— so the wheel can be received.config.blocksRaycast = 1— so clicks don't pass through to nodes behind.styleTag: "ScrollView"— for.zstyletargeting.overflow-xandoverflow-ytoScrollin inline defaults.
The inline defaults mean a .zstyle rule can override them (e.g.
ScrollView { overflow-y: auto; overflow-x: hidden; }). The
inlineDefaults level is below CSS rules in the cascade, so any
stylesheet rule wins.
The default layout type is Vertical. Pass Horizontal explicitly for
horizontal scrolling.
10.1 Why not Stack?
A ScrollView with Stack would be excluded from the scroll arrange
path (see §1). So a Stack ScrollView would clip but not scroll.
The default Vertical is deliberate — it's the common case.
If you want a Stack with clipping only, use Panel with
overflow: hidden.
11. Common tasks
11.1 Adding a new scroll input source
For example, a gamepad joystick that scrolls. In the backend's
pollInputEvents or a custom extension to IPlatform, add the axis
value to PointerState. Then in ScrollController::tickInput, treat
it like a wheel event:
if (pointer.scrollAxis != 0.0f) {
state.velocity.y -= pointer.scrollAxis * AXIS_IMPULSE;
ctx.wheelConsumedThisFrame = true;
}
11.2 Smooth scroll to a target
The framework doesn't have a built-in "smooth scroll to". You can implement it in user code by setting velocity or offset over time:
// In a custom widget's onUpdate:
float target = 0.0f; // top
float current = getScrollY();
float delta = target - current;
if (std::abs(delta) < 0.5f) {
scrollToY(target);
} else {
scrollToY(current + delta * 0.15f); // exponential ease
}
Or use the velocity mechanism directly for a spring-like feel.
11.3 Custom scrollbar rendering
Override drawScrollbar if you need a custom look. It's a const
method, and the fields it reads are public:
struct CustomScrollView : public ScrollView {
void drawScrollbar(const Layout& node, float op) const override {
// ... custom drawing ...
}
};
Wait, drawScrollbar isn't virtual — it's a concrete method of
ScrollController, not Layout. To customize, you'd need to change
ScrollController or add a hook.
A cleaner approach: don't use the built-in scrollbar. Set overflow:
hidden to disable it (and disable scroll input), and implement your
own bar as a separate widget that reads getScrollY() / getMaxScrollY()
and calls scrollToY().
11.4 Custom inertia
The inertia parameters (WHEEL_IMPULSE, DECAY, VELOCITY_MIN) are
static constexpr in ScrollState. To change them, you'd need to
modify the header or shadow them with a subclass of ScrollState
(which isn't possible for Layout's member without changing the type).
In practice, the parameters are tuned for a "one wheel tick = a small scroll with medium deceleration" feel. If you need very different behavior, extend the API.
12. Pitfalls
contentSize is only meaningful for scroll containers.
For a non-scroll node, contentSize is set in measure but never used.
If you read it from a non-scroll node, it's the intrinsic size of the
children — usually the same as measuredSize minus padding.
maxScroll is clamped to zero.
If the content fits the container, maxScroll = 0 and no scroll input
has any effect. canScrollY / canScrollX correctly return false, so
the wheel / drag / track click are all no-ops.
The scrollbar is always drawn on top.
It's drawn after children in draw, so it's on top of everything the
container renders. If you have a child that draws over the right edge
of the container, the scrollbar is on top of it. This is intentional.
The scrollbar is not stylable.
Its width (6px), margin (4px), and colors are hardcoded. A future
extension could expose them via .zstyle (::scrollbar-track /
::scrollbar-thumb parts), but it's not implemented.
The hover freeze is not per-axis.
If either axis is scrolling, both are frozen. A node that's scrolling
vertically but not horizontally still has both state.hovered and
state.pressed frozen. In practice, scrolling is usually mono-axis, so
this doesn't matter.
Absolute children scroll with the content.
translateSubtree doesn't skip absolute children — they're shifted
along with the flow children. So an absolute child of a scroll
container moves when the container scrolls. This is usually correct
(an absolute badge inside a card should scroll with the card), but if
you want a fixed-position child inside a scroll container, this won't
work.
Workaround: put the fixed child as a sibling of the scroll container, positioned absolutely over it. Or use a portal (though portals have their own issues).
Portals inside scroll containers.
translateSubtree doesn't skip portals either (the check is commented
out). A portal's onUpdate recomputes its rect each frame, which
overrides the translation on the portal itself. But the portal's
children are translated, so they can be offset unexpectedly.
The fix: use portals outside the scroll container whenever possible.
If you need a portal inside a scroll container (e.g. a dropdown inside
a scrolled list), test carefully. The Dropdown widget does this and
works because its list portal is repopulated each frame based on the
trigger's position.
The wheelConsumedThisFrame flag is per-frame.
A wheel event consumed by one scroll container is consumed for all
containers in the same frame. A wheel event on a nested scroll
container is only seen by the innermost one. This is intentional
(matches most browsers' behavior), but if you want the outer container
to also react, you'd need to disable consumption.
scrollTo doesn't reset velocity.
If you call scrollTo(x, y) during inertia, the velocity is still
non-zero and will continue to move the offset on the next frame. To
stop inertia, set velocity = {0, 0}:
auto& scroll = getScrollState(); // not a public API
Actually, there's no public API to access the ScrollState. The
scrollTo methods only set offset. To stop inertia, you'd need to
add a method or extend the API.
In practice, scrollTo is used for programmatic jumps, and inertia is
usually not active when that happens. But it's a footgun.
scrollToBottom uses maxScroll from the last arrange.
If the content just changed (e.g. new items added), maxScroll might
be stale. The typical pattern is:
addChildren(...); // modifies tree, marks dirty
// next runFrame recomputes maxScroll
// then scrollToBottom works correctly
Calling scrollToBottom in the same frame as adding children might not
scroll all the way. In practice, this is rare.
The scrollbar thumb size has a minimum.
thumbH = std::min(trackH, std::max(30.0f, trackH * viewportRatio)) —
the minimum is 30px. For a very long content (e.g. 10000 items in a
300px container), the thumb would be 300 * (300 / 10000) = 9px
without the minimum, but it's clamped to 30px. This makes the thumb
usable but breaks the "thumb is a proportional representation"
intuition for very long content.
applyOffsetDelta translates by the delta, not the absolute offset.
This means the translation is cumulative. If some code resets the
subtree (e.g. by calling arrangeInto without resetting the applied
offset), the next applyOffsetDelta would double-count. The arrange
code handles this by resetting appliedOffset = {0, 0} after a full
arrange.
Scroll input requires config.interactive.
The wheel check has node.config.interactive &&. A non-interactive scroll
container (e.g. one with setInteractive(false)) won't respond to the
wheel. If you want a non-interactive container that scrolls, you'd
need to either make it interactive (and accept the click interception)
or add the input handling elsewhere.
Actually, ScrollView::ScrollView sets setInteractive(true). So the
default is interactive. But if you build a scrollable node without
using ScrollView (just a Layout with overflow: scroll), you need
to set interactive manually.
Stack with overflow: scroll doesn't scroll.
The arrange code skips scroll handling for Stack. The tickInput
code also skips it (node.type == LayoutType::Stack). So a Stack
with overflow: scroll clips but doesn't scroll. Use Vertical or
Horizontal for scrollable content.
The anchor for scrollToTop etc.
scrollToTop() calls scrollToY(0.0f). It assumes the "top" is
offset 0. This is correct for a vertical scroll, but the semantic
("show the first item") depends on the content layout. For a Stack
container (which is excluded from scrolling), this doesn't work.
The scrollbar is not drawn during the hover freeze. Actually, it is — the freeze only affects hover state, not rendering. The scrollbar is drawn every frame the container is visible and has a visible bar. During a drag, the thumb color changes (lighter) but the drawing continues.
Focus and scroll interact.
requestFocus on a node inside a scroll container doesn't
automatically scroll it into view. If you want focus to scroll the
container, you need to call scrollToY manually in your focus
handling. There's no scrollIntoView API.
The scroll controller state is per-node, not shared. Nested scroll containers have independent state. Scrolling the inner one doesn't affect the outer. This is the expected behavior, but it means there's no "synchronized scroll" or "linked scroll" feature.
ScrollState::reset clears maxScroll too.
This is important: after a reset, maxScroll = {0, 0}, so no scrolling
is possible until the next updateMaxScroll. This happens in the same
arrange call that follows, so the reset is transient. But if you
read maxScroll between the reset and the next updateMaxScroll,
it's zero.
The scrollbar's "capture" state is used to lighten the thumb.
node.hasPointerCapture() returns true during a scrollbar drag. The
thumb color is {180, 180, 180, 255} during capture, else
{130, 130, 130, 220}. This gives the visual feedback that the user is
dragging. But capture can also be held by other interactions (a slider
drag inside the container), so the scrollbar might lighten unexpectedly.
WHEEL_IMPULSE and the initial velocity.
A single wheel tick produces WHEEL_IMPULSE = 400 px/s of velocity.
With exponential decay 0.9^60 ≈ 0.0018 per second, the total distance
traveled is:
∫ v(t) dt = v0 * ∫ 0.9^(60t) dt = v0 / (60 * ln(1/0.9)) ≈ v0 / 6.3
For v0 = 400, total distance ≈ 63px. So one wheel tick scrolls about
63px, which is a reasonable "row or two" per tick.
If you want more aggressive scrolling, increase WHEEL_IMPULSE. The
DECAY controls the "feel" — lower decay (faster decay) means a
shorter, more snappy scroll; higher decay means a longer, smoother
scroll.