]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Carousel: fix Safari zoom scroll overshoot with a JS-driven slide animation (#42506)
authorMark Otto <markd.otto@gmail.com>
Thu, 18 Jun 2026 02:20:58 +0000 (19:20 -0700)
committerGitHub <noreply@github.com>
Thu, 18 Jun 2026 02:20:58 +0000 (19:20 -0700)
Under Safari page zoom, programmatic smooth scrolls (scrollBy/scrollTo with
behavior smooth) mis-scale and overshoot the target: a one-slide jump sails
well past it and the restored scroll-snap visibly yanks the slide back. This
happens whether or not snapping is suspended during the scroll, so no
snap-configuration tweak avoids it - native smooth scrolling driven by user
gestures is fine, but any programmatic smooth scroll is affected.

Replace the native smooth scroll + settle-watcher with a JS-driven rAF
animation (_animateScroll): step scrollLeft to the target over SCROLL_DURATION
(300ms) with an ease-in-out cubic, using instant scrolls per frame. This
sidesteps Safari's bug entirely and gives every programmatic jump (prev/next,
indicators, wrap, and the seamless loop) a consistent duration. Reduced motion
and the no-requestAnimationFrame path jump straight to target.

Removes the now-unneeded _restoreSnapWhenSettled/_afterScrollSettles
overshoot-detection machinery. Reworks the unit tests to the new seam and adds
coverage for the animator: eased stepping, frame cancellation, reduced motion,
and the no-rAF fallback.

js/src/carousel.js
js/tests/unit/carousel.spec.js

index e9887b15eff6667081227c7fe4b0923ee6e921ef..90581c43af089edcd3eb3741e167e5e6b8f8490d 100644 (file)
@@ -51,10 +51,14 @@ const CLASS_NAME_PLAYING = 'carousel-playing'
 // `--carousel-interval` used in the SCSS source becomes this at runtime.
 const PROPERTY_INTERVAL = '--bs-carousel-interval'
 
-// How many frames the scroll-settle watcher waits when no movement is ever
-// detected (clamped programmatic scroll, or `scrollBy` stubbed in tests) before
-// it gives up and restores snapping anyway.
-const SCROLL_SETTLE_MAX_FRAMES = 10
+// Duration (ms) of the JS-driven slide animation used for programmatic
+// navigation (prev/next, indicators, wrap, and loop). We step `scrollLeft`
+// ourselves over this window instead of calling `scrollBy({behavior:'smooth'})`,
+// because Safari mis-scales programmatic smooth scrolls under page zoom — a
+// one-slide jump sails well past the target (by the zoom factor) and the
+// restored snap then visibly yanks the slide back. Animating by hand is immune
+// to that and gives every jump a consistent duration.
+const SCROLL_DURATION = 300
 
 // How far below the most-visible slide a slide's IntersectionRatio can be while
 // still counting as the active (left-most) slide. After a programmatic scroll
@@ -101,6 +105,12 @@ const DefaultType = {
   pause: '(string|boolean)'
 }
 
+// Standard ease-in-out cubic, so the JS-driven scroll accelerates and
+// decelerates like a native smooth scroll rather than moving linearly.
+const easeInOutCubic = progress => (progress < 0.5 ?
+  4 * progress * progress * progress :
+  1 - ((((-2 * progress) + 2) ** 3) / 2))
+
 /**
  * Class definition
  */
