git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit0f848439b1de794ed80a5377d4541b9c8af2ce41
parent016b9e8542
authorLucas Galante <[email protected]>
date2026-09-03 22:32
feat(scroll): smooth scrolling — shared ScrollMotion behind every scrolling widget

Wheel notches now glide toward their target with a frame-rate-independent
exponential approach, trackpad gestures track 1:1, and a flick coasts under
friction after the finger lifts. The model is widget::scroll_motion —
ScrollAxis/ScrollMotion with Bounds — and every offset-owning widget drives
its existing pub scroll_x/scroll_y through it: ScrollRegion, ScrollBox (and
TreeList via its box, which now ticks it), ParametersBg's pane fallback,
TextBox (both axes), Spreadsheet (its private velocity model replaced by the
shared one), and Graph's unbounded pan. Keyboard steps ride the same glide.

The runner now reads the SCTK axis source and stop flags it used to discard
and publishes a ScrollPhase (Wheel / Finger / FingerEnd) before each
dispatch; the finger-lift stop frame already reached apps as a zero
PixelDelta, so no app-facing type changed. Tunables live in input.kdl
(smooth_scroll, scroll_ease, kinetic_scroll, scroll_friction), app domain
then cce-ui.

Hosts keep reading the pub offsets as the DRAWN values; a host write to them
(thumb drag, auto-snap) is adopted via reconcile on the next wheel/tick
instead of fought. ScrollRegion::tick now returns true while a glide is
live, so hosts must tick their regions to see motion — the app sweep follows.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/backend/window_runner.rs          |  32 +-
 src/input.rs                          |   8 +-
 src/widget/container/parameters_bg.rs |  32 +-
 src/widget/container/scroll_box.rs    |  46 ++-
 src/widget/container/spreadsheet.rs   | 116 +++---
 src/widget/container/treelist.rs      |   5 +
 src/widget/display/graph.rs           |  46 ++-
 src/widget/input/text_box.rs          |  63 +++-
 src/widget/mod.rs                     |   2 +
 src/widget/scroll_motion.rs           | 691 ++++++++++++++++++++++++++++++++++
 src/widget/scroll_region.rs           | 138 +++++--
 11 files changed, 1027 insertions(+), 152 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index a503754..217b9fd 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -4403,6 +4403,8 @@ impl<A: Application> PointerHandler for EngineState<A> {
         let mut discrete_h = 0;
         let mut discrete_v = 0;
         let mut has_scroll = false;
+        let mut axis_source: Option<wl_pointer::AxisSource> = None;
+        let mut axis_stop = false;
         let (mut last_lx, mut last_ly) = (0.0f32, 0.0f32);
 
         // Forced mode: pointer positions arrive in the compositor's scale-1
@@ -4621,11 +4623,18 @@ impl<A: Application> PointerHandler for EngineState<A> {
                         self.redraw = true;
                     }
                 }
