Skip to content

feat(native): scroll-behavior, scroll snap and scroll-initial-target - #56

Closed
mateo-m wants to merge 159 commits into
remorses:mainfrom
mateo-m:feat/scroll-properties
Closed

feat(native): scroll-behavior, scroll snap and scroll-initial-target#56
mateo-m wants to merge 159 commits into
remorses:mainfrom
mateo-m:feat/scroll-properties

Conversation

@mateo-m

@mateo-m mateo-m commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #55 (feat/view-transitions).

What this adds

The rest of the scroll-* properties. scrollbar-* and the physical scroll-margin and scroll-padding sides landed in #53.

  • scroll-behavior: smooth turns a programmatic scroll into a 0.3s glide on the offset. scrollTo and scrollIntoView also take a behavior argument, auto, instant or smooth, 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-type on a box and scroll-snap-align on its descendants snap the box when a scroll rests for 150ms. mandatory always snaps to the nearest position. proximity, the default, snaps within half a viewport. scroll-snap-stop: always on an area stops a long scroll that would pass over it. The snap area grows by the scroll-margin of the element and the viewport shrinks by the scroll-padding of the box, as in CSS. A nested scroll box keeps its own descendants.
  • scroll-initial-target: nearest scrolls the ancestors of an element to it once, on the first frame after it paints. A remount re-arms it.
  • scroll-margin and scroll-padding take the logical variants: -block, -block-start, -block-end, -inline, -inline-start and -inline-end. GPUIX lays text out horizontally, left to right, so block is vertical and inline is horizontal.
  • scroll-timeline (and the -name and -axis longhands) on a scroll box publishes a timeline. animation-timeline on an element points its motion at one: a --name, or the anonymous scroll(nearest | self | root, axis). GPUIX has no @keyframes, so the motion prop is the keyframes source: initial is 0%, animate is 100%, and transition.ease bends 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 | after on 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 when scroll-behavior: smooth applies.

All state lives in thread locals on the render thread, next to SCROLL_HANDLES, and steps once per frame in render() 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 the motion prop always animates unless a timeline takes over.
  • A --name no box declares holds the progress at 0.
  • When several boxes declare one name, the nearest ancestor of the animated element wins, and the oldest element wins otherwise.
  • GPUIX has no pseudo-elements, so the markers are round dots rather than ::scroll-marker content, and every snap area gets one.

v1 limits

  • Snap fires when a scroll rests, not during the gesture, so mandatory does not fight a held drag.
  • The proximity reach 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: always card and a scroll-marker-group: after dot per card, a progress bar an animation-timeline drives, and a smooth box whose buttons glide the offset.

Tests

  • packages/react/src/__tests__/scroll-motion.test.tsx: the glide and its exact end, instant beating the style, a user move canceling the glide, a smooth scrollIntoView, mandatory snap, proximity giving up, stop: always catching 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 --name holding 0, the anonymous scroll(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.rs and scroll_marker.rs unit 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.
  • Full suites: packages/native 315 passed, packages/react 452 passed, examples 56 passed.

Fixes after a manual test round (d1bd5dd)

  • scrollIntoView takes the container option 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-color now resolves var() 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.
  • Snap fires sooner. The idle window is 80ms, and a step under half a pixel, the momentum tail of a wheel, no longer resets it.
  • Demo: the two-axes content is 2000px wide, because 900px fit inside the box on a wide window and the x axis had nothing to scroll. The middle button now computes the real middle offset (-383). The gutter note says to start with GPUIX_SCROLLBARS=classic, because overlay bars reserve nothing, as on the web.
  • Tests: a nested-scroller pair for container (nearest leaves the outer box at 0, the default moves both), a sub-pixel-tail snap test, and a unit test for the var() 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).

  • A snap container now takes a capture-phase wheel listener. It tracks the trackpad gesture through the touch phases.
  • On the lift it predicts the landing from the last 100ms of deltas, picks the snap target at that landing, and starts the glide at once.
  • It then consumes the OS momentum stream, so the momentum cannot cancel the glide. A mouse wheel has no touch phases and keeps the 80ms idle watcher.
  • The zed fork gains capture_scroll_wheel on InteractiveElement (mateo-m/zed@c5ecd998b5), because on_scroll_wheel runs after the box applies the delta.
  • nativeSimulateScrollWheel takes a phase argument, "started", "moved" or "ended".
  • Tests: a fling snaps at the lift to the predicted landing, and momentum events after the lift cannot cancel the glide. With the capture hook disabled, both fail. Suites: native 316, react 457, examples 57.

Scrollbar on the virtual list (b71400f)

