feat(native): scroll-behavior, scroll snap and scroll-initial-target - #56
Closed
mateo-m wants to merge 159 commits into
Closed
feat(native): scroll-behavior, scroll snap and scroll-initial-target#56mateo-m wants to merge 159 commits into
mateo-m wants to merge 159 commits into
Conversation
… onto effect layers
# Conflicts: # .github/workflows/ci.yml # examples/compile.ts # packages/native/src/custom_elements/anchored.rs # packages/native/src/custom_elements/code.rs # packages/native/src/custom_elements/diff.rs # packages/native/src/custom_elements/img.rs # packages/native/src/custom_elements/input.rs # packages/native/src/custom_elements/markdown.rs # packages/native/src/renderer.rs # packages/react/src/types/host.ts # zed
# Conflicts: # zed
# Conflicts: # packages/native/src/custom_elements/code.rs # packages/native/src/renderer/frame.rs # packages/native/src/style/resolve.rs # packages/react/src/testing.ts # packages/react/src/types/host.ts
# Conflicts: # packages/native/src/renderer/frame.rs # packages/react/src/reconciler/event-registry.ts
# Conflicts: # packages/react/src/testing.ts
# Conflicts: # packages/native/index.d.ts # packages/react/src/testing.ts # packages/react/src/types/host.ts
mateo-m
force-pushed
the
feat/scroll-properties
branch
from
September 4, 2026 14:59
a38dbf5 to
4239acf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #55 (feat/view-transitions).
What this adds
The rest of the
scroll-*properties.scrollbar-*and the physicalscroll-marginandscroll-paddingsides landed in #53.scroll-behavior: smoothturns a programmatic scroll into a 0.3s glide on the offset.scrollToandscrollIntoViewalso take abehaviorargument,auto,instantorsmooth, like the web option. A wheel move that takes the box away from the glide cancels it. The glide target clamps to the scrollable range.scroll-snap-typeon a box andscroll-snap-alignon its descendants snap the box when a scroll rests for 150ms.mandatoryalways snaps to the nearest position.proximity, the default, snaps within half a viewport.scroll-snap-stop: alwayson an area stops a long scroll that would pass over it. The snap area grows by thescroll-marginof the element and the viewport shrinks by thescroll-paddingof the box, as in CSS. A nested scroll box keeps its own descendants.scroll-initial-target: nearestscrolls the ancestors of an element to it once, on the first frame after it paints. A remount re-arms it.scroll-marginandscroll-paddingtake the logical variants:-block,-block-start,-block-end,-inline,-inline-startand-inline-end. GPUIX lays text out horizontally, left to right, so block is vertical and inline is horizontal.scroll-timeline(and the-nameand-axislonghands) on a scroll box publishes a timeline.animation-timelineon an element points itsmotionat one: a--name, or the anonymousscroll(nearest | self | root, axis). GPUIX has no@keyframes, so themotionprop is the keyframes source:initialis 0%,animateis 100%, andtransition.easebends the progress. Duration and delay play no part, because the scroll offset is the clock, and a timeline-driven frame asks for no animation frames.scroll-marker-group: before | afteron a scroll box adds a group of markers along that edge, one per snap area, painted like the scrollbar through a deferred draw. The marker of the area nearest the current offset paints stronger. A click on a marker scrolls to its area, with a glide whenscroll-behavior: smoothapplies.All state lives in thread locals on the render thread, next to
SCROLL_HANDLES, and steps once per frame inrender()against the bounds the last frame painted.Divergences from CSS, on purpose
animation-timeline: none(or a missing value) keeps the clock. CSS freezes the animation, but themotionprop always animates unless a timeline takes over.--nameno box declares holds the progress at 0.::scroll-markercontent, and every snap area gets one.v1 limits
mandatorydoes not fight a held drag.proximityreach is half a viewport. The spec leaves the number to the UA.Demo
The Scrollbars panel gains four sections: a carousel that centers the nearest card when a scroll rests, with a
stop: alwayscard and ascroll-marker-group: afterdot per card, a progress bar ananimation-timelinedrives, and a smooth box whose buttons glide the offset.Tests
packages/react/src/__tests__/scroll-motion.test.tsx: the glide and its exact end,instantbeating the style, a user move canceling the glide, a smoothscrollIntoView, mandatory snap, proximity giving up,stop: alwayscatching a long scroll, the logical variants, and the initial target. 9 tests. With the render hook disabled, 6 of the 9 fail, so the tests watch the change itself.packages/react/src/__tests__/scroll-timeline.test.tsx: a named timeline holding a motion at 0 with the clock 2s past its duration, then tracking the offset to 50% and 100%, an undeclared--nameholding 0, the anonymousscroll(nearest), and a marker click landing on its snap area. 3 tests. With the two render hooks disabled, all 3 fail.scroll_motion.rs,scroll_timeline.rsandscroll_marker.rsunit tests: the axis target picker, proximity reach, the always stop, both snap parsers, timeline name resolution (nearest ancestor, longhands over shorthand, anonymous forms), the marker edge word, the dot layout and the current-dot pick. 11 tests.Fixes after a manual test round (d1bd5dd)
scrollIntoViewtakes thecontaineroption from CSSOM View (csswg-drafts #9452)."nearest"scrolls only the nearest scroll box,"all", the default, scrolls every ancestor. The demo buttons use"nearest", so the page stays put.scrollbar-colornow resolvesvar()and the other cascade colours through the same scope as every other colour property. Before, the raw text went to the literal parser and the declaration dropped.middlebutton now computes the real middle offset (-383). The gutter note says to start withGPUIX_SCROLLBARS=classic, because overlay bars reserve nothing, as on the web.container(nearest leaves the outer box at 0, the default moves both), a sub-pixel-tail snap test, and a unit test for thevar()resolver. With the two native changes disabled, the new tests fail. Suites now: native 316, react 455, examples 56.Snap on the lift of the fingers (afb1840)
The snap took over only after the momentum ended, but a browser snaps a fling the moment the fingers lift. css-scroll-snap-1 calls a fling a scroll with an "intended direction and end position", and Chromium predicts the fling destination at gesture end and animates straight to the snap position for that landing (cc/input).
capture_scroll_wheelonInteractiveElement(mateo-m/zed@c5ecd998b5), becauseon_scroll_wheelruns after the box applies the delta.nativeSimulateScrollWheeltakes aphaseargument,"started","moved"or"ended".Scrollbar on the virtual list (b71400f)
The
A long listdemo had no bar, becausegpui::listscrolls itself and theScrollbarchild of the div path never reaches it.ScrollSource: aScrollHandlefor a div, aListStatefor a virtual list. AListScrollbarwrapper takes the layout of the list and defers the bar over it.scrollbar_drag_started), so a row that measures mid-drag cannot move the thumb under the mouse.The fling glide moves along Chromium's curve (ed89cf4)
A fast fling felt wrong after the snap-on-lift change: the glide was a fixed 300ms
easeInOutfrom the current offset, so a fast gesture stopped dead at the lift and then eased. Chromium does not ease. Its snap_fling_curve.cc moves the box along a geometric series: each 16ms frame covers 0.92 of what the frame before it covered, and the frame count comes from the distance. The glide starts fast, slows down like momentum, and a longer distance takes more time (about 0.2s for 20px, 0.6s for 280px, 1s for 2000px).Curveenum splits the glide paths. A programmatic smooth scroll keeps the 300mseaseInOut. A fling glide runs Chromium's series, with the frame count from Chromium's formula and a 3s cap.The momentum tail cannot undo the snap (c241a96)
The snap still felt wrong on a fast fling: the box landed on the snap position and then drifted off it, and the idle watcher eased it back. The cause is in the phase mapping: gpui reads only
phase()of an NSEvent, nevermomentumPhase(), so every macOS momentum event arrives asMovedwith no end marker, and the stream outlives a short glide by a second or more. The old code consumed the stream only while the glide ran. Chromium's snap_fling_controller.cc consumes scroll updates in both the active and the finished state, until the next gesture begin.The header hover spill is the demo's mask, not the renderer (1d52053)
Hovering the first row under the progressive-blur header smeared the hover colour up into the bar. Measured against headless Chrome with the same markup, GPUIX spills about a third of what CSS produces (rows above the hovered item, mean red delta: GPUIX 16.9 / 8.7 / 2.8 versus Chrome 45.0 / 25.0 / 7.4), and a row fully below the strip spills exactly zero, so the backdrop sampling clamps at the bounds correctly. The blur strip is 84px tall and its mask held about 75% strength where the first row rests, so the blur smeared the row's hover colour upward, as it would on the web. The demo's mask now fades from 25% instead of 50%, which cuts the spill to 4.0 / 1.0 / 1.0 and keeps the progressive blur on rows that scroll under the bar.
No snap while the fingers stay on the pad (20419d8)
Resting the fingers on the pad snapped the box after the 80ms idle window, mid-gesture. The web snaps at the end of the gesture, not during it: Chromium's snap-at-scroll-end runs on GestureScrollEnd, and the idle watcher here only exists for mouse wheels, which have no phases.
The snap-stop scan starts at the lift (90ee43a)
A fling after the fingers dragged past a
scroll-snap-stop: alwayscard glided backward to that card, and the consumed momentum stream then held the box there. Live logging (GPUIX_SNAP_DEBUG=1) caught it: a lift at -173 with a predicted landing of -916 picked -107, the always card the drag had already passed. The scan measured from the offset where the fingers went down, so the range wrongly covered the drag itself. Blink's fling strategy measures from the fling start.alwaysscan for a fling now starts at the offset of the lift, so it covers only positions the fling itself passes. A fling that does pass an always area still stops at it.GPUIX_SNAP_DEBUGenv var, cached in aLazyLock.The lift paints the frame that starts the glide (21572ea)
Even with the corrected target, most flings stopped dead at the lift. The step-level debug log showed the glide animation created with the right target and then never stepping: no frame painted after the lift. The glide moves one step per painted frame, and the consumed momentum events schedule no paint of their own, so nothing asked for the first frame. When a hover or the scrollbar fade happened to repaint, the glide woke up and ran, which is why the velocity sometimes survived.
window.refresh()on the lift and on each consumed momentum event, so the first frame always comes. After it, the render loop keeps requesting frames while the animation runs.GPUIX_SNAP_DEBUGgate: glide insert, each step with its elapsed time, the landing, and a cancel with the offsets that caused it.flush(), so it cannot lose a frame the way the real window does. Verified live on the trackpad. Suites: react 464.A regression test for the lost paint request (cbffbb7)
The harness could not catch the frozen-glide bug:
simulateScrollWheelparks the executor, which paints on demand, so a lost frame request never shows. The first attempt, a pump that delivers frames the way a display link would, still passed with the fix reverted, because the harness paints on every re-entry into GPUI. The test now asserts the contract that broke instead: the dispatch of the lift must leave the window marked for redraw.Window::needs_paint(), a test-support accessor for the redraw mark, next tosimulate_next_frame.simulateScrollWheelProbeon the test renderer dispatches a wheel event and returns the mark, read inside the window update, before any paint clears it.window.refresh()call reverted, the lift probe fails withexpected false to be true. Suites: native 316, react 465, examples 57.The masked backdrop blur follows the gradient (504a136)
Unrelated to scroll, found while testing this branch live. The demo header masks a
blur(16px)with a 63px gradient ramp, and the rows still went from sharp to fully blurred within a few pixels. A stripe probe measured the blur width per row. The oldprogressive_blursplit the mask range into three equal parts and crossfaded the σ1, σ4 and σ16 levels with a smoothstep per part. The bottom third of the ramp only produced blurs under σ1, which the eye cannot see, and the visible change bunched into about 16px.mask * radius. Each part of the mask range mixes the two levels around the target, with weights that match the variance of the target kernel. A mix of two Gaussian blurs adds their variances by the mix weights, so the blur width tracks the gradient across the whole ramp. Same change in the Metal, WGSL and HLSL shaders.expected 201.67 to be less than 135. Suites: native 316, react 466, examples 57.A true variable Gaussian blur for masked backdrops (114e1b8)
The variance crossfade above tracked the gradient well, but it still mixed three fixed blur levels. The masked path now blurs each pixel with its own Gaussian, like the variable blur filter of iOS: the sigma at a pixel is the mask value there times the full sigma.
expected 31 to be less than 30at the black end, and the middle rows sit far above their bounds. Suites: native 316, react 466, examples pass.The mask read clamps to the layer bounds, and the demo mask eases on a log scale (240e2ea)
Feedback on the live header: the blur looked at full width from 10% of the ramp down to 90%. Two causes, one in the engine and one in the demo mask.
expected 97 to be less than 15.The demo header replicates the iOS 26 soft scroll edge effect (c1cdbdf)
iOS 26 draws a "soft" edge effect under every bar: a subtle variable blur with a gradient mask, plus a gradient scrim whose colour comes from the content under the bar (sources: sebvidal.com "What's new in UIKit 26", createwithswift.com, Apple developer forums thread 796421). The demo header now has both layers.
backdropFilter: "blur(16px) saturate(1.8) contrast(1.05)". The colour matrix rides the same mask as the blur, so the saturation lift fades out with it, the way iOS materials do. The mask holds full width behind the bar (to 40%) and then halves the sigma at even distances.linear-gradient(to bottom, color-mix(in srgb, var(--color-panel) 72%, transparent), ease-in-out, transparent). The engine cannot read the colour of the content the way iOS does, so the scrim takes the panel colour, which tracks the theme.Two new tests guard what the header depends on. One renders
blur(16px) saturate(0)over solid red under a mask and checks the grey weight falls with the mask (with the saturate dropped, the green channel reads 0 against a bound of 30). One renders acolor-mix()stop inside a gradient stop list, where the commas inside the function must not split the list (with the gradient dropped, the pixel reads black against a bound of red 140). Suites: react 469, examples 57.Apple does not publish the exact blur width, mask stops, or scrim strength of the effect, so those numbers are tuned by eye, not measured.
Follow-up (34ca12a): the effect first ran 28px past the bar, so the first row at rest sat under the tail and read blurred. iOS ends the effect at the bar edge, and content at rest starts below it, sharp. Both layers now stop at the bar height, and the mask falls to zero at the bottom edge of the bar.
The frozen copy of a scrolled screen keeps its scroll offset (ae0f356)
A push from a scrolled list flashed the old screen back to the top for the length of the transition. The web does not do this: the old snapshot of a CSS view transition is an image of the painted pixels, so the scroll position is baked in. Our frozen copy repaints through the live pipeline instead, and the engine promises to reuse the scroll handle of a destroyed screen. A frame could build between the capture and the start call, after the update already removed the old screen from the tree. That frame saw no running transition and dropped the handle, so the copy painted from a fresh one at offset zero. The cleanup now also keeps the state of every id inside a pending capture. A regression test scrolls a screen by 120px, pushes, and checks the first row of the copy still paints 120 above the top. Without the fix it fails with
expected +0 to be close to -120. Suites: native 316, react 470, examples 57.Second cause, found live in the demo (0ee5ee7): React removes an old subtree in two steps. The commit unlinks it, and a later batch destroys each element. For a swap nested under a component, the destroy lands after the start call, so the old ids still sat in the element map, detached. The remap took them for live elements, gave the clone fresh ids, and with them a fresh scroll handle at offset zero. The first fix alone was not enough, because the kept handle hung on ids the copy no longer used. The remap now only treats an id as live when it still sits under the root. A regression test swaps a scrolled screen nested under a component and checks the first row of the copy holds its place at two frame times. With the fix reverted it fails, with the row bounds gone from the paint trackers. Suites: native 316, react 471, examples 57.
Scroll restoration on back navigation is app work, and the demo shows it (e97e873, d69a6e3)
iOS keeps the scroll position of a screen you go back to, because UIKit keeps the old view controller alive. The engine does not restore it, on purpose. A keyed remount starts at the top, the same as on the web, where restoration is app code. The demo now carries the pattern: the effect cleanup saves the offset through
getScrollOffsetwhen a screen unmounts, and the mount effect sets it back throughscrollTo.The pattern exposed a hole in
scrollTo(e97e873). A mount effect runs in the commit that creates the element, before the element ever painted, and ascrollToon an element without scroll state was silently lost. The engine now holds the offset and starts the scroll state there in the frame that creates it. On macOS the commit paints before the effect runs, so the state already exists and the held offset stays unused. It covers the backends that queue their scroll commands (Windows, Linux), which this machine cannot run.Evidence: two vitest tests guard the pattern (a
scrollTofrom a mount effect shows on the first painted frame, and a save-and-restore across a keyed remount lands at the saved offset), four Rust unit tests cover the held offset map, including the one-frame grace before a prune drops an entry whose element never arrived. An automation script drove the real demo app: scroll the Settings list to -60, push General, pop back, and the remounted screen read offset -60 with a screenshot to match. Suites: native 320, react 473, examples 57.Follow-up (74e0efa): iOS restores only the screen you go back to. A pop throws its screen away, so a later push of the same screen starts at the top. The demo saved the offset of both screens, so a re-pushed General kept its old position. The pop now deletes the saved offset of the screen it leaves. Verified live with the full sequence: scroll the root to -60, push, scroll General to -100, pop (root reads -60), push again (General reads 0). With the delete disabled, the same run reads -100.
The header no longer passes clicks through (bb4fb3c)
The three bar layers in the demo carried
pointerEvents: "none", so a row scrolled under the bar still took the click. iOS blocks that tap. The declarations are gone, and the engine default does the right thing: an absolute layer blocks the mouse but not the wheel, so the list under the bar still scrolls. Verified live: with the list at -60 a click on the covered General row does not push, a wheel over the bar moves the list from -60 to -100, and the row at rest still pushes. Suites: examples 57.The last row drops its border with
last:(6e0d3dd)The selector work from the base branch is now merged up the stack, so the demo can use it. Every
NavRowused to declareborderBottomWidth: 1inline, which put a line under the last row of the list. iOS has none there. The border now comes from the class channel,border-b last:border-b-0, because the style prop beats a class in every state and an inline border could not be taken back. Verified live: the list scrolled to its end shows no line under Developer and a line under every other row. Suites: native 308, react 482, examples 60.Moved from the fork
This PR was mateo-m#8.
The branch merged its parent branch after that one merged upstream main at 028f70a.
The history was rewritten once after the first push. It dropped the commits that reworded AGENTS.md, and the zed pin moved to the rewritten mateo-m/zed branches. No code changed in that rewrite.
On the merged head:
cargo test346 passed, 0 failed, the react suite 517 passed, 0 failed, the examples suite 71 passed, 0 failed.bun run buildin packages/native regeneratedindex.d.ts.Stacked on #55. The diff here includes #50, #51, #52, #53, #54, #55.
The zed submodule points at the mateo-m/zed fork until remorses/zed#5, #6 and #7 merge. Then a follow-up commit restores
.gitmodulesto remorses/zed on thegpuixbranch and pins the merged commit. The same GPUI stack is also open upstream as zed-industries/zed#63771, #63772 and #63773.