-                PointerEventKind::Axis { horizontal, vertical, .. } => {
+                PointerEventKind::Axis { horizontal, vertical, source, .. } => {
                     coalesced_h += horizontal.absolute;
                     coalesced_v += vertical.absolute;
                     discrete_h += horizontal.discrete;
                     discrete_v += vertical.discrete;
+                    // The source and the finger-lift stop ride in the same
+                    // frame as the deltas (or alone, for the lift): they
+                    // decide the smooth-scroll phase below.
+                    if source.is_some() {
+                        axis_source = *source;
+                    }
+                    axis_stop |= horizontal.stop || vertical.stop;
                     last_lx = lx;
                     last_ly = ly;
                     has_scroll = true;
@@ -4638,6 +4647,23 @@ impl<A: Application> PointerHandler for EngineState<A> {
             // `input { }` blocks); the compositor's global device scaling has
             // already been applied at the source.
             let factors = crate::input::scroll_factors();
+            // Smooth-scroll phase for this dispatch: a finger lift is a stop
+            // frame (no delta); finger/continuous sources track 1:1 and may
+            // fling on the lift; everything else is a wheel notch that glides.
+            let no_delta = coalesced_h == 0.0 && coalesced_v == 0.0 && discrete_h == 0 && discrete_v == 0;
+            let phase = if axis_stop && no_delta {
+                crate::widget::ScrollPhase::FingerEnd
+            } else if discrete_h == 0 && discrete_v == 0
+                && matches!(
+                    axis_source,
+                    None | Some(wl_pointer::AxisSource::Finger) | Some(wl_pointer::AxisSource::Continuous)
+                )
+            {
+                crate::widget::ScrollPhase::Finger
+            } else {
+                crate::widget::ScrollPhase::Wheel
+            };
+            crate::widget::scroll_motion::set_scroll_phase(phase);
             let delta = if discrete_h == 0 && discrete_v == 0 {
                 // Pixel scroll event from touchpad / smooth mouse
                 MouseScrollDelta::PixelDelta(Position {
@@ -4654,7 +4680,7 @@ impl<A: Application> PointerHandler for EngineState<A> {
                 static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
                 let t = T0.get_or_init(std::time::Instant::now).elapsed().as_millis();
                 eprintln!(
-                    "[scroll {t}ms] runner: coalesced=({coalesced_h:.2},{coalesced_v:.2}) discrete=({discrete_h},{discrete_v}) factors=(tp {:.2}, m {:.2}) -> {delta:?} at ({last_lx:.0},{last_ly:.0})",
+                    "[scroll {t}ms] runner: coalesced=({coalesced_h:.2},{coalesced_v:.2}) discrete=({discrete_h},{discrete_v}) source={axis_source:?} stop={axis_stop} phase={phase:?} factors=(tp {:.2}, m {:.2}) -> {delta:?} at ({last_lx:.0},{last_ly:.0})",
                     factors.trackpad, factors.mouse
                 );
             }
@@ -5118,6 +5144,8 @@ impl<A: Application> wayland_client::Dispatch<ZwpPointerGesturePinchV1, ()> for
                 if let Some(ctx) = state.inner.as_mut().unwrap().ui_context_mut() {
                     ctx.ctrl_pressed = true; // Force ctrl_pressed = true for the pinch event
                 }
+                // A synthesized delta, not a scroll gesture: no glide, no fling.
+                crate::widget::scroll_motion::set_scroll_phase(crate::widget::ScrollPhase::Wheel);
 
                 state.inner.as_mut().unwrap().handle_mouse_wheel(&delta, LogicalPosition::new(px, py), &mut rebuild);
 
diff --git a/src/input.rs b/src/input.rs
index c1823d8..ea8b063 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -20,7 +20,13 @@
 //     }
 //     cce-ui {
 //         open_search "ctrl+f"         // toolkit-wide widget defaults
-//         input { scroll_factor 1.0 }  // toolkit-wide scroll default
+//         input {
+//             scroll_factor 1.0        // toolkit-wide scroll default
+//             smooth_scroll true       // wheel notches glide (widget::scroll_motion)
+//             scroll_ease 12.0         // glide rate, 1/s
+//             kinetic_scroll true      // trackpad flicks coast after the lift
+//             scroll_friction 6.0      // coast decay, 1/s
+//         }
 //     }
 //     cce-files {
 //         open_search "/"              // per-app override of the cce-ui default
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 77c9dad..93ff438 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -74,6 +74,8 @@ pub struct ParametersBg {
     /// `tick`) — the shared [`crate::widget::ScrollbarActivity`], which was extracted FROM
     /// this widget so every app's plate-straddling scrollbar behaves the same way.
     activity: crate::widget::ScrollbarActivity,
+    /// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
+    scroll_motion: crate::widget::ScrollMotion,
     /// One code-editor column's shaped advance (monospace @12, the family/size
     /// the code rows draw in), recorded by [`Paint::prepare_text`]. The caret
     /// and click→column math read it; the hardcoded 7.2 px/col they used
@@ -176,6 +178,7 @@ impl ParametersBg {
             scrollbar_dragging: false,
             drag_offset_y: 0.0,
             activity: crate::widget::ScrollbarActivity::new(),
+            scroll_motion: crate::widget::ScrollMotion::new(),
         })
     }
 
@@ -1812,6 +1815,18 @@ impl Input for ParametersBg {
                 }
             }
         }
+        // The pane's own wheel glide / trackpad coast: adopt any host write to
+        // `scroll_y`, advance, and re-seat the rows when the offset moved.
+        self.scroll_motion.reconcile(0.0, self.scroll_y);
+        let pane_max = (self.content_h - self.rect.height).max(0.0);
+        if self.scroll_motion.tick(dt, crate::widget::Bounds::max(0.0), crate::widget::Bounds::max(pane_max)) {
+            self.scroll_y = self.scroll_motion.y.pos();
+            self.update_slider_rects();
+            changed = true;
+        }
+        if self.scroll_motion.is_animating() {
+            changed = true;
+        }
         // Decay the "recently scrolled" window; keep frames coming until it expires so the
         // scrollbar's sink behind the plate actually renders.
         if self.activity.holding() {
@@ -2653,15 +2668,16 @@ impl Input for ParametersBg {
                             if crate::scroll_debug() {
                                 eprintln!("[scroll] params: PANE-SCROLL fallback at ({px:.0},{py:.0})");
                             }
-                            let scroll_speed = 24.0;
-                            let dy = match delta {
-                                MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
-                                MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-                            };
-                            let old_scroll = self.scroll_y;
                             let max_scroll = (self.content_h - self.rect.height).max(0.0);
-                            self.scroll_y = (self.scroll_y + dy).clamp(0.0, max_scroll);
-                            if (self.scroll_y - old_scroll).abs() > 0.01 {
+                            self.scroll_motion.reconcile(0.0, self.scroll_y);
+                            let moved = self.scroll_motion.apply(
+                                delta,
+                                (crate::widget::LINE_PX, crate::widget::LINE_PX),
+                                crate::widget::Bounds::max(0.0),
+                                crate::widget::Bounds::max(max_scroll),
+                            );
+                            self.scroll_y = self.scroll_motion.y.pos();
+                            if moved {
                                 self.update_slider_rects();
                                 self.activity.bump();
                                 self.recompute_scrollbar_raised();
diff --git a/src/widget/container/scroll_box.rs b/src/widget/container/scroll_box.rs
index 4ea49f5..8f4d2c8 100644
--- a/src/widget/container/scroll_box.rs
+++ b/src/widget/container/scroll_box.rs
@@ -69,6 +69,8 @@ pub struct ScrollBox {
     pub show_background: bool,
     pub scrollbar_dragging: bool,
     pub drag_offset_y: f32,
+    /// Smooth-scroll driver behind `scroll_y` (see `ScrollRegion::motion`).
+    motion: crate::widget::scroll_motion::ScrollMotion,
 }
 
 impl ScrollBox {
@@ -85,6 +87,7 @@ impl ScrollBox {
             show_background: true,
             scrollbar_dragging: false,
             drag_offset_y: 0.0,
+            motion: crate::widget::scroll_motion::ScrollMotion::new(),
         }
     }
 
@@ -96,6 +99,16 @@ impl ScrollBox {
         self.viewport_offset_h = viewport_h - self.base.h;
         let max_scroll = (content_h - viewport_h).max(0.0);
         self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
+        self.motion.set_bounds(Bounds::max(0.0), Bounds::max(max_scroll));
+    }
+
+    fn bounds_y(&self) -> Bounds {
+        Bounds::max((self.content_h - self.viewport_h).max(0.0))
+    }
+
+    /// Whether a glide or coast is still moving the offset.
+    pub fn is_animating(&self) -> bool {
+        self.motion.is_animating()
     }
 
     pub fn hit_test_scrollbar(&self, px: f32, py: f32) -> bool {
@@ -300,22 +313,22 @@ impl ScrollBox {
         changed
     }
 
-    /// Legacy `WidgetHost` default parity (cce-test-interface's panel copy ticks it).
-    pub fn tick(&mut self, _dt: f32, _ctx: &mut UiContext) -> bool {
-        false
+    /// Per-frame smooth-scroll upkeep: adopts host writes to `scroll_y`,
+    /// advances a wheel glide or trackpad coast, and returns the repaint
+    /// signal (true while anything is still moving).
+    pub fn tick(&mut self, dt: f32, _ctx: &mut UiContext) -> bool {
+        self.motion.reconcile(0.0, self.scroll_y);
+        let moved = self.motion.tick(dt, Bounds::max(0.0), self.bounds_y());
+        self.scroll_y = self.motion.y.pos();
+        moved || self.motion.is_animating()
     }
 
     pub fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
         if self.hit_test(px, py, ctx) {
-            let scroll_speed = 24.0;
-            let dy = match delta {
-                MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
-                MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
-            };
-            let old_scroll = self.scroll_y;
-            let max_scroll = (self.content_h - self.viewport_h).max(0.0);
-            self.scroll_y = (self.scroll_y + dy).clamp(0.0, max_scroll);
-            (self.scroll_y - old_scroll).abs() > 0.01
+            self.motion.reconcile(0.0, self.scroll_y);
+            let changed = self.motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), self.bounds_y());
+            self.scroll_y = self.motion.y.pos();
+            changed
         } else {
             false
         }
@@ -480,11 +493,20 @@ mod tests {
         let mut dummy = UiContext::new();
         let changed = sb.mouse_wheel(&delta, 50.0, 50.0, &mut dummy);
         assert!(changed);
+        // The notch glides: run the motion out before reading the offset.
+        for _ in 0..1000 {
+            if !sb.is_animating() { break; }
+            sb.tick(1.0 / 60.0, &mut dummy);
+        }
         assert_eq!(sb.scroll_y, 48.0);
 
         // 4. Clamps at max scroll: 150 - 100 = 50
         let delta_large = MouseScrollDelta::LineDelta(0.0, -10.0);
         sb.mouse_wheel(&delta_large, 50.0, 50.0, &mut dummy);
+        for _ in 0..1000 {
+            if !sb.is_animating() { break; }
+            sb.tick(1.0 / 60.0, &mut dummy);
+        }
         assert_eq!(sb.scroll_y, 50.0);
 
         // 5. Test item draw coordinates (intersection contract: partially
diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index fe0daa4..2741033 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -13,6 +13,7 @@
 use crate::colors;
 use crate::scene::layout::Rect;
 use crate::scene::paint::PaintCtx;
+use crate::widget::scroll_motion::{scroll_settings, Bounds, ScrollMotion};
 use crate::widget::{
     Adapted, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton, MouseScrollDelta,
     NamedKey, Paint, SpreadsheetController,
@@ -42,13 +43,15 @@ pub struct Spreadsheet {
     /// sortable headers.
     header_hover_col: Option<usize>,
     scroll_y: f32,
-    scroll_velocity: f32,
     dragging_scrollbar: bool,
     drag_offset_y: f32,
     scrollbar_hovered: bool,
     scrollbar_thumb_hovered: bool,
     scroll_x: f32,
-    hscroll_velocity: f32,
+    /// Smooth-scroll driver behind `scroll_y`/`scroll_x`: wheel notches
+    /// glide, trackpad flicks coast (the widget's former velocity model,
+    /// now the toolkit-wide one).
+    motion: ScrollMotion,
     dragging_hscrollbar: bool,
     drag_offset_x: f32,
     hscrollbar_hovered: bool,
@@ -93,13 +96,12 @@ impl Spreadsheet {
             sort: None,
             header_hover_col: None,
             scroll_y: 0.0,
-            scroll_velocity: 0.0,
             dragging_scrollbar: false,
             drag_offset_y: 0.0,
             scrollbar_hovered: false,
             scrollbar_thumb_hovered: false,
             scroll_x: 0.0,
-            hscroll_velocity: 0.0,
+            motion: ScrollMotion::new(),
             dragging_hscrollbar: false,
             drag_offset_x: 0.0,
             hscrollbar_hovered: false,
@@ -514,19 +516,17 @@ impl Input for Spreadsheet {
             Event::MouseWheel { delta, .. } => {
                 // Delta signs follow the ScrollRegion/TextBox convention (negate the
                 // event delta); natural scroll is already applied upstream by libinput.
-                let (dx, dy) = match delta {
-                    MouseScrollDelta::LineDelta(x, y) => (-*x * ROW_H, -*y * ROW_H),
-                    MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
-                };
-                let mut used = false;
-                if dy.abs() > 0.0 && self.geom(ectx.rect).is_some() {
-                    self.scroll_velocity += dy * 12.0;
-                    used = true;
-                }
-                if dx.abs() > 0.0 && self.hgeom(ectx.rect).is_some() {
-                    self.hscroll_velocity += dx * 12.0;
-                    used = true;
-                }
+                let (dx, dy) = ScrollMotion::delta_px(delta, (ROW_H, ROW_H));
+                let by = self.geom(ectx.rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
+                let bx = self.hgeom(ectx.rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
+                // Claimed whenever the pointed axis can scroll at all (an
+                // overflowing table swallows its wheel), moved or not.
+                let used = (dy != 0.0 && by.hi > 0.0) || (dx != 0.0 && bx.hi > 0.0);
+                self.motion.reconcile(self.scroll_x, self.scroll_y);
+                let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
+                self.motion.apply_px(dx, dy, discrete, bx, by);
+                self.scroll_x = self.motion.x.pos();
+                self.scroll_y = self.motion.y.pos();
                 used
             }
             Event::KeyInput(key_event) => {
@@ -536,18 +536,23 @@ impl Input for Spreadsheet {
                 let Some(g) = self.geom(ectx.rect) else {
                     return false;
                 };
-                let old = g.scroll;
+                // Steps glide, and a held key accumulates from the glide's
+                // target rather than the offset drawn this frame.
+                self.motion.reconcile(self.scroll_x, self.scroll_y);
+                let by = Bounds::max(g.max_scroll);
+                let old = by.clamp(self.motion.y.target());
                 let new = match &key_event.logical_key {
-                    Key::Named(NamedKey::ArrowDown) => (old + ROW_H).clamp(0.0, g.max_scroll),
-                    Key::Named(NamedKey::ArrowUp) => (old - ROW_H).clamp(0.0, g.max_scroll),
-                    Key::Named(NamedKey::PageDown) => (old + g.visible_h).clamp(0.0, g.max_scroll),
-                    Key::Named(NamedKey::PageUp) => (old - g.visible_h).clamp(0.0, g.max_scroll),
+                    Key::Named(NamedKey::ArrowDown) => old + ROW_H,
+                    Key::Named(NamedKey::ArrowUp) => old - ROW_H,
+                    Key::Named(NamedKey::PageDown) => old + g.visible_h,
+                    Key::Named(NamedKey::PageUp) => old - g.visible_h,
                     Key::Named(NamedKey::Home) => 0.0,
                     Key::Named(NamedKey::End) => g.max_scroll,
                     _ => return false,
                 };
-                self.scroll_y = new;
-                (new - old).abs() > 0.01
+                let moved = self.motion.y.scroll_to(new, by, &scroll_settings());
+                self.scroll_y = self.motion.y.pos();
+                moved
             }
             _ => false,
         }
@@ -570,8 +575,8 @@ impl Input for Spreadsheet {
     }
 
     fn drag_begin(&mut self, px: f32, py: f32, rect: Rect) {
-        self.scroll_velocity = 0.0;
-        self.hscroll_velocity = 0.0;
+        // A grab or release cancels any glide/coast in flight.
+        self.motion = ScrollMotion::at(self.scroll_x, self.scroll_y);
         // The vertical bar owns the shared bottom-right corner (it was here
         // first); the horizontal bar takes what's left of the bottom band.
         if let Some(g) = self.geom(rect) {
@@ -610,7 +615,7 @@ impl Input for Spreadsheet {
 
     fn drag_update(&mut self, px: f32, py: f32, rect: Rect) -> bool {
         if self.dragging_scrollbar {
-            self.scroll_velocity = 0.0;
+            self.motion.y.jump_to(self.scroll_y);
             if let Some(g) = self.geom(rect) {
                 let old = g.scroll;
                 self.scroll_to_thumb(&g, py - self.drag_offset_y);
@@ -619,7 +624,7 @@ impl Input for Spreadsheet {
             return false;
         }
         if self.dragging_hscrollbar {
-            self.hscroll_velocity = 0.0;
+            self.motion.x.jump_to(self.scroll_x);
             if let Some(g) = self.hgeom(rect) {
                 let old = g.scroll;
                 self.hscroll_to_thumb(&g, px - self.drag_offset_x);
@@ -632,48 +637,23 @@ impl Input for Spreadsheet {
     fn drag_end(&mut self) {
         self.dragging_scrollbar = false;
         self.dragging_hscrollbar = false;
-        self.scroll_velocity = 0.0;
-        self.hscroll_velocity = 0.0;
+        // A grab or release cancels any glide/coast in flight.
+        self.motion = ScrollMotion::at(self.scroll_x, self.scroll_y);
     }
 
-    // --- Inertial scroll: the wheel only sets velocity; each frame integrates and decays it.
+    // --- Smooth scroll: the wheel/finger feed the shared motion; each frame advances it.
 
     fn tick(&mut self, dt: f32, rect: Rect) -> bool {
-        let mut moved = false;
-        let friction = 8.0;
-        if self.scroll_velocity.abs() > 0.01 {
-            let content_h = self.rows.len() as f32 * ROW_H;
-            let visible_h = (rect.height - HEADER_H).max(0.0);
-            let max_scroll = (content_h - visible_h).max(0.0);
-            let old = self.scroll_y;
-
-            self.scroll_y = (self.scroll_y + self.scroll_velocity * dt).clamp(0.0, max_scroll);
-
-            // Decelerate with friction (exponential decay); stop dead at the bounds or below
-            // the motion threshold.
-            self.scroll_velocity *= (-friction * dt).exp();
-            if self.scroll_y == 0.0 || self.scroll_y == max_scroll {
-                self.scroll_velocity = 0.0;
-            }
-            if self.scroll_velocity.abs() < 5.0 {
-                self.scroll_velocity = 0.0;
-            }
-            moved |= (self.scroll_y - old).abs() > 0.01;
-        }
-        if self.hscroll_velocity.abs() > 0.01 {
-            let max_scroll = self.hgeom(rect).map_or(0.0, |g| g.max_scroll);
-            let old = self.scroll_x;
-            self.scroll_x = (self.scroll_x + self.hscroll_velocity * dt).clamp(0.0, max_scroll);
-            self.hscroll_velocity *= (-friction * dt).exp();
-            if self.scroll_x == 0.0 || self.scroll_x == max_scroll {
-                self.hscroll_velocity = 0.0;
-            }
-            if self.hscroll_velocity.abs() < 5.0 {
-                self.hscroll_velocity = 0.0;
-            }
-            moved |= (self.scroll_x - old).abs() > 0.01;
+        self.motion.reconcile(self.scroll_x, self.scroll_y);
+        if !self.motion.is_animating() {
+            return false;
         }
-        moved
+        let by = self.geom(rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
+        let bx = self.hgeom(rect).map_or(Bounds::max(0.0), |g| Bounds::max(g.max_scroll));
+        let moved = self.motion.tick(dt, bx, by);
+        self.scroll_x = self.motion.x.pos();
+        self.scroll_y = self.motion.y.pos();
+        moved || self.motion.is_animating()
     }
 
     fn wants_tick(&self) -> bool {
@@ -847,6 +827,12 @@ mod tests {
             alt: false,
         };
         assert!(s.keyboard_input(&end, &mut ctx));
+        // The key glides: run the motion out before reading the offset.
+        for _ in 0..1000 {
+            if !Input::tick(&mut *s.inner_mut(), 1.0 / 60.0, rect) {
+                break;
+            }
+        }
         let g = s.inner().geom(rect).unwrap();
         assert_eq!(g.scroll, g.max_scroll);
 
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index cf67c20..8b3576e 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -1197,6 +1197,11 @@ impl Input for TreeList {
             }
         }
 
+        // The list's own glide/coast (wheel notches and trackpad flicks land
+        // in the ScrollBox; only its tick moves the drawn offset).
+        if self.scroll_box.tick(dt, ui) {
+            changed = true;
+        }
         if (self.scroll_box.scroll_y - self.last_scroll_y).abs() > 0.01 {
             self.last_scroll_y = self.scroll_box.scroll_y;
             self.scrollbar_activity_timer = 1.0;
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 3ae0152..a14580e 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -60,6 +60,9 @@ pub struct Graph {
     grid_size_y: f32,
     grid_origin_x: f32,
     grid_origin_y: f32,
+    /// Smooth-scroll driver behind the pan origin: notches glide, a trackpad
+    /// flick coasts across the unbounded canvas.
+    pan_motion: crate::widget::ScrollMotion,
     skipped_row_h: f32,
     skipped_col_w: f32,
     nodes: Vec<GraphNode>,
@@ -129,6 +132,7 @@ impl Graph {
             grid_size_y,
             grid_origin_x: 0.0,
             grid_origin_y: 0.0,
+            pan_motion: crate::widget::ScrollMotion::new(),
             skipped_row_h: grid_size_y / 2.0,
             skipped_col_w: grid_size_x / 2.0,
             nodes: Vec::new(),
@@ -761,6 +765,23 @@ impl Paint for Graph {
 }
 
 impl Input for Graph {
+    /// Advances the pan glide/coast behind the grid origin. Idle is a no-op.
+    fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
+        self.pan_motion.reconcile(self.grid_origin_x, self.grid_origin_y);
+        if !self.pan_motion.is_animating() {
+            return false;
+        }
+        let free = crate::widget::Bounds::UNBOUNDED;
+        let moved = self.pan_motion.tick(dt, free, free);
+        self.grid_origin_x = self.pan_motion.x.pos();
+        self.grid_origin_y = self.pan_motion.y.pos();
+        moved || self.pan_motion.is_animating()
+    }
+
+    fn wants_tick(&self) -> bool {
+        true
+    }
+
     /// Legacy hit test excluded the right/bottom edges.
     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
         x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
@@ -860,18 +881,19 @@ impl Input for Graph {
                         }
                     }
                 } else {
-                    match delta {
-                        MouseScrollDelta::LineDelta(x, y) => {
-                            self.grid_origin_x += *x * 15.0;
-                            self.grid_origin_y += *y * 15.0;
-                            true
-                        }
-                        MouseScrollDelta::PixelDelta(pos) => {
-                            self.grid_origin_x += pos.x as f32;
-                            self.grid_origin_y += pos.y as f32;
-                            true
-                        }
-                    }
+                    // Pan: the origin moves WITH the wheel sign (no negation —
+                    // the canvas follows the gesture), across an unbounded plane.
+                    let (dx, dy) = match delta {
+                        MouseScrollDelta::LineDelta(x, y) => (*x * 15.0, *y * 15.0),
+                        MouseScrollDelta::PixelDelta(pos) => (pos.x as f32, pos.y as f32),
+                    };
+                    let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
+                    let free = crate::widget::Bounds::UNBOUNDED;
+                    self.pan_motion.reconcile(self.grid_origin_x, self.grid_origin_y);
+                    self.pan_motion.apply_px(dx, dy, discrete, free, free);
+                    self.grid_origin_x = self.pan_motion.x.pos();
+                    self.grid_origin_y = self.pan_motion.y.pos();
+                    true
                 }
             }
             Event::KeyInput(key_event) => {
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index f977d69..c1737a3 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -81,6 +81,11 @@ pub struct TextBox {
     pub history: History<TextEditorState>,
     pub scroll_y: f32,
     pub scroll_x: f32,
+    /// Smooth-scroll driver behind `scroll_x`/`scroll_y` (see `ScrollRegion::motion`).
+    scroll_motion: ScrollMotion,
+    /// `(max_x, max_y)` as of the last wheel — the glide's bounds, so `tick`
+    /// never re-wraps the text just to re-derive them.
+    scroll_max: (f32, f32),
     default_font_size: f32,
     default_font_family: String,
     pub cursor_x_offset: f32,
@@ -143,6 +148,8 @@ impl TextBox {
             history: History::new(),
             scroll_y: 0.0,
             scroll_x: 0.0,
+            scroll_motion: ScrollMotion::new(),
+            scroll_max: (0.0, 0.0),
             default_font_size: style_size,
             default_font_family: style_family,
             cursor_x_offset: 0.0,
@@ -927,25 +934,22 @@ impl TextBox {
             (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
         };
 
-        let mut changed = false;
-
+        let mut dy_px = 0.0;
+        let mut max_scroll_y = 0.0;
         if self.multiline {
             let content_h = lines.len() as f32 * line_height;
-            let max_scroll = (content_h - (self.rect.height - 16.0)).max(0.0);
-            let scroll_amt = match *delta {
+            max_scroll_y = (content_h - (self.rect.height - 16.0)).max(0.0);
+            dy_px = match *delta {
                 MouseScrollDelta::LineDelta(_, dy) => -dy * line_height * 2.0,
                 MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
             };
-            let old_scroll = self.scroll_y;
-            self.scroll_y = (self.scroll_y + scroll_amt).clamp(0.0, max_scroll);
-            if old_scroll != self.scroll_y {
-                changed = true;
-            }
         }
 
+        let mut dx_px = 0.0;
+        let mut max_scroll_x = 0.0;
         if !self.line_wrap_enabled() {
             let content_w = self.content_width(&lines);
-            let max_scroll_x = (content_w - (self.rect.width - 16.0)).max(0.0);
+            max_scroll_x = (content_w - (self.rect.width - 16.0)).max(0.0);
             let natural = crate::layout::touchpad_natural_scroll();
             let scroll_amt_x = match *delta {
                 MouseScrollDelta::LineDelta(dx, dy) => {
@@ -966,13 +970,23 @@ impl TextBox {
                     }
                 }
             };
-            let old_scroll_x = self.scroll_x;
-            self.scroll_x = (self.scroll_x + scroll_amt_x).clamp(0.0, max_scroll_x);
-            if old_scroll_x != self.scroll_x {
-                changed = true;
-            }
+            dx_px = scroll_amt_x;
         }
 
+        // Both axes through the shared motion: notches glide, finger tracks
+        // 1:1, a flick coasts. The pub offsets are the drawn values.
+        self.scroll_max = (max_scroll_x, max_scroll_y);
+        self.scroll_motion.reconcile(self.scroll_x, self.scroll_y);
+        let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
+        let changed = self.scroll_motion.apply_px(
+            dx_px,
+            dy_px,
+            discrete,
+            Bounds::max(max_scroll_x),
+            Bounds::max(max_scroll_y),
+        );
+        self.scroll_x = self.scroll_motion.x.pos();
+        self.scroll_y = self.scroll_motion.y.pos();
         changed
     }
 
@@ -1607,6 +1621,25 @@ impl Paint for TextBox {
 }
 
 impl Input for TextBox {
+    /// Advances the wheel glide / trackpad coast behind the scroll offsets.
+    /// Cheap when idle (the common case); `wants_tick` is unconditional
+    /// because it is sampled once at registration.
+    fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
+        self.scroll_motion.reconcile(self.scroll_x, self.scroll_y);
+        if !self.scroll_motion.is_animating() {
+            return false;
+        }
+        let (mx, my) = self.scroll_max;
+        let moved = self.scroll_motion.tick(dt, Bounds::max(mx), Bounds::max(my));
+        self.scroll_x = self.scroll_motion.x.pos();
+        self.scroll_y = self.scroll_motion.y.pos();
+        moved || self.scroll_motion.is_animating()
+    }
+
+    fn wants_tick(&self) -> bool {
+        true
+    }
+
     fn tracks_base_focus(&self) -> bool {
         false
     }
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 094a28d..083131a 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -539,11 +539,13 @@ pub mod editor;
 pub mod layout_helper;
 pub mod model;
 pub mod scroll_region;
+pub mod scroll_motion;
  
 // Re-exports
 pub use self::editor::TextEditorState;
 pub use self::layout_helper::{ColumnLayout, RowLayout};
 pub use self::scroll_region::{ScrollRegion, ScrollbarActivity};
+pub use self::scroll_motion::{Bounds, ScrollAxis, ScrollMotion, ScrollPhase, ScrollSettings, LINE_PX};
 pub use self::model::{Adapted, EventCtx, Input, Layout, Paint};
 pub use self::core::{Widget, focus, hover_animation, clipboard, context_menu, clear_widget_references};
 pub use self::core::focus::link_parent_child;
diff --git a/src/widget/scroll_motion.rs b/src/widget/scroll_motion.rs
new file mode 100644
index 0000000..aaf50a9
--- /dev/null
+++ b/src/widget/scroll_motion.rs
@@ -0,0 +1,691 @@
+//! Smooth scrolling — the one place wheel/trackpad deltas turn into an
+//! animated scroll offset, shared by every scrolling widget and available to
+//! apps that own their offsets themselves.
+//!
+//! Three input regimes, decided by [`ScrollPhase`] (which the runner sets from
+//! the Wayland `axis_source` / `axis_stop` events before each dispatch):
+//!
+//! - **Wheel** (discrete clicks, `LineDelta`): each notch moves the *target*;
+//!   the drawn offset eases toward it with a frame-rate-independent
+//!   exponential approach. Rapid notches accumulate into one glide instead of
+//!   a staircase.
+//! - **Finger** (trackpad, `PixelDelta` with a finger/continuous source): the
+//!   offset follows the gesture 1:1 — nothing is smoother than the hand — while
+//!   a velocity estimate is kept.
+//! - **FingerEnd** (`axis_stop`, the finger lift): the estimated velocity
+//!   carries the offset on, decaying under friction, so a flick coasts.
+//!
+//! The model is one [`ScrollAxis`] per direction, paired as a
+//! [`ScrollMotion`]. A host keeps its existing `scroll_y: f32` field as the
+//! *drawn* offset and lets the motion drive it: feed events with
+//! [`ScrollMotion::apply`], advance with [`ScrollMotion::tick`] once per
+//! frame, and copy [`ScrollAxis::pos`] out. Hosts that also write the field
+//! directly (drag, keyboard, auto-snap to a selection) call
+//! [`ScrollMotion::reconcile`] first so the motion adopts the external write
+//! instead of fighting it.
+//!
+//! Everything is pure time-based math — no clock, GPU, or loop — except the
+//! finger-velocity estimate, which timestamps events with `Instant`.
+//!
+//! Tunables come from `input.kdl` (`<app>` domain, then `cce-ui`):
+//!
+//! ```text
+//! cce-ui {
+//!     input {
+//!         smooth_scroll true      // wheel notches glide (false = instant)
+//!         scroll_ease 12.0        // wheel glide rate, 1/s (higher = snappier)
+//!         kinetic_scroll true     // trackpad flicks coast after the lift
+//!         scroll_friction 6.0     // coast decay, 1/s (higher = shorter coast)
+//!     }
+//! }
+//! ```
+
+use std::sync::atomic::{AtomicU8, Ordering};
+use std::time::Instant;
+
+use crate::widget::MouseScrollDelta;
+
+/// Which stage of a scroll gesture the current wheel event belongs to. The
+/// runner sets this from the Wayland axis source/stop before dispatching;
+/// consumers read it through [`current_scroll_phase`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ScrollPhase {
+    /// A discrete wheel click (or a synthesized delta with no gesture).
+    Wheel,
+    /// Continuous finger/trackpad motion; the gesture is still in progress.
+    Finger,
+    /// The finger lifted (`axis_stop`) — the event carries no delta.
+    FingerEnd,
+}
+
+static PHASE: AtomicU8 = AtomicU8::new(0);
+
+/// Publish the phase of the wheel event about to be dispatched. Runner-side.
+pub fn set_scroll_phase(phase: ScrollPhase) {
+    PHASE.store(phase as u8, Ordering::Relaxed);
+}
+
+/// The phase of the wheel event currently being dispatched. Outside a
+/// dispatch it reports the last one, which only matters for hosts that
+/// synthesize their own wheel events (they get `Wheel` semantics unless a
+/// real gesture is mid-flight).
+pub fn current_scroll_phase() -> ScrollPhase {
+    match PHASE.load(Ordering::Relaxed) {
+        1 => ScrollPhase::Finger,
+        2 => ScrollPhase::FingerEnd,
+        _ => ScrollPhase::Wheel,
+    }
+}
+
+/// Pixels one wheel notch moves a list — the toolkit's line unit, shared so
+/// every scrolling host steps the same distance per click.
+pub const LINE_PX: f32 = 24.0;
+
+/// Exponential-approach convergence: the drawn offset snaps to its target
+/// once within this many pixels.
+const SNAP_PX: f32 = 0.5;
+/// A coast below this speed (px/s) stops.
+const COAST_STOP_SPEED: f32 = 5.0;
+/// A finger held still this long (seconds) before lifting yields no fling.
+const FLING_STALE_S: f32 = 0.08;
+/// Velocity-estimate blend per finger event (new sample weight).
+const VEL_BLEND: f32 = 0.35;
+
+/// The process-wide smooth-scroll tunables, resolved once from `input.kdl`.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct ScrollSettings {
+    /// Wheel notches glide toward their target (false = the legacy jump).
+    pub smooth: bool,
+    /// Wheel glide rate, 1/s. 12 reaches 95% of a notch in ~250ms.
+    pub ease_rate: f32,
+    /// Trackpad flicks coast after the lift.
+    pub kinetic: bool,
+    /// Coast decay, 1/s. 6 halves the speed every ~115ms.
+    pub friction: f32,
+}
+
+impl Default for ScrollSettings {
+    fn default() -> Self {
+        Self { smooth: true, ease_rate: 12.0, kinetic: true, friction: 6.0 }
+    }
+}
+
+static SETTINGS: std::sync::OnceLock<ScrollSettings> = std::sync::OnceLock::new();
+
+/// This app's effective smooth-scroll settings (`<app>` → `cce-ui` → defaults).
+pub fn scroll_settings() -> ScrollSettings {
+    *SETTINGS.get_or_init(|| {
+        let input = crate::input::cached();
+        let app = crate::config::get_app_name().unwrap_or_default();
+        let d = ScrollSettings::default();
+        let flag = |key: &str, default: bool| {
+            input
+                .resolve_setting(&app, "", key)
+                .and_then(crate::input::SettingValue::as_bool)
+                .unwrap_or(default)
+        };
+        let rate = |key: &str, default: f32| {
+            input
+                .resolve_setting(&app, "", key)
+                .and_then(crate::input::SettingValue::as_f64)
+                .map(|v| v as f32)
+                .filter(|v| v.is_finite() && *v > 0.0)
+                .unwrap_or(default)
+        };
+        ScrollSettings {
+            smooth: flag("smooth_scroll", d.smooth),
+            ease_rate: rate("scroll_ease", d.ease_rate),
+            kinetic: flag("kinetic_scroll", d.kinetic),
+            friction: rate("scroll_friction", d.friction),
+        }
+    })
+}
+
+/// The range an axis may occupy. Lists are `0..=max_scroll`; a canvas that
+/// pans freely is [`Bounds::UNBOUNDED`].
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Bounds {
+    pub lo: f32,
+    pub hi: f32,
+}
+
+impl Bounds {
+    pub const UNBOUNDED: Bounds = Bounds { lo: f32::NEG_INFINITY, hi: f32::INFINITY };
+
+    /// `0..=max`, with a negative `max` (content shorter than the viewport)
+    /// collapsing to `0..=0`.
+    pub fn max(max: f32) -> Bounds {
+        Bounds { lo: 0.0, hi: max.max(0.0) }
+    }
+
+    pub fn clamp(&self, v: f32) -> f32 {
+        v.clamp(self.lo, self.hi)
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Mode {
+    Idle,
+    /// Wheel glide: `pos` chases `target`.
+    Easing,
+    /// Finger down: `pos` is the gesture, `vel` is being estimated.
+    Tracking,
+    /// Finger lifted: `pos` integrates `vel` under friction.
+    Coasting,
+}
+
+/// One scroll direction: the drawn offset, where it is heading, and how fast.
+#[derive(Debug, Clone, Copy)]
+pub struct ScrollAxis {
+    pos: f32,
+    target: f32,
+    vel: f32,
+    mode: Mode,
+}
+
+impl Default for ScrollAxis {
+    fn default() -> Self {
+        Self::new(0.0)
+    }
+}
+
+impl ScrollAxis {
+    pub fn new(pos: f32) -> Self {
+        Self { pos, target: pos, vel: 0.0, mode: Mode::Idle }
+    }
+
+    /// The offset to draw at this frame.
+    pub fn pos(&self) -> f32 {
+        self.pos
+    }
+
+    /// Where the offset is heading (equals `pos` unless a wheel glide is in
+    /// flight). Hosts that virtualize rows may prefetch toward this.
+    pub fn target(&self) -> f32 {
+        self.target
+    }
+
+    /// Current speed in px/s (finger estimate while tracking, coast speed after).
+    pub fn velocity(&self) -> f32 {
+        self.vel
+    }
+
+    /// Whether `tick` will still move the offset.
+    pub fn is_animating(&self) -> bool {
+        matches!(self.mode, Mode::Easing | Mode::Coasting)
+    }
+
+    /// Snap to `pos` and cancel any motion.
+    pub fn jump_to(&mut self, pos: f32) {
+        self.pos = pos;
+        self.target = pos;
+        self.vel = 0.0;
+        self.mode = Mode::Idle;
+    }
+
+    /// Adopt a host-side write to the drawn offset (a scrollbar drag, an
+    /// auto-snap to a selection): if the host's value differs from ours, the
+    /// host moved it and any motion in flight is abandoned.
+    pub fn reconcile(&mut self, host_pos: f32) {
+        if (host_pos - self.pos).abs() > 1e-3 {
+            self.jump_to(host_pos);
+        }
+    }
+
+    /// Re-clamp after the content or viewport changed size.
+    pub fn set_bounds(&mut self, b: Bounds) {
+        let p = b.clamp(self.pos);
+        let t = b.clamp(self.target);
+        if p != self.pos || t != self.target {
+            self.pos = p;
+            self.target = t;
+            if p == t && self.mode == Mode::Easing {
+                self.mode = Mode::Idle;
+            }
+        }
+    }
+
+    /// A wheel notch worth `delta` pixels: move the target and glide there
+    /// (or jump, with smoothing off). Returns whether anything will move.
+    pub fn wheel(&mut self, delta: f32, b: Bounds, s: &ScrollSettings) -> bool {
+        if delta == 0.0 {
+            return false;
+        }
+        // A wheel click during a coast redirects it rather than adding to a
+        // fling the user has visibly abandoned.
+        if self.mode == Mode::Coasting {
+            self.vel = 0.0;
+            self.target = self.pos;
+        }
+        let new_target = b.clamp(self.target + delta);
+        if (new_target - self.target).abs() < 1e-3 {
+            // Already heading there (or pinned at the bound): nothing new moves.
+            return false;
+        }
+        self.target = new_target;
+        if s.smooth {
+            self.mode = Mode::Easing;
+        } else {
+            self.pos = new_target;
+            self.mode = Mode::Idle;
+        }
+        true
+    }
+
+    /// Finger motion worth `delta` pixels, `dt` seconds after the previous
+    /// finger event: the offset follows 1:1 and the velocity estimate blends
+    /// in this sample. Returns whether the offset moved.
+    pub fn finger(&mut self, delta: f32, dt: f32, b: Bounds) -> bool {
+        let old = self.pos;
+        let new_pos = b.clamp(self.pos + delta);
+        self.pos = new_pos;
+        self.target = new_pos;
+        self.mode = Mode::Tracking;
+        let applied = new_pos - old;
+        let sample = applied / dt.clamp(0.004, 0.1);
+        self.vel = if applied == 0.0 && delta != 0.0 {
+            // Pinned against a bound: no fling into the wall.
+            0.0
+        } else {
+            self.vel * (1.0 - VEL_BLEND) + sample * VEL_BLEND
+        };
+        (self.pos - old).abs() > 1e-3
+    }
+
+    /// The finger lifted `since_last` seconds after its last motion: coast on
+    /// the estimated velocity (or stop dead, with kinetic scrolling off or a
+    /// finger that had come to rest). Returns whether a coast started.
+    pub fn finger_end(&mut self, since_last: f32, s: &ScrollSettings) -> bool {
+        if self.mode != Mode::Tracking {
+            return false;
+        }
+        if !s.kinetic || since_last > FLING_STALE_S || self.vel.abs() < COAST_STOP_SPEED {
+            self.vel = 0.0;
+            self.mode = Mode::Idle;
+            return false;
+        }
+        self.mode = Mode::Coasting;
+        true
+    }
+
+    /// Glide to an absolute offset (keyboard paging, "scroll to selection").
+    /// Returns whether anything will move.
+    pub fn scroll_to(&mut self, target: f32, b: Bounds, s: &ScrollSettings) -> bool {
+        let t = b.clamp(target);
+        if (t - self.pos).abs() < 1e-3 && (t - self.target).abs() < 1e-3 {
+            return false;
+        }
+        self.vel = 0.0;
+        self.target = t;
+        if s.smooth {
+            self.mode = Mode::Easing;
+        } else {
+            self.pos = t;
+            self.mode = Mode::Idle;
+        }
+        true
+    }
+
+    /// Advance `dt` seconds. Returns whether the drawn offset changed — the
+    /// host's repaint signal; check [`Self::is_animating`] to keep frames
+    /// coming.
+    pub fn tick(&mut self, dt: f32, b: Bounds, s: &ScrollSettings) -> bool {
+        let old = self.pos;
+        match self.mode {
+            Mode::Idle | Mode::Tracking => return false,
+            Mode::Easing => {
+                let remaining = self.target - self.pos;
+                if remaining.abs() <= SNAP_PX {
+                    self.pos = self.target;
+                    self.mode = Mode::Idle;
+                } else {
+                    // Frame-rate independent: the same fraction of the remaining
+                    // distance per unit time whatever the frame pacing.
+                    self.pos += remaining * (1.0 - (-s.ease_rate * dt).exp());
+                }
+            }
+            Mode::Coasting => {
+                let p = b.clamp(self.pos + self.vel * dt);
+                self.pos = p;
+                self.target = p;
+                self.vel *= (-s.friction * dt).exp();
+                if p == b.lo || p == b.hi || self.vel.abs() < COAST_STOP_SPEED {
+                    self.vel = 0.0;
+                    self.mode = Mode::Idle;
+                }
+            }
+        }
+        (self.pos - old).abs() > 1e-4
+    }
+}
+
+/// A two-axis scroll offset with the event-to-motion mapping shared by every
+/// host: `LineDelta` notches scale by the line unit, `PixelDelta`s are pixels,
+/// and the phase decides wheel-glide vs finger-track vs fling.
+#[derive(Debug, Clone, Copy)]
+pub struct ScrollMotion {
+    pub x: ScrollAxis,
+    pub y: ScrollAxis,
+    /// Timestamp of the last finger event, for the velocity estimate and the
+    /// stale-fling check.
+    last_finger: Option<Instant>,
+}
+
+impl Default for ScrollMotion {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl ScrollMotion {
+    pub fn new() -> Self {
+        Self { x: ScrollAxis::default(), y: ScrollAxis::default(), last_finger: None }
+    }
+
+    pub fn at(x: f32, y: f32) -> Self {
+        Self { x: ScrollAxis::new(x), y: ScrollAxis::new(y), last_finger: None }
+    }
+
+    pub fn is_animating(&self) -> bool {
+        self.x.is_animating() || self.y.is_animating()
+    }
+
+    /// Adopt host-side writes to both drawn offsets (see [`ScrollAxis::reconcile`]).
+    pub fn reconcile(&mut self, x: f32, y: f32) {
+        self.x.reconcile(x);
+        self.y.reconcile(y);
+    }
+
+    pub fn set_bounds(&mut self, bx: Bounds, by: Bounds) {
+        self.x.set_bounds(bx);
+        self.y.set_bounds(by);
+    }
+
+    /// The wheel delta as content pixels, sign-flipped into "offset grows
+    /// when the content moves up" — the convention every host used inline
+    /// (`-y * 24.0`, `-pos.y`). `line_px` is the per-notch unit for each axis.
+    pub fn delta_px(delta: &MouseScrollDelta, line_px: (f32, f32)) -> (f32, f32) {
+        match delta {
+            MouseScrollDelta::LineDelta(x, y) => (-x * line_px.0, -y * line_px.1),
+            MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
+        }
+    }
+
+    /// Feed one wheel event, using the runner-published phase. Returns
+    /// whether the offset or its target moved (the host's "raise the
+    /// scrollbar" signal, and a repaint request when true).
+    pub fn apply(&mut self, delta: &MouseScrollDelta, line_px: (f32, f32), bx: Bounds, by: Bounds) -> bool {
+        let (dx, dy) = Self::delta_px(delta, line_px);
+        self.apply_px(dx, dy, matches!(delta, MouseScrollDelta::LineDelta(..)), bx, by)
+    }
+
+    /// [`Self::apply`] with the conversion already done. `discrete` marks a
+    /// wheel-notch delta; a pixel delta takes the finger path only while the
+    /// runner reports a finger gesture, else it is applied instantly.
+    pub fn apply_px(&mut self, dx: f32, dy: f32, discrete: bool, bx: Bounds, by: Bounds) -> bool {
+        let s = scroll_settings();
+        let phase = if discrete { ScrollPhase::Wheel } else { current_scroll_phase() };
+        match phase {
+            ScrollPhase::Wheel => {
+                let mut moved = false;
+                if discrete {
+                    moved |= self.x.wheel(dx, bx, &s);
+                    moved |= self.y.wheel(dy, by, &s);
+                } else {
+                    // A pixel delta outside any gesture (a synthesized or
+                    // sourceless event): direct, like the finger path, but
+                    // never flings.
+                    moved |= self.x.finger(dx, 1.0, bx);
+                    moved |= self.y.finger(dy, 1.0, by);
+                    self.x.vel = 0.0;
+                    self.y.vel = 0.0;
+                    self.x.mode = Mode::Idle;
+                    self.y.mode = Mode::Idle;
+                }
+                moved
+            }
+            ScrollPhase::Finger => {
+                let now = Instant::now();
+                let dt = self.last_finger.map_or(0.016, |t| now.duration_since(t).as_secs_f32());
+                self.last_finger = Some(now);
+                let mut moved = false;
+                moved |= self.x.finger(dx, dt, bx);
+                moved |= self.y.finger(dy, dt, by);
+                moved
+            }
+            ScrollPhase::FingerEnd => {
+                let since = self.last_finger.map_or(1.0, |t| t.elapsed().as_secs_f32());
+                let mut coasting = false;
+                coasting |= self.x.finger_end(since, &s);
+                coasting |= self.y.finger_end(since, &s);
+                self.last_finger = None;
+                coasting
+            }
+        }
+    }
+
+    /// Advance both axes. Returns whether either drawn offset changed.
+    pub fn tick(&mut self, dt: f32, bx: Bounds, by: Bounds) -> bool {
+        let s = scroll_settings();
+        let mut moved = false;
+        moved |= self.x.tick(dt, bx, &s);
+        moved |= self.y.tick(dt, by, &s);
+        moved
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn smooth() -> ScrollSettings {
+        ScrollSettings { smooth: true, ease_rate: 12.0, kinetic: true, friction: 6.0 }
+    }
+
+    fn settle(a: &mut ScrollAxis, b: Bounds, s: &ScrollSettings) -> u32 {
+        let mut frames = 0;
+        while a.is_animating() && frames < 10_000 {
+            a.tick(1.0 / 60.0, b, s);
+            frames += 1;
+        }
+        frames
+    }
+
+    #[test]
+    fn wheel_glides_to_target_and_settles() {
+        let s = smooth();
+        let b = Bounds::max(1000.0);
+        let mut a = ScrollAxis::new(0.0);
+        assert!(a.wheel(24.0, b, &s));
+        assert_eq!(a.target(), 24.0);
+        assert_eq!(a.pos(), 0.0, "the wheel moves the target, not the drawn offset");
+        assert!(a.tick(1.0 / 60.0, b, &s));
+        assert!(a.pos() > 0.0 && a.pos() < 24.0);
+        let frames = settle(&mut a, b, &s);
+        assert_eq!(a.pos(), 24.0);
+        assert!(frames > 3 && frames < 60, "settled in {frames} frames");
+    }
+
+    #[test]
+    fn notches_accumulate_into_one_glide() {
+        let s = smooth();
+        let b = Bounds::max(1000.0);
+        let mut a = ScrollAxis::new(0.0);
+        a.wheel(24.0, b, &s);
+        a.tick(1.0 / 60.0, b, &s);
+        a.wheel(24.0, b, &s);
+        assert_eq!(a.target(), 48.0);
+        settle(&mut a, b, &s);
+        assert_eq!(a.pos(), 48.0);
+    }
+
+    #[test]
+    fn wheel_target_clamps_to_bounds() {
+        let s = smooth();
+        let b = Bounds::max(30.0);
+        let mut a = ScrollAxis::new(0.0);
+        a.wheel(100.0, b, &s);
+        assert_eq!(a.target(), 30.0);
+        assert!(!a.wheel(100.0, b, &s), "a notch past the end moves nothing");
+        settle(&mut a, b, &s);
+        assert_eq!(a.pos(), 30.0);
+    }
+
+    #[test]
+    fn smoothing_off_jumps() {
+        let s = ScrollSettings { smooth: false, ..smooth() };
+        let b = Bounds::max(1000.0);
+        let mut a = ScrollAxis::new(0.0);
+        a.wheel(24.0, b, &s);
+        assert_eq!(a.pos(), 24.0);
+        assert!(!a.is_animating());
+    }
+
+    #[test]
+    fn finger_tracks_one_to_one_then_flings() {
+        let s = smooth();
+        let b = Bounds::max(10_000.0);
+        let mut a = ScrollAxis::new(0.0);
+        // A steady 15px every 8ms swipe.
+        for _ in 0..10 {
+            assert!(a.finger(15.0, 0.008, b));
+        }
+        assert_eq!(a.pos(), 150.0);
+        assert!(!a.is_animating(), "no motion of its own while the finger is down");
+        assert!(a.finger_end(0.01, &s));
+        let before = a.pos();
+        let frames = settle(&mut a, b, &s);
+        assert!(a.pos() > before + 50.0, "coasted from {before} to {}", a.pos());
+        assert!(frames > 5);
+        assert_eq!(a.velocity(), 0.0);
+    }
+
+    #[test]
+    fn resting_finger_does_not_fling() {
+        let s = smooth();
+        let b = Bounds::max(10_000.0);
+        let mut a = ScrollAxis::new(0.0);
+        for _ in 0..10 {
+            a.finger(15.0, 0.008, b);
+        }
+        assert!(!a.finger_end(0.5, &s), "a finger held still before lifting stops dead");
+        assert_eq!(a.pos(), 150.0);
+    }
+
+    #[test]
+    fn kinetic_off_stops_dead() {
+        let s = ScrollSettings { kinetic: false, ..smooth() };
+        let b = Bounds::max(10_000.0);
+        let mut a = ScrollAxis::new(0.0);
+        for _ in 0..10 {
+            a.finger(15.0, 0.008, b);
+        }
+        assert!(!a.finger_end(0.01, &s));
+        assert!(!a.is_animating());
+    }
+
+    #[test]
+    fn coast_stops_at_the_bound() {
+        let s = smooth();
+        let b = Bounds::max(200.0);
+        let mut a = ScrollAxis::new(0.0);
+        for _ in 0..10 {
+            a.finger(15.0, 0.008, b);
+        }
+        a.finger_end(0.01, &s);
+        settle(&mut a, b, &s);
+        assert_eq!(a.pos(), 200.0);
+        assert_eq!(a.velocity(), 0.0);
+    }
+
+    #[test]
+    fn wheel_during_coast_redirects() {
+        let s = smooth();
+        let b = Bounds::max(10_000.0);
+        let mut a = ScrollAxis::new(0.0);
+        for _ in 0..10 {
+            a.finger(15.0, 0.008, b);
+        }
+        a.finger_end(0.01, &s);
+        a.tick(1.0 / 60.0, b, &s);
+        let p = a.pos();
+        a.wheel(-24.0, b, &s);
+        assert_eq!(a.velocity(), 0.0);
+        assert!((a.target() - (p - 24.0)).abs() < 1e-3);
+    }
+
+    #[test]
+    fn reconcile_adopts_host_writes() {
+        let s = smooth();
+        let b = Bounds::max(1000.0);
+        let mut a = ScrollAxis::new(0.0);
+        a.wheel(240.0, b, &s);
+        a.tick(1.0 / 60.0, b, &s);
+        // The host dragged the thumb to 500 behind our back.
+        a.reconcile(500.0);
+        assert_eq!(a.pos(), 500.0);
+        assert_eq!(a.target(), 500.0);
+        assert!(!a.is_animating());
+        // An unchanged host value is not a write.
+        a.wheel(24.0, b, &s);
+        a.reconcile(500.0);
+        assert!(a.is_animating());
+    }
+
+    #[test]
+    fn bounds_shrink_reclamps_and_settles() {
+        let s = smooth();
+        let mut a = ScrollAxis::new(0.0);
+        a.wheel(900.0, Bounds::max(1000.0), &s);
+        settle(&mut a, Bounds::max(1000.0), &s);
+        a.set_bounds(Bounds::max(100.0));
+        assert_eq!(a.pos(), 100.0);
+        assert_eq!(a.target(), 100.0);
+    }
+
+    #[test]
+    fn scroll_to_glides_keyboard_pages() {
+        let s = smooth();
+        let b = Bounds::max(1000.0);
+        let mut a = ScrollAxis::new(0.0);
+        assert!(a.scroll_to(400.0, b, &s));
+        assert_eq!(a.pos(), 0.0);
+        settle(&mut a, b, &s);
+        assert_eq!(a.pos(), 400.0);
+    }
+
+    #[test]
+    fn ease_is_frame_rate_independent() {
+        let s = smooth();
+        let b = Bounds::max(1000.0);
+        let mut fast = ScrollAxis::new(0.0);
+        let mut slow = ScrollAxis::new(0.0);
+        fast.wheel(500.0, b, &s);
+        slow.wheel(500.0, b, &s);
+        for _ in 0..12 {
+            fast.tick(1.0 / 120.0, b, &s);
+        }
+        slow.tick(0.1, b, &s);
+        assert!((fast.pos() - slow.pos()).abs() < 1.0, "120Hz {} vs 10Hz {}", fast.pos(), slow.pos());
+    }
+
+    #[test]
+    fn delta_conversion_matches_the_legacy_convention() {
+        let (dx, dy) = ScrollMotion::delta_px(&MouseScrollDelta::LineDelta(0.0, -2.0), (LINE_PX, LINE_PX));
+        assert_eq!((dx, dy), (0.0, 48.0));
+        let (dx, dy) = ScrollMotion::delta_px(
+            &MouseScrollDelta::PixelDelta(crate::widget::Position { x: 3.0, y: -10.0 }),
+            (LINE_PX, LINE_PX),
+        );
+        assert_eq!((dx, dy), (-3.0, 10.0));
+    }
+
+    #[test]
+    fn unbounded_axis_pans_negative() {
+        let s = smooth();
+        let mut a = ScrollAxis::new(0.0);
+        a.wheel(-300.0, Bounds::UNBOUNDED, &s);
+        settle(&mut a, Bounds::UNBOUNDED, &s);
+        assert_eq!(a.pos(), -300.0);
+    }
+}
diff --git a/src/widget/scroll_region.rs b/src/widget/scroll_region.rs
index f003594..6f547b5 100644
--- a/src/widget/scroll_region.rs
+++ b/src/widget/scroll_region.rs
@@ -22,6 +22,7 @@
 //! together, rows paint over the thumb and it peeks through the inter-row gaps
 //! as dotted segments).
 
+use crate::widget::scroll_motion::{scroll_settings, Bounds, ScrollMotion, LINE_PX};
 use crate::widget::{ElementState, Key, KeyEvent, MouseScrollDelta, NamedKey};
 
 /// How long (seconds) a raise/sink scrollbar stays raised after the last wheel
@@ -142,6 +143,11 @@ pub struct ScrollRegion {
     /// is always drawn and always grabbable — existing hosts unchanged.
     pub sink_behind: bool,
     activity: ScrollbarActivity,
+    /// The smooth-scroll driver behind `scroll_x`/`scroll_y`: wheel notches
+    /// glide, trackpad flicks coast. The pub offsets stay the DRAWN values —
+    /// hosts keep reading them — and any host write to them is adopted on the
+    /// next `wheel`/`tick` via `reconcile`.
+    motion: ScrollMotion,
 }
 
 impl Default for ScrollRegion {
@@ -180,6 +186,7 @@ impl ScrollRegion {
             edge_inset: 4.0,
             sink_behind: false,
             activity: ScrollbarActivity::new(),
+            motion: ScrollMotion::new(),
         }
     }
 
@@ -211,6 +218,7 @@ impl ScrollRegion {
         self.viewport_y = viewport_y;
         self.viewport_h = viewport_h;
         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
+        self.motion.set_bounds(self.bounds_x(), self.bounds_y());
     }
 
     /// `ScrollBox::update_bounds` shape: raw content height, not a row count.
@@ -219,10 +227,44 @@ impl ScrollRegion {
         self.viewport_y = viewport_y;
         self.viewport_h = viewport_h;
         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
+        self.motion.set_bounds(self.bounds_x(), self.bounds_y());
     }
 
+    /// Jump the offset (no glide) — cancels any motion in flight.
     pub fn set_scroll_y(&mut self, val: f32) {
         self.scroll_y = val;
+        self.motion.y.jump_to(val);
+    }
+
+    /// Glide the offset to `val` (a "scroll to selection" that should read as
+    /// motion, not a cut). Falls back to a jump with smoothing off.
+    pub fn scroll_to_y(&mut self, val: f32) -> bool {
+        self.motion.reconcile(self.scroll_x, self.scroll_y);
+        let moved = self.motion.y.scroll_to(val, self.bounds_y(), &scroll_settings());
+        self.sync_from_motion();
+        if moved {
+            self.raise();
+        }
+        moved
+    }
+
+    /// Whether a glide or coast is still moving the offset — hosts whose tick
+    /// chain is conditional use this to keep frames coming.
+    pub fn is_animating(&self) -> bool {
+        self.motion.is_animating()
+    }
+
+    fn bounds_y(&self) -> Bounds {
+        Bounds::max(self.max_scroll())
+    }
+
+    fn bounds_x(&self) -> Bounds {
+        Bounds::max(self.max_scroll_x())
+    }
+
+    fn sync_from_motion(&mut self) {
+        self.scroll_x = self.motion.x.pos();
+        self.scroll_y = self.motion.y.pos();
     }
 
     /// Declare how wide the content really is. Wider than the box = the list
@@ -230,6 +272,7 @@ impl ScrollRegion {
     pub fn set_content_w(&mut self, w: f32) {
         self.content_w = w;
         self.scroll_x = self.scroll_x.clamp(0.0, self.max_scroll_x());
+        self.motion.x.set_bounds(self.bounds_x());
     }
 
     /// Whether horizontal scrolling is live (content declared wider than the
@@ -439,17 +482,11 @@ impl ScrollRegion {
         if !self.hit(px, py) {
             return false;
         }
-        let (dx, dy) = match delta {
-            MouseScrollDelta::LineDelta(x, y) => (-x * 24.0, -y * 24.0),
-            MouseScrollDelta::PixelDelta(pos) => (-pos.x as f32, -pos.y as f32),
-        };
-        let old_y = self.scroll_y;
-        self.scroll_y = (self.scroll_y + dy).clamp(0.0, self.max_scroll());
         // Sideways wheel/trackpad deltas pan an h-scrollable list; a
         // vertical-only list ignores them (max_scroll_x = 0 clamps to 0).
-        let old_x = self.scroll_x;
-        self.scroll_x = (self.scroll_x + dx).clamp(0.0, self.max_scroll_x());
-        let changed = (self.scroll_y - old_y).abs() > 0.01 || (self.scroll_x - old_x).abs() > 0.01;
+        self.motion.reconcile(self.scroll_x, self.scroll_y);
+        let changed = self.motion.apply(delta, (LINE_PX, LINE_PX), self.bounds_x(), self.bounds_y());
+        self.sync_from_motion();
         if changed {
             self.raise();
         }
@@ -489,14 +526,21 @@ impl ScrollRegion {
     /// while frames flow, so the hold must keep them coming or the sink would
     /// stall until the next input event. No-op (false) without `sink_behind`.
     pub fn tick(&mut self, dt: f32) -> bool {
+        // The glide/coast first: a host write to the pub offsets since the
+        // last frame (thumb drag, auto-snap) is adopted, then the motion
+        // advances and the drawn offsets follow it.
+        self.motion.reconcile(self.scroll_x, self.scroll_y);
+        let moved = self.motion.tick(dt, self.bounds_x(), self.bounds_y());
+        self.sync_from_motion();
+        let animating = self.motion.is_animating();
         if !self.sink_behind {
-            return false;
+            return moved || animating;
         }
         let holding = self.activity.holding();
         let flipped = self
             .activity
             .tick(dt, self.overflowing(), self.dragging || self.dragging_h);
-        flipped || holding
+        moved || animating || flipped || holding
     }
 
     /// Hover/focus-scoped keyboard scrolling (`ScrollBox::keyboard_input` reached the boxes
@@ -505,40 +549,42 @@ impl ScrollRegion {
         if (!self.hovered && !self.focused) || event.state != ElementState::Pressed {
             return false;
         }
-        let max = self.max_scroll();
-        let old = self.scroll_y;
-        if event.ctrl {
+        // Keyboard steps ride the same glide as wheel notches (a held arrow
+        // accumulates into one motion); pages and Home/End glide to their
+        // absolute target.
+        let s = scroll_settings();
+        self.motion.reconcile(self.scroll_x, self.scroll_y);
+        let by = self.bounds_y();
+        let bx = self.bounds_x();
+        let max_x = self.max_scroll_x();
+        let changed = if event.ctrl {
             match &event.logical_key {
-                Key::Character(c) if c == "n" || c == "N" => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
-                Key::Character(c) if c == "p" || c == "P" => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
+                Key::Character(c) if c == "n" || c == "N" => self.motion.y.wheel(LINE_PX, by, &s),
+                Key::Character(c) if c == "p" || c == "P" => self.motion.y.wheel(-LINE_PX, by, &s),
                 _ => return false,
             }
         } else {
-            let old_x = self.scroll_x;
-            let max_x = self.max_scroll_x();
             match &event.logical_key {
-                Key::Named(NamedKey::ArrowDown) => self.scroll_y = (self.scroll_y + 24.0).clamp(0.0, max),
-                Key::Named(NamedKey::ArrowUp) => self.scroll_y = (self.scroll_y - 24.0).clamp(0.0, max),
-                Key::Named(NamedKey::PageDown) => self.scroll_y = (self.scroll_y + self.viewport_h).clamp(0.0, max),
-                Key::Named(NamedKey::PageUp) => self.scroll_y = (self.scroll_y - self.viewport_h).clamp(0.0, max),
-                Key::Named(NamedKey::Home) => self.scroll_y = 0.0,
-                Key::Named(NamedKey::End) => self.scroll_y = max,
-                // Only an h-scrollable list claims the horizontal arrows —
-                // elsewhere they keep falling through to other handlers.
-                Key::Named(NamedKey::ArrowRight) if max_x > 0.0 => {
-                    self.scroll_x = (self.scroll_x + 24.0).clamp(0.0, max_x)
+                Key::Named(NamedKey::ArrowDown) => self.motion.y.wheel(LINE_PX, by, &s),
+                Key::Named(NamedKey::ArrowUp) => self.motion.y.wheel(-LINE_PX, by, &s),
+                Key::Named(NamedKey::PageDown) => {
+                    let t = self.motion.y.target() + self.viewport_h;
+                    self.motion.y.scroll_to(t, by, &s)
                 }
-                Key::Named(NamedKey::ArrowLeft) if max_x > 0.0 => {
-                    self.scroll_x = (self.scroll_x - 24.0).clamp(0.0, max_x)
+                Key::Named(NamedKey::PageUp) => {
+                    let t = self.motion.y.target() - self.viewport_h;
+                    self.motion.y.scroll_to(t, by, &s)
                 }
+                Key::Named(NamedKey::Home) => self.motion.y.scroll_to(0.0, by, &s),
+                Key::Named(NamedKey::End) => self.motion.y.scroll_to(by.hi, by, &s),
+                // Only an h-scrollable list claims the horizontal arrows —
+                // elsewhere they keep falling through to other handlers.
+                Key::Named(NamedKey::ArrowRight) if max_x > 0.0 => self.motion.x.wheel(LINE_PX, bx, &s),
+                Key::Named(NamedKey::ArrowLeft) if max_x > 0.0 => self.motion.x.wheel(-LINE_PX, bx, &s),
                 _ => return false,
             }
-            if (self.scroll_x - old_x).abs() > 0.01 {
-                self.raise();
-                return true;
-            }
-        }
-        let changed = (self.scroll_y - old).abs() > 0.01;
+        };
+        self.sync_from_motion();
         if changed {
             self.raise();
         }
@@ -649,14 +695,25 @@ mod tests {
         r
     }
 
+    /// Run the glide out (a no-op with smoothing off in the test host's config).
+    fn settle(r: &mut ScrollRegion) {
+        let mut n = 0;
+        while r.is_animating() && n < 1000 {
+            r.tick(1.0 / 60.0);
+            n += 1;
+        }
+    }
+
     #[test]
     fn wheel_scrolls_and_clamps() {
         let mut r = region();
         r.update_bounds(10, 20.0, 100.0); // content_h = 444 > 100
         assert!(r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 50.0, 50.0));
+        settle(&mut r);
         assert_eq!(r.scroll_y, 48.0);
         assert!(!r.wheel(&MouseScrollDelta::LineDelta(0.0, -2.0), 500.0, 50.0)); // miss
         r.wheel(&MouseScrollDelta::LineDelta(0.0, -100.0), 50.0, 50.0);
+        settle(&mut r);
         assert_eq!(r.scroll_y, 344.0); // clamped to max_scroll
     }
 
@@ -732,10 +789,13 @@ mod tests {
         r.set_content_w(500.0);
         assert!(r.h_scroll_active());
         assert!(r.wheel(&MouseScrollDelta::LineDelta(-2.0, 0.0), 50.0, 50.0));
+        settle(&mut r);
         assert_eq!(r.scroll_x, 48.0);
         r.wheel(&MouseScrollDelta::LineDelta(-100.0, 0.0), 50.0, 50.0);
+        settle(&mut r);
         assert_eq!(r.scroll_x, 300.0); // max = 500 - 200
         r.wheel(&MouseScrollDelta::LineDelta(100.0, 0.0), 50.0, 50.0);
+        settle(&mut r);
         assert_eq!(r.scroll_x, 0.0);
     }
 
@@ -803,8 +863,11 @@ mod tests {
         r.cursor_moved(sb_x + 1.0, 50.0);
         assert!(!r.tick(0.016));
         assert!(!r.scrollbar_raised());
-        // Raise by scrolling, hover it, and let the hold lapse: hover sustains.
+        // Raise by scrolling (and let the glide land, so the ticks below
+        // measure only the raise/sink state), hover it, and let the hold
+        // lapse: hover sustains.
         r.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 50.0);
+        settle(&mut r);
         r.cursor_moved(sb_x + 1.0, 50.0);
         r.tick(SCROLL_ACTIVE_HOLD + 0.1); // hold lapses, hover keeps it raised
         assert!(r.scrollbar_raised());
@@ -872,6 +935,7 @@ mod tests {
         r.cursor_moved(50.0, 50.0);
         assert!(r.hovered);
         assert!(r.keyboard(&down));
+        settle(&mut r);
         assert_eq!(r.scroll_y, 24.0);
     }
 }