@@ -122,7 +132,8 @@ class Carousel extends BaseComponent {
 
     this._interval = null
     this._observer = null
-    this._snapRestoreFrame = null
+    // rAF handle for the in-flight JS-driven scroll animation (see `_animateScroll`).
+    this._scrollFrame = null
     // True while a seamless loop transition is animating, so the
     // IntersectionObserver and re-entrant navigation don't interfere.
     this._looping = false
@@ -253,8 +264,8 @@ class Carousel extends BaseComponent {
       this._observer.disconnect()
     }
 
-    if (this._snapRestoreFrame !== null) {
-      cancelAnimationFrame(this._snapRestoreFrame)
+    if (this._scrollFrame !== null) {
+      cancelAnimationFrame(this._scrollFrame)
     }
 
     // Tidy up any in-flight loop transition: drop a stray clone and restore
@@ -410,23 +421,76 @@ class Carousel extends BaseComponent {
       return
     }
 
-    // `scroll-snap-stop: always` keeps user wheel/touch flings to a single slide,
-    // but it also clamps *programmatic* scrolls to one snap point — which would
-    // break multi-slide jumps from an indicator click, `to()`, or wrapping from
-    // the last slide back to the first. Disable snapping for the duration of the
-    // programmatic scroll, then restore it once the scroll settles so the slide
-    // still rests precisely (honouring peek/gap).
+    // `scroll-snap-stop: always` would clamp a programmatic scroll to a single
+    // snap point, breaking multi-slide jumps (an indicator click, `to()`, or
+    // wrapping from the last slide back to the first). Suspend snapping while we
+    // animate, then restore it once we arrive so the slide rests precisely on the
+    // snap point (honouring peek/gap).
     const targetLeft = this._viewport.scrollLeft + left
     this._viewport.style.scrollSnapType = 'none'
-    this._viewport.scrollBy({
-      left,
-      top: 0,
-      // `'instant'` (not `'auto'`) for reduced motion: the viewport sets
-      // `scroll-behavior: smooth` in CSS, and `'auto'` defers to it, so it would
-      // still animate. `'instant'` forces an immediate, motion-free jump.
-      behavior: this._prefersReducedMotion() ? 'instant' : 'smooth'
+    this._animateScroll(targetLeft, () => {
+      this._viewport.style.scrollSnapType = ''
+      // Without IntersectionObserver nothing else fires `slid`/updates the active
+      // slide after a programmatic scroll, so do it here. With the observer
+      // present this is a no-op (it already moved the active index to `index`).
+      if (!this._observer) {
+        this._setActive(index)
+      }
+
+      // The IntersectionObserver doesn't fire once the viewport has stopped, so
+      // refresh the end controls here to catch the final settle landing exactly
+      // on the scroll extent (e.g. disabling `next` at the last view).
+      this._updateEndControls()
     })
-    this._restoreSnapWhenSettled(targetLeft, index)
+  }
+
+  // Animate `this._viewport.scrollLeft` to `targetLeft` over `SCROLL_DURATION`,
+  // stepping the position ourselves each frame (the caller suspends snapping
+  // first and restores it in `onComplete`). This replaces
+  // `scrollBy({behavior:'smooth'})`, whose Safari page-zoom bug made programmatic
+  // jumps overshoot the target and snap back. Because we set every frame's
+  // absolute position with an instant scroll, the animation can't overshoot and
+  // every jump takes the same time, in every browser.
+  _animateScroll(targetLeft, onComplete) {
+    if (this._scrollFrame !== null) {
+      cancelAnimationFrame(this._scrollFrame)
+      this._scrollFrame = null
+    }
+
+    const startLeft = this._viewport.scrollLeft
+    const distance = targetLeft - startLeft
+
+    // Reduced motion (or no rAF, e.g. unit tests): jump straight to the target.
+    if (this._prefersReducedMotion() || typeof requestAnimationFrame === 'undefined') {
+      this._viewport.scrollTo({ left: targetLeft, behavior: 'instant' })
+      onComplete()
+      return
+    }
+
+    let startTime = null
+    const step = now => {
+      if (startTime === null) {
+        startTime = now
+      }
+
+      const progress = Math.min((now - startTime) / SCROLL_DURATION, 1)
+      // `'instant'` (not the default) because the viewport sets
+      // `scroll-behavior: smooth` in CSS; without it each step would itself
+      // animate and fight this loop.
+      this._viewport.scrollTo({ left: startLeft + (distance * easeInOutCubic(progress)), behavior: 'instant' })
+
+      if (progress < 1) {
+        this._scrollFrame = requestAnimationFrame(step)
+        return
+      }
+
+      // Land exactly on target, guarding against floating-point drift.
+      this._viewport.scrollTo({ left: targetLeft, behavior: 'instant' })
+      this._scrollFrame = null
+      onComplete()
+    }
+
+    this._scrollFrame = requestAnimationFrame(step)
   }
 
   // Horizontal distance to scroll the viewport so `element` rests where the
@@ -500,13 +564,7 @@ class Carousel extends BaseComponent {
       this._jumpScroll(this._scrollDelta(items[fromIndex]))
     }
 
-    this._viewport.scrollBy({
-      left: this._scrollDelta(clone),
-      top: 0,
-      behavior: 'smooth'
-    })
-
-    this._afterScrollSettles(() => {
+    this._animateScroll(this._viewport.scrollLeft + this._scrollDelta(clone), () => {
       // Teleport to the real destination without animation. JS runs to
       // completion before the browser paints, so removing the clone and the
       // compensating scroll land in a single frame (no visible flash).
@@ -545,89 +603,6 @@ class Carousel extends BaseComponent {
     this._viewport.scrollBy({ left: delta, top: 0, behavior: 'instant' })
   }
 
-  // Re-enable scroll snapping once the viewport reaches `targetLeft` (or stops
-  // moving). Passing the target matters: restoring `mandatory` snapping re-snaps
-  // to the *nearest* snap point, so if we restored mid-animation the viewport
-  // could jump back to the slide we came from — most visible stepping to the
-  // first/last slide, where it looks like the control "doesn't work".
-  _restoreSnapWhenSettled(targetLeft, index) {
-    this._afterScrollSettles(() => {
-      this._viewport.style.scrollSnapType = ''
-      // Without IntersectionObserver nothing else fires `slid`/updates the active
-      // slide after a programmatic scroll, so do it here. With the observer
-      // present this is a no-op (it already moved the active index to `index`).
-      if (!this._observer && index !== undefined) {
-        this._setActive(index)
-      }
-
-      // The IntersectionObserver doesn't fire once the viewport has stopped, so
-      // refresh the end controls here to catch the final ~1px settle landing
-      // exactly on the scroll extent (e.g. disabling `next` at the last view).
-      this._updateEndControls()
-    }, targetLeft)
-  }
-
-  // Invoke `callback` once the viewport stops moving. We watch the scroll
-  // position across frames instead of relying on the `scrollend` event, which
-  // isn't available across our supported browsers yet.
-  //
-  // Crucially, we only start counting "stable" frames once the scroll has
-  // actually moved. A smooth `scrollBy` doesn't update `scrollLeft` for the first
-  // frame or two, so naively treating those initial unchanged frames as
-  // "settled" would re-enable `mandatory` snapping mid-animation — which cancels
-  // the in-flight programmatic scroll and lands on the wrong slide (most visible
-  // in multi-item layouts). If the scroll never moves (delta clamped at an end,
-  // or `scrollBy` stubbed out in unit tests), we fall back to a short frame cap.
-  //
-  // When `targetLeft` is known we also finish the moment we arrive there, so the
-  // snap is restored exactly on the destination snap point and can't re-snap the
-  // viewport backwards (the failure mode where stepping to the first/last slide
-  // appears to do nothing).
-  _afterScrollSettles(callback, targetLeft) {
-    if (typeof requestAnimationFrame === 'undefined') {
-      callback()
-      return
-    }
-
-    if (this._snapRestoreFrame !== null) {
-      cancelAnimationFrame(this._snapRestoreFrame)
-    }
-
-    const startLeft = this._viewport.scrollLeft
-    let lastLeft = startLeft
-    let stableFrames = 0
-    let waited = 0
-    let hasMoved = false
-
-    const tick = () => {
-      const currentLeft = this._viewport.scrollLeft
-      const reachedTarget = targetLeft !== undefined && Math.abs(currentLeft - targetLeft) <= 1
-
-      if (Math.abs(currentLeft - startLeft) > 1) {
-        hasMoved = true
-      }
-
-      // Only accrue stable frames after movement begins, so the pre-animation
-      // and ease-in frames don't prematurely count as settled.
-      if (hasMoved) {
-        stableFrames = Math.abs(currentLeft - lastLeft) < 1 ? stableFrames + 1 : 0
-      }
-
-      lastLeft = currentLeft
-      waited += 1
-
-      if (reachedTarget || (hasMoved && stableFrames >= 3) || (!hasMoved && waited >= SCROLL_SETTLE_MAX_FRAMES)) {
-        this._snapRestoreFrame = null
-        callback()
-        return
-      }
-
-      this._snapRestoreFrame = requestAnimationFrame(tick)
-    }
-
-    this._snapRestoreFrame = requestAnimationFrame(tick)
-  }
-
   // Fade mode just swaps the active class; the CSS opacity transition on
   // `.carousel-item` performs the crossfade over `--carousel-fade-duration` (and
   // collapses to an instant swap under reduced motion, via the `transition`
index 86d7285584e4de4305689bba0722a9b33e54bd8e..5f670e54331b34fad4df6ce255b6a616b064ad6b 100644 (file)
@@ -7,7 +7,21 @@ import {
 describe('Carousel', () => {
   let fixtureEl
   let realIntersectionObserver
-  let scrollBySpy
+  let scrollToSpy
+  let animateScrollSpy
+  // Completion callback captured from the most recent stubbed `_animateScroll`,
+  // so a test can decide when the (otherwise instant in tests) scroll "settles".
+  let pendingScrollComplete
+
+  // Run the pending scroll's completion callback (clone teleport, active sync,
+  // snap restore, …), simulating the animation finishing.
+  const settleScroll = () => {
+    const complete = pendingScrollComplete
+    pendingScrollComplete = null
+    if (complete) {
+      complete()
+    }
+  }
 
   // A no-op IntersectionObserver so the real one doesn't fire during tests;
   // active-slide syncing is driven explicitly via `_handleIntersection`.
@@ -75,20 +89,6 @@ describe('Carousel', () => {
     carousel._handleIntersection(entries)
   }
 
-  // `scrollBy` is stubbed (no real movement), so the settle watcher never sees
-  // movement and falls back to its frame cap (SCROLL_SETTLE_MAX_FRAMES = 10).
-  // Await past that so the loop transition's teleport/`slid` step runs first.
-  const flushFrames = (count = 14) => {
-    let chain = Promise.resolve()
-    for (let i = 0; i < count; i++) {
-      chain = chain.then(() => new Promise(resolve => {
-        requestAnimationFrame(resolve)
-      }))
-    }
-
-    return chain
-  }
-
   beforeAll(() => {
     fixtureEl = getFixture()
   })
@@ -96,7 +96,18 @@ describe('Carousel', () => {
   beforeEach(() => {
     realIntersectionObserver = window.IntersectionObserver
     window.IntersectionObserver = MockIntersectionObserver
-    scrollBySpy = spyOn(Element.prototype, 'scrollBy')
+    // Stub `scrollBy` (used by the loop transition's instant teleport) so it
+    // doesn't move the fixture; assertions go through `scrollToSpy`/`animateScrollSpy`.
+    spyOn(Element.prototype, 'scrollBy')
+    scrollToSpy = spyOn(Element.prototype, 'scrollTo')
+    // Stub the JS scroll: record the target and capture the completion callback
+    // (run on demand via `settleScroll`) instead of waiting out the real rAF
+    // animation. Tests that exercise the real animator opt back in with
+    // `animateScrollSpy.and.callThrough()`.
+    pendingScrollComplete = null
+    animateScrollSpy = spyOn(Carousel.prototype, '_animateScroll').and.callFake((targetLeft, onComplete) => {
+      pendingScrollComplete = onComplete
+    })
   })
 
   afterEach(() => {
@@ -239,11 +250,10 @@ describe('Carousel', () => {
 
       carousel.next()
 
-      expect(scrollBySpy).toHaveBeenCalled()
-      // The viewport (not the item) is the element being scrolled
-      expect(scrollBySpy.calls.mostRecent().object).toEqual(carousel._viewport)
-      // item2 sits one item-width (100) to the right of the viewport's start
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(100)
+      expect(animateScrollSpy).toHaveBeenCalled()
+      // item2 sits one item-width (100) to the right of the viewport's start, so
+      // the absolute scroll target is 0 + 100
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(100)
       expect(slideSpy).toHaveBeenCalledTimes(1)
       expect(slideSpy.calls.mostRecent().args[0].to).toEqual(1)
       expect(slideSpy.calls.mostRecent().args[0].direction).toEqual('left')
@@ -272,7 +282,7 @@ describe('Carousel', () => {
 
       carousel.next()
 
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should wrap to the last item when going prev from the first (wrap: true)', () => {
@@ -283,7 +293,7 @@ describe('Carousel', () => {
       carousel.prev()
 
       // item3 is two item-widths (200) to the right — a full multi-slide jump
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
     })
 
     it('should not move past the ends when `ends` is `stop`', () => {
@@ -293,7 +303,7 @@ describe('Carousel', () => {
       stubLayout(carousel)
       carousel.prev()
 
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should center the active slide when `.carousel-center` is present', () => {
@@ -306,7 +316,7 @@ describe('Carousel', () => {
       // Center mode aligns the item's center to the viewport's center:
       // (itemLeft 200 + itemWidth/2 100) - (viewportLeft 0 + viewportWidth/2 150) = 150
       // (start alignment would instead be itemLeft - viewportLeft = 200)
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(150)
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(150)
     })
 
     it('should rest the slide at the scroll-padding (peek) offset', () => {
@@ -320,7 +330,7 @@ describe('Carousel', () => {
       carousel.next()
 
       // itemLeft 100 - (viewportLeft 0 + padStart 30) = 70 (vs 100 without peek)
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(70)
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(70)
     })
 
     it('should do nothing when navigating to the current index', () => {
@@ -330,7 +340,7 @@ describe('Carousel', () => {
       stubLayout(carousel)
       carousel.to(0)
 
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should not advance past the last item when `ends` is `stop`', () => {
@@ -341,7 +351,7 @@ describe('Carousel', () => {
       carousel._activeIndex = 2
       carousel.next()
 
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should ignore a non-numeric index', () => {
@@ -351,21 +361,7 @@ describe('Carousel', () => {
       stubLayout(carousel)
       carousel.to('not-a-number')
 
-      expect(scrollBySpy).not.toHaveBeenCalled()
-    })
-
-    it('should scroll without smooth behavior when reduced motion is preferred', () => {
-      fixtureEl.innerHTML = basicMarkup()
-
-      spyOn(window, 'matchMedia').and.returnValue({ matches: true })
-
-      const carousel = new Carousel('#myCarousel')
-      stubLayout(carousel)
-      carousel.next()
-
-      // `'instant'` rather than `'auto'`, which would defer to the CSS
-      // `scroll-behavior: smooth` and animate despite the user's preference.
-      expect(scrollBySpy.calls.mostRecent().args[0].behavior).toEqual('instant')
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should not scroll when the target item is missing', () => {
@@ -375,7 +371,7 @@ describe('Carousel', () => {
       stubLayout(carousel)
       carousel._scrollToIndex(99)
 
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should report mirrored slide directions in RTL', () => {
@@ -418,47 +414,114 @@ describe('Carousel', () => {
       // Navigation must measure from the real position (item 2), not the stale 0.
       expect(carousel._navIndex()).toEqual(2)
     })
+  })
 
-    it('should cancel a pending snap-restore frame when navigating again', () => {
+  // The real `_animateScroll` (the global spy is bypassed with `callThrough`).
+  // We drive a JS-owned rAF animation instead of `scrollBy({behavior:'smooth'})`
+  // because Safari mis-scales programmatic smooth scrolls under page zoom.
+  describe('scroll animation (`_animateScroll`)', () => {
+    // Run whatever rAF callbacks are queued, passing each a synthetic timestamp
+    // (ms) so the time-based animation advances deterministically (no real wait).
+    const tick = (frames, now) => {
+      for (const cb of frames.splice(0)) {
+        cb(now)
+      }
+    }
+
+    it('should step the viewport toward the target with eased, instant scrolls and finish on it', () => {
       fixtureEl.innerHTML = basicMarkup()
+      const carousel = new Carousel('#myCarousel')
+      animateScrollSpy.and.callThrough()
+
+      const frames = []
+      spyOn(window, 'requestAnimationFrame').and.callFake(cb => frames.push(cb))
+      const onComplete = jasmine.createSpy('onComplete')
+
+      // SCROLL_DURATION is 300ms; the first frame seeds the start time.
+      carousel._animateScroll(500, onComplete)
+
+      tick(frames, 0) // progress 0 → eased position 0, instant scroll, not done
+      expect(scrollToSpy.calls.mostRecent().args[0].behavior).toEqual('instant')
+      expect(scrollToSpy.calls.mostRecent().args[0].left).toEqual(0)
+      expect(onComplete).not.toHaveBeenCalled()
+
+      tick(frames, 200) // progress ~0.67 → past the easing midpoint, partway there
+      const mid = scrollToSpy.calls.mostRecent().args[0].left
+      expect(mid).toBeGreaterThan(0)
+      expect(mid).toBeLessThan(500)
+      expect(onComplete).not.toHaveBeenCalled()
 
+      tick(frames, 400) // progress > 1 → land exactly on target and complete
+      expect(scrollToSpy.calls.mostRecent().args[0].left).toEqual(500)
+      expect(onComplete).toHaveBeenCalledTimes(1)
+    })
+
+    it('should cancel an in-flight animation frame when a new scroll starts', () => {
+      fixtureEl.innerHTML = basicMarkup()
       const carousel = new Carousel('#myCarousel')
-      stubLayout(carousel)
+      animateScrollSpy.and.callThrough()
+
+      spyOn(window, 'requestAnimationFrame').and.returnValue(42)
       const cancelSpy = spyOn(window, 'cancelAnimationFrame').and.callThrough()
 
-      carousel.next() // schedules a snap-restore frame
-      carousel.to(2) // navigates again before it settles, cancelling the pending frame
+      carousel._animateScroll(100, () => {})
+      carousel._animateScroll(200, () => {}) // supersedes the first
 
-      expect(cancelSpy).toHaveBeenCalled()
+      expect(cancelSpy).toHaveBeenCalledWith(42)
     })
 
-    it('should restore snapping immediately when requestAnimationFrame is unavailable', () => {
+    it('should jump straight to the target under reduced motion', () => {
       fixtureEl.innerHTML = basicMarkup()
+      spyOn(window, 'matchMedia').and.returnValue({ matches: true })
+      const carousel = new Carousel('#myCarousel')
+      animateScrollSpy.and.callThrough()
 
+      const rafSpy = spyOn(window, 'requestAnimationFrame')
+      const onComplete = jasmine.createSpy('onComplete')
+
+      carousel._animateScroll(500, onComplete)
+
+      // No animation frames scheduled; one instant scroll lands on target.
+      expect(rafSpy).not.toHaveBeenCalled()
+      expect(scrollToSpy).toHaveBeenCalledTimes(1)
+      expect(scrollToSpy.calls.mostRecent().args[0]).toEqual({ left: 500, behavior: 'instant' })
+      expect(onComplete).toHaveBeenCalledTimes(1)
+    })
+
+    it('should jump straight to the target when requestAnimationFrame is unavailable', () => {
+      fixtureEl.innerHTML = basicMarkup()
       const carousel = new Carousel('#myCarousel')
-      stubLayout(carousel)
+      animateScrollSpy.and.callThrough()
+
       const original = window.requestAnimationFrame
       window.requestAnimationFrame = undefined
+      const onComplete = jasmine.createSpy('onComplete')
 
-      carousel.next()
-      expect(carousel._viewport.style.scrollSnapType).toEqual('')
+      try {
+        carousel._animateScroll(500, onComplete)
+      } finally {
+        window.requestAnimationFrame = original
+      }
 
-      window.requestAnimationFrame = original
+      expect(scrollToSpy.calls.mostRecent().args[0]).toEqual({ left: 500, behavior: 'instant' })
+      expect(onComplete).toHaveBeenCalledTimes(1)
     })
 
-    it('should restore snapping as soon as the viewport reaches the scroll target', async () => {
+    it('should restore snapping once the navigation animation completes', () => {
       fixtureEl.innerHTML = basicMarkup()
-
       const carousel = new Carousel('#myCarousel')
-      carousel._viewport.style.scrollSnapType = 'none'
-      // Pretend the viewport is already sitting on the destination snap point.
-      Object.defineProperty(carousel._viewport, 'scrollLeft', { configurable: true, get: () => 300 })
+      stubLayout(carousel)
+      animateScrollSpy.and.callThrough()
+      const original = window.requestAnimationFrame
+      window.requestAnimationFrame = undefined
 
-      carousel._restoreSnapWhenSettled(300)
-      // Far fewer than SCROLL_SETTLE_MAX_FRAMES: only the target-reached branch
-      // can restore this quickly (the no-movement fallback waits the full cap).
-      await flushFrames(2)
+      try {
+        carousel.next()
+      } finally {
+        window.requestAnimationFrame = original
+      }
 
+      // Snapping is suspended for the scroll, then restored on completion.
       expect(carousel._viewport.style.scrollSnapType).toEqual('')
     })
   })
@@ -471,11 +534,11 @@ describe('Carousel', () => {
       stubLayout(carousel)
 
       carousel.prev()
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
 
       carousel._activeIndex = 2
       carousel.next()
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should wrap around at both ends when `ends` is `wrap`', () => {
@@ -486,12 +549,12 @@ describe('Carousel', () => {
 
       carousel.prev()
       // wraps to the last item (item3), two item-widths (200) to the right
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
     })
   })
 
   describe('seamless loop (`ends: loop`)', () => {
-    it('should continue into a transient clone and teleport to the first slide when going next from the last', async () => {
+    it('should continue into a transient clone and teleport to the first slide when going next from the last', () => {
       fixtureEl.innerHTML = basicMarkup()
 
       const carouselEl = fixtureEl.querySelector('#myCarousel')
@@ -507,7 +570,7 @@ describe('Carousel', () => {
       expect(carousel._viewport.querySelector('.carousel-item-clone')).not.toBeNull()
       expect(carousel._looping).toBeTrue()
 
-      await flushFrames()
+      settleScroll()
 
       expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
       expect(carousel._activeIndex).toEqual(0)
@@ -516,7 +579,7 @@ describe('Carousel', () => {
       expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('left')
     })
 
-    it('should continue into a transient clone and teleport to the last slide when going prev from the first', async () => {
+    it('should continue into a transient clone and teleport to the last slide when going prev from the first', () => {
       fixtureEl.innerHTML = basicMarkup()
 
       const carouselEl = fixtureEl.querySelector('#myCarousel')
@@ -529,7 +592,7 @@ describe('Carousel', () => {
 
       expect(carousel._viewport.querySelector('.carousel-item-clone')).not.toBeNull()
 
-      await flushFrames()
+      settleScroll()
 
       expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
       expect(carousel._activeIndex).toEqual(2)
@@ -537,7 +600,7 @@ describe('Carousel', () => {
       expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('right')
     })
 
-    it('should report mirrored loop directions in RTL', async () => {
+    it('should report mirrored loop directions in RTL', () => {
       fixtureEl.innerHTML = basicMarkup()
       document.documentElement.dir = 'rtl'
 
@@ -549,7 +612,7 @@ describe('Carousel', () => {
 
       carousel._activeIndex = 2
       carousel.next()
-      await flushFrames()
+      settleScroll()
 
       expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('right')
     })
@@ -577,7 +640,7 @@ describe('Carousel', () => {
 
       carousel.to(1)
 
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should not move the active index from intersection churn while looping', () => {
@@ -604,7 +667,7 @@ describe('Carousel', () => {
       carousel.prev()
 
       expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
     })
 
     it('should fall back to a plain wrap for centered layouts', () => {
@@ -616,7 +679,7 @@ describe('Carousel', () => {
       carousel.prev()
 
       expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
-      expect(scrollBySpy).toHaveBeenCalled()
+      expect(animateScrollSpy).toHaveBeenCalled()
     })
 
     it('should fall back to a plain wrap with fewer than two slides', () => {
@@ -646,7 +709,7 @@ describe('Carousel', () => {
       carousel.prev()
 
       expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
     })
 
     it('should fall back to a plain wrap when a peek is configured', () => {
@@ -850,7 +913,7 @@ describe('Carousel', () => {
       expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
     })
 
-    it('should refresh the end controls once a programmatic scroll settles at the extent', async () => {
+    it('should refresh the end controls once a programmatic scroll settles at the extent', () => {
       fixtureEl.innerHTML = controlsMarkup({ slides: 6 })
 
       const carousel = new Carousel('#myCarousel', { ends: 'stop' })
@@ -862,7 +925,7 @@ describe('Carousel', () => {
       expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
 
       carousel._scrollToIndex(1)
-      await flushFrames()
+      settleScroll()
 
       expect(fixtureEl.querySelector('#next').disabled).toBeTrue()
     })
@@ -931,7 +994,7 @@ describe('Carousel', () => {
       expect(carousel._activeIndex).toEqual(1)
     })
 
-    it('should sync the active slide and fire `slid` after settling when there is no observer', async () => {
+    it('should sync the active slide and fire `slid` after settling when there is no observer', () => {
       const original = window.IntersectionObserver
       window.IntersectionObserver = undefined
 
@@ -945,7 +1008,7 @@ describe('Carousel', () => {
         EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
 
         carousel.to(1)
-        await flushFrames()
+        settleScroll()
 
         expect(carousel._activeIndex).toEqual(1)
         expect(slidSpy).toHaveBeenCalled()
@@ -964,7 +1027,7 @@ describe('Carousel', () => {
 
       expect(fixtureEl.querySelector('#item2')).toHaveClass('active')
       expect(fixtureEl.querySelector('#item1')).not.toHaveClass('active')
-      expect(scrollBySpy).not.toHaveBeenCalled()
+      expect(animateScrollSpy).not.toHaveBeenCalled()
     })
 
     it('should not use the View Transition API for the fade', () => {
@@ -1307,7 +1370,7 @@ describe('Carousel', () => {
 
       expect(carousel._playing).toBeFalse()
       expect(carousel._interval).toBeNull()
-      expect(scrollBySpy).toHaveBeenCalled()
+      expect(animateScrollSpy).toHaveBeenCalled()
     })
 
     it('should go to the previous item with data-bs-slide="prev"', () => {
@@ -1326,7 +1389,7 @@ describe('Carousel', () => {
 
       fixtureEl.querySelector('#prev').click()
       // wraps to the last item (item2), one item-width (100) to the right
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(100)
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(100)
     })
 
     it('should go to a given index with data-bs-slide-to', () => {
@@ -1338,7 +1401,7 @@ describe('Carousel', () => {
       fixtureEl.querySelector('[data-bs-slide-to="2"]').click()
 
       // item3 is two item-widths (200) to the right — a full multi-slide jump
-      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+      expect(animateScrollSpy.calls.mostRecent().args[0]).toEqual(200)
     })
 
     it('should toggle play/pause via the data-api', () => {