The A long list demo had no bar, because gpui::list scrolls itself and the Scrollbar child of the div path never reaches it.

  • The bar now reads either source through one ScrollSource: a ScrollHandle for a div, a ListState for a virtual list. A ListScrollbar wrapper takes the layout of the list and defers the bar over it.
  • A thumb drag freezes the list's content height (scrollbar_drag_started), so a row that measures mid-drag cannot move the thumb under the mouse.
  • A classic bar reserves its gutter as right padding on the list. The zed fork now lays list rows out inside horizontal padding (mateo-m/zed@28523545f7), which it ignored before.
  • Blank rows during an aggressive fling are the unmounted-row stubs: gpui reaches rows React has not committed yet. The demo overdraw goes from 12 to 30 rows (about 1000px per side), which covers the frames a fling outruns React by.
  • Tests: a classic bar shrinks the rows by its 15px gutter, and a thumb drag lands the list at the exact offset. With the bar attach disabled, both fail. A screenshot shows the overlay thumb on a scrolled list. Suites: native 316, react 459, examples 57.

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 easeInOut from 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).

  • A Curve enum splits the glide paths. A programmatic smooth scroll keeps the 300ms easeInOut. A fling glide runs Chromium's series, with the frame count from Chromium's formula and a 3s cap.
  • A new touch on the box stops a running glide at once, as snap_fling_controller.cc clears its fling on a gesture begin.
  • Test: at 320ms after the lift the box still moves along the curve, where the old 300ms ease had already landed. With the curve reverted to the smooth glide, the test fails. Suites: native 316, react 460, examples 57.

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, never momentumPhase(), so every macOS momentum event arrives as Moved with 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 box now consumes the whole momentum stream after a fling, past the landing. A gap of more than 100ms between events ends the stream, so a later mouse wheel on the same box still scrolls.
  • Test: fifty momentum events every 16ms, well past the landing, leave the box at the snap position, and a wheel after a 200ms gap moves it. With the old consume-while-gliding condition restored, the test fails. Suites: native 316, react 461, examples 57.

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 idle watcher now skips a container whose gesture still has the fingers down. The lift picks the target, as before.
  • The landing prediction drops samples older than the 100ms velocity window, so a lift after a rest has zero velocity and snaps to the nearest position instead of a stale one.
  • Test: four pulls, then a 700ms rest with the fingers down, leave the box at -20, and the lift then snaps it to 0. With the fingers-down check disabled, the test fails. Suites: native 316, react 462, examples 57.

The snap-stop scan starts at the lift (90ee43a)

A fling after the fingers dragged past a scroll-snap-stop: always card 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.

  • The always scan 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.
  • The debug logging stays, behind the GPUIX_SNAP_DEBUG env var, cached in a LazyLock.
  • Test: a drag to -150 past an always row at -100, then a lift that predicts -800, must land at -800. With the old scan range restored, the test fails at -100, the exact live symptom. Suites: native 316, react 464, examples 57.

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.

  • The wheel capture handler now calls 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.
  • The step-level logging joined the GPUIX_SNAP_DEBUG gate: glide insert, each step with its elapsed time, the landing, and a cancel with the offsets that caused it.
  • No automated test: the test harness drives frames explicitly through 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: simulateScrollWheel parks 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.

  • gpui (fork, 451cdfa613): Window::needs_paint(), a test-support accessor for the redraw mark, next to simulate_next_frame.
  • simulateScrollWheelProbe on the test renderer dispatches a wheel event and returns the mark, read inside the window update, before any paint clears it.
  • The test probes the lift and one consumed momentum event, and both must ask for a paint. With the window.refresh() call reverted, the lift probe fails with expected 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 old progressive_blur split 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.

  • gpui (fork, 6a6f1b5df3): the target radius is now 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.
  • Measured on the demo mask: the blur now falls over 56px instead of 16px, and fine detail returns over 16px instead of snapping back.
  • Test: stripes under a strip with a linear mask. The contrast at three quarters of the mask must stay under 135. The old mapping gives 202, the new one 112. The contrast must also rise row by row with no flat span. With the old Metal mapping restored, the test fails with 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.

  • gpui (fork, 63148e4221): two blur passes, one per axis, run at full region size and read the mask per pixel. Pairs of taps merge into one linear read at their weighted centre, which halves the reads, and a cap of 96 taps bounds one huge sigma. The premask pass, the three-level pyramid, the float pipeline and the Catmull-Rom read-back are gone. Same change in the Metal, WGSL and HLSL backends, and the fixed unmasked blur is untouched.
  • Measured against theory: for the test stripes the ideal Gaussian gives contrast 20, 95 and 162 at masks 0.75, 0.5 and 0.375. The new path reads 23, 96.7 and 163.
  • The regression test now brackets that curve. With the crossfade build restored, the test fails with expected 31 to be less than 30 at 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.

  • Engine (fork, 308910136e): the blur passes also blur the pad around the layer. The pad above the strip got a mask of zero, stayed sharp in the first pass, and the vertical pass mixed that sharpness back into the top of the strip. A probe with the strip 40px below the window top read contrast 97 at the top row instead of 3. The mask read now clamps to the layer bounds. A new regression test covers it, and without the clamp it fails with expected 97 to be less than 15.
  • Demo: the eye reads blur on a log scale, so sigma 4 and sigma 16 both look like mush on small text, and a near-linear mask spends most of the strip on the same look. The header mask stops now halve the sigma at even distances (1, 0.5, 0.25, 0.125, 0.06, 0), so every span of the strip shows the same visible change. Suites: native 316, react 467, examples 57.

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.

  • Blur layer: 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.
  • Scrim layer: 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 a color-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 getScrollOffset when a screen unmounts, and the mount effect sets it back through scrollTo.

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 a scrollTo on 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 scrollTo from 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 NavRow used to declare borderBottomWidth: 1 inline, 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 test 346 passed, 0 failed, the react suite 517 passed, 0 failed, the examples suite 71 passed, 0 failed. bun run build in packages/native regenerated index.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 .gitmodules to remorses/zed on the gpuix branch and pins the merged commit. The same GPUI stack is also open upstream as zed-industries/zed#63771, #63772 and #63773.

mateo-m added 30 commits August 25, 2026 16:59
mateo-m added 12 commits August 29, 2026 19:12
# 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:
#	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 mateo-m closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant