]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Carousel: scroll-snap rebuild, opt-in autoplay, play/pause, and UI redesign (#42484)
authorMark Otto <markd.otto@gmail.com>
Thu, 11 Jun 2026 16:46:59 +0000 (09:46 -0700)
committerGitHub <noreply@github.com>
Thu, 11 Jun 2026 16:46:59 +0000 (09:46 -0700)
* Make carousel autoplay opt-in via boolean `autoplay` option

Replace the `ride` option with a boolean `autoplay` option (default
`false`), so carousels are static by default and only autoplay when
explicitly opted in with `data-bs-autoplay="true"`.

This removes the confusing `ride="true"` behavior that started
autoplaying only after the first user interaction, and the cryptic
`ride="carousel"` string value. Addresses the first two points of #32649.

* Stop carousel autoplay on interaction and add play/pause control

Address the WCAG 2.2.2 (Pause, Stop, Hide) concern in #32649.

Behavior: once a user takes control of an autoplaying carousel — clicking
a control or indicator, using the keyboard, or swiping — autoplay stops
for good instead of resuming, respecting their intent. A new runtime
`_playing` flag tracks autoplay intent and gates `_maybeEnableCycle()`.

Control: add a `.carousel-control-play-pause` button as the discoverable
pause/stop mechanism WCAG actually requires (a hover-only pause does not
qualify). It toggles autoplay, reflects state by swapping its icon (via
pause-fill/play-fill CSS mask icons) and `aria-label`, and can also start
autoplay on an otherwise static carousel.

Includes unit tests, SCSS tokens/styles with dark-mode support, docs, and
a migration note.

* Rebuild carousel on CSS scroll snap

Replace the float/translateX engine and custom swipe handler with a native
horizontal scroll-snap container. Sliding, touch dragging, momentum, and
keyboard scrolling now come from the browser; JavaScript only layers on
autoplay, the prev/next/indicator controls, and active-slide syncing via an
IntersectionObserver.

This unlocks features the old engine couldn't do, all via CSS custom
properties on `.carousel`:

- Multiple slides per view (`--carousel-items`) with responsive
  `.carousel-items-*` utilities
- Peek of adjacent slides (`--carousel-peek`) and gaps (`--carousel-gap`)
- Variable-width slides (`.carousel-auto`) and center mode (`.carousel-center`)

`.carousel-fade` becomes a stacked-opacity mode that upgrades to a View
Transition where supported. `touch: false` now applies `touch-action: pan-y`.
The markup, public JS API, and slide/slid events are preserved; the
transitional `.carousel-item-{start,end,next,prev}` classes and the
`.carousel.pointer-event` helper are removed.

Includes a full unit-test rewrite, SCSS engine, docs with multi-item/peek/
variable-width examples, and a migration entry.

* Add carousel stacked layout and dot indicator variants

Rename the per-slide layout tokens for clarity and add new layout options:

- `--carousel-peek` -> `--carousel-items-peek`, `--carousel-gap` ->
  `--carousel-items-gap` (both now carry a length unit so they're safe inside
  the flex-basis `calc()`)
- New `--carousel-gap` for spacing the stacked layout's rows
- New `.carousel-stacked` layout (controls outside the inner viewport, above or
  below) and `.carousel-indicators-dots` round indicator style
- Fix the `.carousel-item` flex-basis so the peek isn't subtracted twice (the
  `padding-inline` already insets the content box)

* Add carousel `ends` option (stop/wrap/loop) and robust scroll navigation

Replace the `wrap` boolean with an `ends` option taking `stop`, `wrap`
(default), or `loop`:

- `stop` disables the prev/next controls at each end (moving focus to the
  opposite control first, so focus is never lost)
- `wrap` jumps from the last slide back to the first (and vice versa)
- `loop` continues seamlessly into a transient clone of the destination slide,
  then teleports to the real one with no visible backward jump (single-slide
  scroll layouts only; otherwise falls back to `wrap`)

Rework programmatic navigation to scroll the viewport directly with `scrollBy`
instead of `scrollIntoView`, suspending scroll-snap for the duration and
restoring it once the scroll settles. This fixes multi-slide jumps (indicator
clicks, reaching the last slide) that `scroll-snap-stop: always` previously
clamped to one slide, avoids yanking the page to an off-screen carousel, and
works in RTL. Navigation steps are now measured from the live scroll position
so a stale active index can't make a control silently no-op.

Includes unit tests covering the ends modes, the loop transition, end-control
disabling, and the scroll-settle behavior.

* Document carousel end behavior, stacked layout, and indicator variants

Add docs and examples for the new stacked carousels (top, bottom, and with
dot indicators), the `ends` option (wrap/stop/loop) under a new "End behavior"
section, and refresh the options table and intro for the scroll-snap model.

* Redesign carousel controls, indicators, and layout

- `.carousel` is now a flex column by default (controls/indicators sit in the
  flow above or below the slides); overlaying them on top of the slides moves
  to a new `.carousel-overlay` modifier.
- Rename the control-icon classes to `.carousel-icon-{prev,next,pause,play}`,
  painted with `currentcolor` so they inherit the surrounding text/button color
  and work inside `.btn-*`. `.carousel-control-play-pause` is now just a
  behavior hook that shows the matching glyph via `.paused`.
- Redesign indicators as pills: the active one widens, and while autoplaying it
  fills like a progress bar over the slide's interval
  (`carousel-indicator-progress` keyframes driven by `--carousel-interval`).
- Remove `.carousel-caption` and its `--carousel-caption-*` tokens.

* Add carousel autoplay progress, drop `touch` option and View Transitions

- While cycling, toggle a `.carousel-playing` class and expose the wait as
  `--carousel-interval` so CSS can animate the active indicator's progress
  fill. Schedule each tick from the slide being navigated *to*, so per-item
  `data-bs-interval`s don't lag a slide behind.
- Remove the `touch` option: horizontal dragging is part of the native
  scroll-snap container, so there's nothing left for JavaScript to toggle.
- Fade is now a plain CSS opacity crossfade; drop the View Transition path,
  which double-animated against the CSS transition and visibly stuttered.

* Update carousel docs, examples, and fixtures for the redesign

Switch the docs examples, homepage/cheatsheet examples, and test fixtures to
`.carousel-overlay` + `.carousel-icon-*` and drop `.carousel-caption`. Document
the stacked-by-default layout, the `.carousel-overlay` modifier, the renamed
control-icon classes, the autoplay progress indicator, and the removed `touch`
option and captions in the migration guide.

* fix bundlewatch

* Fix MDX emphasis-style lint error in migration guide

* Default carousel `ends` to `loop`

Change the default end behavior from `wrap` to `loop`, so carousels scroll
seamlessly past the ends by default (falling back to `wrap` where seamless
looping doesn't apply). Tests that exercise the wrap jump now request it
explicitly.

* Clean up carousel styles and refresh docs

- Remove the legacy `--carousel-control-*` tokens and the entire
  `carousel-dark-*` token map / `.carousel-dark` block and dark color-mode
  rule; dark styling now rides on `light-dark()` in `.carousel-overlay`.
- Flip RTL prev/next icons with `transform: scaleX(-1)` instead of swapping
  mask images, and drop the dead commented-out overlay rules.
- Drop the now-unused `colors` and `color-mode` Sass imports.
- Docs: lead the end-behavior section with `loop` (the new default), rework
  the dark example onto `.carousel-overlay-controls` + `.btn-*` controls, and
  round the slide corners.

* Remove unused `$enable-dark-mode` Sass flag

Its last consumer was the carousel's dark color-mode block, now removed in
favor of `light-dark()`, so the flag is no longer referenced anywhere.

* Clean up carousel autoplay timer and listeners on dispose

Clear the pending autoplay timer in `dispose()` so a queued tick can't fire
after teardown and throw on the now-null `_element`, and remove the
`pointerdown` listener bound to the viewport (`.carousel-inner`)—which
`super.dispose()` doesn't cover, since it only drops listeners on `_element`.
Adds tests for both.

* Simplify carousel active indicator tokens

Drop the `--carousel-indicator-active-width` and `--carousel-indicator-active-bg`
tokens, which only carried `null` fallbacks, and inline their values directly on
the active indicator.

* Use button controls in carousel examples, fixtures, and docs

Replace the removed `.carousel-control-prev`/`.carousel-control-next` classes
with `.btn-icon btn-sm` buttons across the docs examples, homepage and cheatsheet
examples, and JS test fixtures. Also drop the leftover `.carousel-indicators-dots`
class and the obsolete "Custom transition" docs section, and correct the
peek/gap token names (`--carousel-items-peek`/`-gap`) in the migration guide.

* Remove `$enable-dark-mode` from the customization docs

Follow-up to removing the now-unused flag: drop its row from the global options
table and the sentence about disabling dark mode via Sass.

* Bump JS bundlewatch size limits for the carousel rewrite

* Harden carousel navigation, looping, and autoplay edge cases

Audit fixes:
- Measure the "current" slide in `to()` from the live scroll position
  (`_navIndex()`) rather than the async `_activeIndex`, so an indicator/control
  used mid-scroll compares against where the viewport actually rests.
- Validate the `ends` option in `_configAfterMerge`, falling back to the
  default (`loop`) for unknown values so navigation and end-control logic agree.
- Strip ids from the entire cloned subtree during a loop transition, not just
  the clone root, to avoid duplicate ids while the clone is on screen.
- Sync the active slide and fire `slid` after a programmatic scroll settles
  when no IntersectionObserver is available.
- Stop autoplay at the last slide under `ends: 'stop'` instead of re-arming a
  timer that can never advance.

Adds tests for each, plus hover-pause and dispose-mid-loop coverage.

* Remove unused `--carousel-indicator-opacity` token

The redesigned pill indicators no longer fade via opacity, so the token has no
remaining consumers.

* Expand carousel docs, examples, and migration notes

- Document the `ends` option, the removed `.carousel-control-*` / `.carousel-dark`
  classes and v5 tokens, and the removed `$enable-dark-mode` flag in the
  migration guide.
- Clarify the `.active` starting-slide and indicator notes, theme the overlay
  example controls (`.btn-subtle .theme-secondary`), rework the per-item
  interval example with a play/pause control, and sharpen the `slid` event
  description.
- Add a play/pause control to the integration fixture, and fix a corrupted
  custom-property reference in the homepage carousel example CSS.

* Bump JS bundlewatch limits for carousel audit fixes

15 files changed:
.bundlewatch.config.json
js/src/carousel.js
js/tests/integration/index.html
js/tests/unit/carousel.spec.js
js/tests/visual/carousel.html
scss/_carousel.scss
scss/_config.scss
site/src/assets/examples/carousel/carousel.css
site/src/assets/examples/carousel/index.astro
site/src/assets/examples/cheatsheet/index.astro
site/src/assets/partials/snippets.js
site/src/content/docs/components/carousel.mdx
site/src/content/docs/customize/color-modes.mdx
site/src/content/docs/customize/options.mdx
site/src/content/docs/guides/migration.mdx

index 25c981393a043c310e766063e8be0fb4d728fdf6..ebb04f0058d877b02d11c5c2cc29bb49a7d9556a 100644 (file)
     },
     {
       "path": "./dist/css/bootstrap.css",
-      "maxSize": "49.25 kB"
+      "maxSize": "49.75 kB"
     },
     {
       "path": "./dist/css/bootstrap.min.css",
-      "maxSize": "46.0 kB"
+      "maxSize": "46.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "72.75 kB"
+      "maxSize": "79.5 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
-      "maxSize": "51.0 kB"
+      "maxSize": "51.75 kB"
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "44.0 kB"
+      "maxSize": "50.75 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-      "maxSize": "28.75 kB"
+      "maxSize": "29.75 kB"
     }
   ],
   "ci": {
index 66d5b7c4994e81c6cae8abd82ebd33b0b0f74618..e9887b15eff6667081227c7fe4b0923ee6e921ef 100644 (file)
@@ -9,14 +9,7 @@ import BaseComponent from './base-component.js'
 import EventHandler from './dom/event-handler.js'
 import Manipulator from './dom/manipulator.js'
 import SelectorEngine from './dom/selector-engine.js'
-import {
-  getNextActiveElement,
-  isRTL,
-  isVisible,
-  reflow,
-  triggerTransitionEnd
-} from './util/index.js'
-import Swipe from './util/swipe.js'
+import { isRTL, isVisible } from './util/index.js'
 
 /**
  * Constants
@@ -29,10 +22,7 @@ const DATA_API_KEY = '.data-api'
 
 const ARROW_LEFT_KEY = 'ArrowLeft'
 const ARROW_RIGHT_KEY = 'ArrowRight'
-const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch
 
-const ORDER_NEXT = 'next'
-const ORDER_PREV = 'prev'
 const DIRECTION_LEFT = 'left'
 const DIRECTION_RIGHT = 'right'
 
@@ -41,47 +31,74 @@ const EVENT_SLID = `slid${EVENT_KEY}`
 const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
 const EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}`
 const EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}`
-const EVENT_DRAG_START = `dragstart${EVENT_KEY}`
+const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`
 const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
 const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
 
 const CLASS_NAME_CAROUSEL = 'carousel'
 const CLASS_NAME_ACTIVE = 'active'
-const CLASS_NAME_SLIDE = 'slide'
-const CLASS_NAME_END = 'carousel-item-end'
-const CLASS_NAME_START = 'carousel-item-start'
-const CLASS_NAME_NEXT = 'carousel-item-next'
-const CLASS_NAME_PREV = 'carousel-item-prev'
+const CLASS_NAME_FADE = 'carousel-fade'
+const CLASS_NAME_CENTER = 'carousel-center'
+const CLASS_NAME_AUTO = 'carousel-auto'
+const CLASS_NAME_CLONE = 'carousel-item-clone'
+const CLASS_NAME_PAUSED = 'paused'
+// Added to the root while the autoplay timer is running, so CSS can fill the
+// active indicator like a progress bar over the current slide's interval.
+const CLASS_NAME_PLAYING = 'carousel-playing'
+
+// Shipped (`--bs-`-prefixed) custom property the indicator fill animation reads
+// for its duration. The build prefixes every custom property, so the bare
+// `--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
+
+// 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
+// the viewport rests a sub-pixel past the snap offset, leaving the intended
+// slide a hair less visible than its fully-in neighbors; the tolerance prevents
+// that rounding from skipping the active index forward.
+const ACTIVE_RATIO_TOLERANCE = 0.05
 
 const SELECTOR_ACTIVE = '.active'
-const SELECTOR_ITEM = '.carousel-item'
+// Exclude transient loop clones so index math, indicators, and active-slide
+// detection only ever see the real slides.
+const SELECTOR_ITEM = `.carousel-item:not(.${CLASS_NAME_CLONE})`
 const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM
-const SELECTOR_ITEM_IMG = '.carousel-item img'
+const SELECTOR_INNER = '.carousel-inner'
 const SELECTOR_INDICATORS = '.carousel-indicators'
+const SELECTOR_PLAY_PAUSE = '.carousel-control-play-pause'
 const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]'
-const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]'
+const SELECTOR_DATA_SLIDE_PREV = '[data-bs-slide="prev"]'
+const SELECTOR_DATA_SLIDE_NEXT = '[data-bs-slide="next"]'
+const SELECTOR_DATA_AUTOPLAY = '[data-bs-autoplay="true"]'
 
 const KEY_TO_DIRECTION = {
   [ARROW_LEFT_KEY]: DIRECTION_RIGHT,
   [ARROW_RIGHT_KEY]: DIRECTION_LEFT
 }
 
+const ENDS_STOP = 'stop'
+const ENDS_WRAP = 'wrap'
+const ENDS_LOOP = 'loop'
+
 const Default = {
+  autoplay: false,
+  ends: ENDS_LOOP,
   interval: 5000,
   keyboard: true,
-  pause: 'hover',
-  ride: false,
-  touch: true,
-  wrap: true
+  pause: 'hover'
 }
 
 const DefaultType = {
+  autoplay: 'boolean',
+  ends: 'string',
   interval: 'number',
   keyboard: 'boolean',
-  pause: '(string|boolean)',
-  ride: '(boolean|string)',
-  touch: 'boolean',
-  wrap: 'boolean'
+  pause: '(string|boolean)'
 }
 
 /**
@@ -92,18 +109,41 @@ class Carousel extends BaseComponent {
   constructor(element, config) {
     super(element, config)
 
+    // The scroll viewport. The browser owns sliding, dragging, momentum, and
+    // keyboard scrolling; this controller only layers on autoplay, the
+    // prev/next/indicator controls, and active-slide syncing.
+    this._viewport = SelectorEngine.findOne(SELECTOR_INNER, this._element) || this._element
+    this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
+    this._playPauseElement = SelectorEngine.findOne(SELECTOR_PLAY_PAUSE, this._element)
+    // Prev/next controls scoped to the carousel root (covers inline and stacked
+    // layouts). External controls placed outside `.carousel` aren't managed.
+    this._prevControls = SelectorEngine.find(SELECTOR_DATA_SLIDE_PREV, this._element)
+    this._nextControls = SelectorEngine.find(SELECTOR_DATA_SLIDE_NEXT, this._element)
+
     this._interval = null
-    this._activeElement = null
-    this._isSliding = false
-    this.touchTimeout = null
-    this._swipeHelper = null
+    this._observer = null
+    this._snapRestoreFrame = null
+    // True while a seamless loop transition is animating, so the
+    // IntersectionObserver and re-entrant navigation don't interfere.
+    this._looping = false
+    this._visibility = new Map()
+    // Runtime autoplay intent. Starts from the `autoplay` option, but is turned
+    // off once the user takes control (clicks a control, uses the keyboard,
+    // swipes/drags, or presses pause) so we don't move content out from under
+    // them (WCAG 2.2.2 Pause, Stop, Hide).
+    this._playing = this._config.autoplay
+
+    this._activeIndex = this._initialActiveIndex()
 
-    this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
     this._addEventListeners()
+    this._observeItems()
+    this._refreshActiveState()
 
-    if (this._config.ride === CLASS_NAME_CAROUSEL) {
+    if (this._playing) {
       this.cycle()
     }
+
+    this._updatePlayPauseControl()
   }
 
   // Getters
@@ -121,84 +161,134 @@ class Carousel extends BaseComponent {
 
   // Public
   next() {
-    this._slide(ORDER_NEXT)
+    this.to(this._navIndex() + 1)
   }
 
   nextWhenVisible() {
-    // Don't call next when the page isn't visible
-    // or the carousel or its parent isn't visible
+    // Don't advance when the page or the carousel isn't visible
     if (document.visibilityState === 'visible' && isVisible(this._element)) {
       this.next()
     }
   }
 
   prev() {
-    this._slide(ORDER_PREV)
+    this.to(this._navIndex() - 1)
   }
 
   pause() {
-    if (this._isSliding) {
-      triggerTransitionEnd(this._element)
-    }
-
     this._clearInterval()
+    // Freeze the indicator progress fill; it resets to empty until cycling
+    // resumes and `_scheduleAutoplay` restarts it from scratch.
+    this._element.classList.remove(CLASS_NAME_PLAYING)
   }
 
   cycle() {
     this._clearInterval()
-    this._updateInterval()
-
-    this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval)
+    this._scheduleAutoplay()
+    this._element.classList.add(CLASS_NAME_PLAYING)
   }
 
-  _maybeEnableCycle() {
-    if (!this._config.ride) {
+  to(index) {
+    // Ignore navigation while a seamless loop transition is animating
+    if (this._looping) {
       return
     }
 
-    if (this._isSliding) {
-      EventHandler.one(this._element, EVENT_SLID, () => this.cycle())
-      return
+    const items = this._getItems()
+    const rawIndex = Number.parseInt(index, 10)
+
+    // Seamless loop: continue forward/backward into a transient clone instead of
+    // the visible `wrap` jump. Only the simple single-slide scroll layout
+    // qualifies, and reduced motion falls back to the plain wrap below.
+    if (this._config.ends === ENDS_LOOP && !this._prefersReducedMotion() && this._canLoop()) {
+      if (rawIndex > items.length - 1) {
+        this._loopTransition(true)
+        return
+      }
+
+      if (rawIndex < 0) {
+        this._loopTransition(false)
+        return
+      }
     }
 
-    this.cycle()
-  }
+    const targetIndex = this._normalizeIndex(rawIndex, items.length)
+    // Measure "current" from the live scroll position: `_activeIndex` updates
+    // asynchronously, so an indicator/control used mid-scroll must compare
+    // against where the viewport actually rests (`_navIndex` returns the tracked
+    // active index for fade/non-scrollable layouts).
+    const currentIndex = this._navIndex()
 
-  to(index) {
-    const items = this._getItems()
-    if (index > items.length - 1 || index < 0) {
+    if (targetIndex === null || targetIndex === currentIndex) {
       return
     }
 
-    if (this._isSliding) {
-      EventHandler.one(this._element, EVENT_SLID, () => this.to(index))
+    const slideEvent = EventHandler.trigger(this._element, EVENT_SLIDE, {
+      relatedTarget: items[targetIndex],
+      direction: this._direction(currentIndex, targetIndex),
+      from: currentIndex,
+      to: targetIndex
+    })
+
+    if (slideEvent.defaultPrevented) {
       return
     }
 
-    const activeIndex = this._getItemIndex(this._getActive())
-    if (activeIndex === index) {
+    if (this._isFade()) {
+      this._fadeTo(targetIndex)
       return
     }
 
-    const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV
-
-    this._slide(order, items[index])
+    // Scroll mode: the IntersectionObserver fires `slid` and syncs state once
+    // the new slide settles into view.
+    this._scrollToIndex(targetIndex)
   }
 
   dispose() {
-    if (this._swipeHelper) {
-      this._swipeHelper.dispose()
+    // Stop autoplay first: otherwise a pending timer would fire after the
+    // instance is torn down and throw on the now-null `_element`.
+    this._clearInterval()
+
+    if (this._observer) {
+      this._observer.disconnect()
+    }
+
+    if (this._snapRestoreFrame !== null) {
+      cancelAnimationFrame(this._snapRestoreFrame)
+    }
+
+    // Tidy up any in-flight loop transition: drop a stray clone and restore
+    // native snapping, so the viewport isn't left mid-animation.
+    for (const clone of SelectorEngine.find(`.${CLASS_NAME_CLONE}`, this._viewport)) {
+      clone.remove()
     }
 
+    this._viewport.style.scrollSnapType = ''
+
+    // The pointerdown listener lives on the viewport (`.carousel-inner`), which
+    // `super.dispose()` doesn't clean up—it only drops listeners on `_element`.
+    EventHandler.off(this._viewport, EVENT_KEY)
+
     super.dispose()
   }
 
   // Private
+  // Normalize an unknown `ends` value so navigation and end-control logic can't
+  // disagree about whether the carousel wraps.
   _configAfterMerge(config) {
-    config.defaultInterval = config.interval
+    if (![ENDS_STOP, ENDS_WRAP, ENDS_LOOP].includes(config.ends)) {
+      config.ends = Default.ends
+    }
+
     return config
   }
 
+  _initialActiveIndex() {
+    const active = SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element)
+    const index = active ? this._getItems().indexOf(active) : 0
+    return Math.max(index, 0)
+  }
+
   _addEventListeners() {
     if (this._config.keyboard) {
       EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
@@ -209,196 +299,591 @@ class Carousel extends BaseComponent {
       EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle())
     }
 
-    if (this._config.touch && Swipe.isSupported()) {
-      this._addTouchEventListeners()
-    }
+    // Dragging, swiping, or tapping the track is an explicit interaction
+    EventHandler.on(this._viewport, EVENT_POINTERDOWN, () => this._pauseFromInteraction())
   }
 
-  _addTouchEventListeners() {
-    for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {
-      EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault())
+  _keydown(event) {
+    if (/input|textarea/i.test(event.target.tagName)) {
+      return
     }
 
-    const endCallBack = () => {
-      if (this._config.pause !== 'hover') {
-        return
+    const direction = KEY_TO_DIRECTION[event.key]
+    if (direction) {
+      event.preventDefault()
+      this._pauseFromInteraction()
+      if (direction === DIRECTION_RIGHT) {
+        this.prev()
+      } else {
+        this.next()
       }
+    }
+  }
 
-      // If it's a touch-enabled device, mouseenter/leave are fired as
-      // part of the mouse compatibility events on first tap - the carousel
-      // would stop cycling until user tapped out of it;
-      // here, we listen for touchend, explicitly pause the carousel
-      // (as if it's the second time we tap on it, mouseenter compat event
-      // is NOT fired) and after a timeout (to allow for mouse compatibility
-      // events to fire) we explicitly restart cycling
-
-      this.pause()
-      if (this.touchTimeout) {
-        clearTimeout(this.touchTimeout)
-      }
+  _observeItems() {
+    // Fade mode stacks slides instead of scrolling, so there's nothing to observe
+    if (this._isFade() || typeof IntersectionObserver === 'undefined') {
+      return
+    }
+
+    this._observer = new IntersectionObserver(
+      entries => this._handleIntersection(entries),
+      { root: this._viewport, threshold: [0, 0.25, 0.5, 0.75, 1] }
+    )
+
+    for (const item of this._getItems()) {
+      this._observer.observe(item)
+    }
+  }
+
+  _handleIntersection(entries) {
+    // A loop transition deliberately scrolls onto a transient clone; ignore the
+    // visibility churn so it doesn't move the active index mid-animation.
+    if (this._looping) {
+      return
+    }
 
-      this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval)
+    for (const entry of entries) {
+      this._visibility.set(entry.target, entry.isIntersecting ? entry.intersectionRatio : 0)
     }
 
-    const swipeConfig = {
-      leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)),
-      rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)),
-      endCallback: endCallBack
+    const items = this._getItems()
+    const ratios = items.map(item => this._visibility.get(item) ?? 0)
+    const maxRatio = Math.max(...ratios)
+
+    // Pick the left-most slide that's *near* fully visible rather than the strict
+    // global maximum. After a programmatic scroll the viewport rests ~1px past
+    // the target snap offset, so the intended left-most slide reports a ratio a
+    // hair below the deeper, fully-visible ones (e.g. 0.997 vs 1.0). A strict max
+    // would skip past it and inflate the active index by one, which breaks
+    // multi-item next/prev. The tolerance keeps the intended slide active while
+    // peeking slivers (well below the max) are still ignored.
+    let bestIndex = this._activeIndex
+
+    if (maxRatio > 0) {
+      bestIndex = ratios.findIndex(ratio => ratio >= maxRatio - ACTIVE_RATIO_TOLERANCE)
     }
 
-    this._swipeHelper = new Swipe(this._element, swipeConfig)
+    this._setActive(bestIndex)
+    // Keep the end controls in sync with the scroll position even when the
+    // active index doesn't change (e.g. the final stretch of a multi-item
+    // scroll, where the left-most slide is already the last reachable one).
+    this._updateEndControls()
   }
 
-  _keydown(event) {
-    if (/input|textarea/i.test(event.target.tagName)) {
+  // The index a `next()`/`prev()` step is measured from. Scroll layouts read it
+  // from the live scroll position instead of `this._activeIndex`, because the
+  // IntersectionObserver updates that asynchronously: after one step the index
+  // can still be stale, so the next step would compute the same target and
+  // silently no-op (the "the button does nothing / can't reach the end slide"
+  // symptom). Fade and non-scrollable layouts have no scroll position to read,
+  // so they keep using the tracked active index (also what the unit tests rely
+  // on when there's no real layout).
+  _navIndex() {
+    if (this._isFade() || (this._viewport.scrollWidth - this._viewport.clientWidth) <= 0) {
+      return this._activeIndex
+    }
+
+    let index = this._activeIndex
+    let smallestDelta = Number.POSITIVE_INFINITY
+
+    for (const [itemIndex, item] of this._getItems().entries()) {
+      // The slide currently resting at the active position has ~zero delta.
+      const delta = Math.abs(this._scrollDelta(item))
+      if (delta < smallestDelta) {
+        smallestDelta = delta
+        index = itemIndex
+      }
+    }
+
+    return index
+  }
+
+  _scrollToIndex(index) {
+    const item = this._getItems()[index]
+    if (!item) {
       return
     }
 
-    const direction = KEY_TO_DIRECTION[event.key]
-    if (direction) {
-      event.preventDefault()
-      this._slide(this._directionToOrder(direction))
+    const left = this._scrollDelta(item)
+    if (Math.abs(left) < 1) {
+      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).
+    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._restoreSnapWhenSettled(targetLeft, index)
   }
 
-  _getItemIndex(element) {
-    return this._getItems().indexOf(element)
+  // Horizontal distance to scroll the viewport so `element` rests where the
+  // active slide should sit. Scroll the viewport itself rather than calling
+  // `element.scrollIntoView()`: the latter scrolls *every* scrollable ancestor
+  // (including the page), so an autoplaying carousel below the fold would yank
+  // the whole page to itself on each tick. Using bounding rects keeps it
+  // direction-agnostic (works in RTL).
+  _scrollDelta(element) {
+    const viewportRect = this._viewport.getBoundingClientRect()
+    const rect = element.getBoundingClientRect()
+
+    if (this._element.classList.contains(CLASS_NAME_CENTER)) {
+      return (rect.left + (rect.width / 2)) - (viewportRect.left + (viewportRect.width / 2))
+    }
+
+    // Start alignment: rest the slide at the scroll-padding (peek) offset, which
+    // is exactly where scroll-snap will settle. Aligning flush to the edge
+    // instead would make the browser re-snap by `peek` once snapping is restored,
+    // producing a visible secondary nudge after the programmatic scroll.
+    const padStart = Number.parseFloat(getComputedStyle(this._viewport).scrollPaddingInlineStart) || 0
+
+    return isRTL() ?
+      rect.right - (viewportRect.right - padStart) :
+      rect.left - (viewportRect.left + padStart)
   }
 
-  _setActiveIndicatorElement(index) {
-    if (!this._indicatorsElement) {
+  // Seamless loop: continue past an end into a one-off clone of the destination
+  // slide, then teleport to the real slide so there's no visible backward jump.
+  _loopTransition(isNext) {
+    const items = this._getItems()
+    const last = items.length - 1
+    const fromIndex = this._activeIndex
+    const toIndex = isNext ? 0 : last
+    const direction = this._loopDirection(isNext)
+
+    const slideEvent = EventHandler.trigger(this._element, EVENT_SLIDE, {
+      relatedTarget: items[toIndex],
+      direction,
+      from: fromIndex,
+      to: toIndex
+    })
+
+    if (slideEvent.defaultPrevented) {
       return
     }
 
-    const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement)
+    this._looping = true
+
+    const clone = (isNext ? items[0] : items[last]).cloneNode(true)
+    clone.classList.add(CLASS_NAME_CLONE)
+    clone.classList.remove(CLASS_NAME_ACTIVE)
+    clone.removeAttribute('id')
+    // Also strip ids from the cloned subtree to avoid duplicate ids while the
+    // clone is on screen.
+    for (const node of SelectorEngine.find('[id]', clone)) {
+      node.removeAttribute('id')
+    }
 
-    activeIndicator.classList.remove(CLASS_NAME_ACTIVE)
-    activeIndicator.removeAttribute('aria-current')
+    clone.setAttribute('aria-hidden', 'true')
+    clone.inert = true
 
-    const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement)
+    this._viewport.style.scrollSnapType = 'none'
 
-    if (newActiveIndicator) {
-      newActiveIndicator.classList.add(CLASS_NAME_ACTIVE)
-      newActiveIndicator.setAttribute('aria-current', 'true')
+    if (isNext) {
+      this._viewport.append(clone)
+    } else {
+      this._viewport.prepend(clone)
+      // Prepending shifts the real slides to the right; instantly re-align the
+      // current slide so the insertion doesn't flash before we animate.
+      this._jumpScroll(this._scrollDelta(items[fromIndex]))
     }
-  }
 
-  _updateInterval() {
-    const element = this._activeElement || this._getActive()
+    this._viewport.scrollBy({
+      left: this._scrollDelta(clone),
+      top: 0,
+      behavior: 'smooth'
+    })
+
+    this._afterScrollSettles(() => {
+      // 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).
+      clone.remove()
+      this._jumpScroll(this._scrollDelta(items[toIndex]))
+
+      this._activeIndex = toIndex
+      this._refreshActiveState()
+
+      EventHandler.trigger(this._element, EVENT_SLID, {
+        relatedTarget: items[toIndex],
+        direction,
+        from: fromIndex,
+        to: toIndex
+      })
+
+      this._viewport.style.scrollSnapType = ''
+      this._looping = false
+    })
+  }
 
-    if (!element) {
-      return
+  _loopDirection(isNext) {
+    if (isRTL()) {
+      return isNext ? DIRECTION_RIGHT : DIRECTION_LEFT
     }
 
-    const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10)
+    return isNext ? DIRECTION_LEFT : DIRECTION_RIGHT
+  }
+
+  // Instant (non-animated) scroll with snapping suspended, used to teleport the
+  // viewport during a loop transition. `behavior: 'instant'` is required because
+  // the viewport sets `scroll-behavior: smooth` in CSS, and `'auto'` would defer
+  // to it and animate the teleport (a visible backward slide).
+  _jumpScroll(delta) {
+    this._viewport.style.scrollSnapType = 'none'
+    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)
+      }
 
-    this._config.interval = elementInterval || this._config.defaultInterval
+      // 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)
   }
 
-  _slide(order, element = null) {
-    if (this._isSliding) {
+  // 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
     }
 
-    const activeElement = this._getActive()
-    const isNext = order === ORDER_NEXT
-    const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap)
+    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`
+  // mixin). It deliberately avoids the View Transition API: a view transition
+  // crossfades a page snapshot over its own (shorter) duration while this CSS
+  // fade also runs underneath, so the two animations overlap and visibly stutter.
+  _fadeTo(index) {
+    this._setActive(index)
+  }
 
-    if (nextElement === activeElement) {
+  _setActive(index) {
+    const items = this._getItems()
+    if (index === this._activeIndex || !items[index]) {
       return
     }
 
-    const nextElementIndex = this._getItemIndex(nextElement)
+    const from = this._activeIndex
 
-    const triggerEvent = eventName => {
-      return EventHandler.trigger(this._element, eventName, {
-        relatedTarget: nextElement,
-        direction: this._orderToDirection(order),
-        from: this._getItemIndex(activeElement),
-        to: nextElementIndex
-      })
+    this._activeIndex = index
+    this._refreshActiveState()
+
+    EventHandler.trigger(this._element, EVENT_SLID, {
+      relatedTarget: items[index],
+      direction: this._direction(from, index),
+      from,
+      to: index
+    })
+  }
+
+  _refreshActiveState() {
+    const items = this._getItems()
+
+    for (const [index, item] of items.entries()) {
+      item.classList.toggle(CLASS_NAME_ACTIVE, index === this._activeIndex)
     }
 
-    const slideEvent = triggerEvent(EVENT_SLIDE)
+    this._setActiveIndicatorElement(this._activeIndex)
+    this._updateEndControls()
+  }
 
-    if (slideEvent.defaultPrevented) {
+  _updateEndControls() {
+    // Only `ends: 'stop'` has real ends; under `wrap`/`loop` you can always
+    // advance, so disabling end controls would be meaningless. When stopping,
+    // disable the prev control at the start of the scroll range and the next
+    // control at the end so there are no dead end-buttons.
+    if (this._config.ends !== ENDS_STOP) {
       return
     }
 
-    if (!activeElement || !nextElement) {
-      // Some weirdness is happening, so we bail
-      return
+    const viewport = this._viewport
+    const maxScroll = viewport.scrollWidth - viewport.clientWidth
+
+    let atStart
+    let atEnd
+
+    if (maxScroll > 0) {
+      // Scrollable: measure the real scroll extent so this works for multi-item,
+      // peek, and variable-width layouts where the last slide can never become
+      // the left-most (active) one. `Math.abs` keeps it correct in RTL, where
+      // `scrollLeft` runs from 0 down to negative.
+      const progress = Math.abs(viewport.scrollLeft)
+      atStart = progress <= 1
+      atEnd = progress >= maxScroll - 1
+    } else {
+      // Not scrollable (or no layout yet, e.g. in unit tests): fall back to the
+      // active index for the single-slide case.
+      const last = this._getItems().length - 1
+      atStart = this._activeIndex <= 0
+      atEnd = this._activeIndex >= last
     }
 
-    const isCycling = Boolean(this._interval)
-    this.pause()
+    this._setControlsDisabled(this._prevControls, atStart)
+    this._setControlsDisabled(this._nextControls, atEnd)
+  }
+
+  _setControlsDisabled(controls, disabled) {
+    for (const control of controls) {
+      // a11y: if we're about to disable the focused control, move focus to the
+      // opposite (still-enabled) control so focus isn't lost.
+      if (disabled && control === document.activeElement) {
+        const opposite = controls === this._prevControls ? this._nextControls : this._prevControls
+        const fallback = opposite[0] ?? this._viewport
+        // `preventScroll` so moving focus doesn't yank the page/viewport to the
+        // newly-focused control mid-navigation.
+        fallback.focus({ preventScroll: true })
+      }
 
-    this._isSliding = true
+      control.disabled = disabled
+    }
+  }
 
-    this._setActiveIndicatorElement(nextElementIndex)
-    this._activeElement = nextElement
+  _setActiveIndicatorElement(index) {
+    if (!this._indicatorsElement) {
+      return
+    }
 
-    const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END
-    const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV
+    const active = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement)
+    if (active) {
+      active.classList.remove(CLASS_NAME_ACTIVE)
+      active.removeAttribute('aria-current')
+    }
 
-    nextElement.classList.add(orderClassName)
+    const newActive = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement)
+    if (newActive) {
+      newActive.classList.add(CLASS_NAME_ACTIVE)
+      newActive.setAttribute('aria-current', 'true')
+    }
+  }
 
-    reflow(nextElement)
+  _normalizeIndex(index, length) {
+    if (Number.isNaN(index) || length === 0) {
+      return null
+    }
 
-    activeElement.classList.add(directionalClassName)
-    nextElement.classList.add(directionalClassName)
+    if (index < 0) {
+      return this._wrapsAround() ? length - 1 : null
+    }
 
-    const completeCallBack = () => {
-      nextElement.classList.remove(directionalClassName, orderClassName)
-      nextElement.classList.add(CLASS_NAME_ACTIVE)
+    if (index > length - 1) {
+      return this._wrapsAround() ? 0 : null
+    }
 
-      activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName)
+    return index
+  }
 
-      this._isSliding = false
+  // Whether navigating past an end wraps to the other end. `loop` continues
+  // seamlessly where it can (see `_canLoop`) and otherwise behaves like `wrap`.
+  _wrapsAround() {
+    return this._config.ends === ENDS_WRAP || this._config.ends === ENDS_LOOP
+  }
 
-      triggerEvent(EVENT_SLID)
+  // Seamless looping is only supported for the simple single-slide scroll
+  // layout. Multi-item, peek, center, and variable-width layouts fall back to
+  // the plain `wrap` jump.
+  _canLoop() {
+    if (this._isFade() || this._getItems().length < 2) {
+      return false
     }
 
-    this._queueCallback(completeCallBack, activeElement, this._isAnimated())
+    const styles = getComputedStyle(this._element)
+    const num = name => Number.parseFloat(styles.getPropertyValue(name)) || 0
 
-    if (isCycling) {
-      this.cycle()
+    // These are the shipped, `--bs-`-prefixed custom properties (the build
+    // prefixes every custom property), not the bare names used in the SCSS source.
+    return (num('--bs-carousel-items') || 1) === 1 &&
+      num('--bs-carousel-items-peek') === 0 &&
+      !this._element.classList.contains(CLASS_NAME_CENTER) &&
+      !this._element.classList.contains(CLASS_NAME_AUTO)
+  }
+
+  _direction(from, to) {
+    const isNext = to > from
+    if (isRTL()) {
+      return isNext ? DIRECTION_RIGHT : DIRECTION_LEFT
     }
+
+    return isNext ? DIRECTION_LEFT : DIRECTION_RIGHT
   }
 
-  _isAnimated() {
-    return this._element.classList.contains(CLASS_NAME_SLIDE)
+  _scheduleAutoplay(index = this._activeIndex) {
+    const interval = this._itemInterval(index)
+    // Expose the wait so the active indicator's CSS fill matches it.
+    this._element.style.setProperty(PROPERTY_INTERVAL, `${interval}ms`)
+    this._interval = setTimeout(() => {
+      // Capture the slide the advance lands on *before* navigating: the active
+      // index only updates once the scroll settles (asynchronously), so reading
+      // it after `nextWhenVisible()` would schedule the next wait from the slide
+      // we're leaving — making per-item `data-bs-interval`s lag by one slide.
+      const upcoming = this._upcomingIndex()
+      this.nextWhenVisible()
+
+      // Nothing comes after the last slide when `ends: 'stop'`; stop cycling
+      // instead of re-arming a timer that can never advance.
+      if (upcoming === null) {
+        this.pause()
+        return
+      }
+
+      this._scheduleAutoplay(upcoming)
+    }, interval)
   }
 
-  _getActive() {
-    return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element)
+  // The slide the next autoplay tick will rest on, derived from the live scroll
+  // position (which still reflects the current slide when the timer fires).
+  // Returns `null` when there's nowhere left to advance (`ends: stop` at the end).
+  _upcomingIndex() {
+    return this._normalizeIndex(this._navIndex() + 1, this._getItems().length)
   }
 
-  _getItems() {
-    return SelectorEngine.find(SELECTOR_ITEM, this._element)
+  _itemInterval(index = this._activeIndex) {
+    const item = this._getItems()[index]
+    const interval = item ? Number.parseInt(item.getAttribute('data-bs-interval'), 10) : Number.NaN
+    return Number.isNaN(interval) ? this._config.interval : interval
   }
 
-  _clearInterval() {
-    if (this._interval) {
-      clearInterval(this._interval)
-      this._interval = null
+  _maybeEnableCycle() {
+    if (!this._playing) {
+      return
     }
+
+    this.cycle()
   }
 
-  _directionToOrder(direction) {
-    if (isRTL()) {
-      return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT
+  // Turn autoplay off for good once the user interacts with the carousel
+  _pauseFromInteraction() {
+    this._playing = false
+    this.pause()
+    this._updatePlayPauseControl()
+  }
+
+  _togglePlayPause() {
+    if (this._playing) {
+      this._pauseFromInteraction()
+      return
     }
 
-    return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV
+    this._playing = true
+    this.cycle()
+    this._updatePlayPauseControl()
   }
 
-  _orderToDirection(order) {
-    if (isRTL()) {
-      return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT
+  _updatePlayPauseControl() {
+    if (!this._playPauseElement) {
+      return
+    }
+
+    this._playPauseElement.classList.toggle(CLASS_NAME_PAUSED, !this._playing)
+
+    const label = this._playPauseElement.getAttribute(
+      this._playing ? 'data-bs-pause-label' : 'data-bs-play-label'
+    )
+
+    if (label) {
+      this._playPauseElement.setAttribute('aria-label', label)
     }
+  }
 
-    return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT
+  _isFade() {
+    return this._element.classList.contains(CLASS_NAME_FADE)
+  }
+
+  _prefersReducedMotion() {
+    return typeof window !== 'undefined' &&
+      typeof window.matchMedia === 'function' &&
+      window.matchMedia('(prefers-reduced-motion: reduce)').matches
+  }
+
+  _getItems() {
+    return SelectorEngine.find(SELECTOR_ITEM, this._element)
+  }
+
+  _clearInterval() {
+    if (this._interval) {
+      clearTimeout(this._interval)
+      this._interval = null
+    }
   }
 }
 
@@ -416,26 +901,39 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (e
   event.preventDefault()
 
   const carousel = Carousel.getOrCreateInstance(target)
+
+  // Manually cycling the carousel is an explicit interaction, so stop autoplay
+  carousel._pauseFromInteraction()
+
   const slideIndex = this.getAttribute('data-bs-slide-to')
 
   if (slideIndex) {
     carousel.to(slideIndex)
-    carousel._maybeEnableCycle()
     return
   }
 
   if (Manipulator.getDataAttribute(this, 'slide') === 'next') {
     carousel.next()
-    carousel._maybeEnableCycle()
     return
   }
 
   carousel.prev()
-  carousel._maybeEnableCycle()
+})
+
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_PLAY_PAUSE, function (event) {
+  const target = SelectorEngine.getElementFromSelector(this)
+
+  if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) {
+    return
+  }
+
+  event.preventDefault()
+
+  Carousel.getOrCreateInstance(target)._togglePlayPause()
 })
 
 EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
-  const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE)
+  const carousels = SelectorEngine.find(SELECTOR_DATA_AUTOPLAY)
 
   for (const carousel of carousels) {
     Carousel.getOrCreateInstance(carousel)
index e4fa9f0a382d204d8676131f891bd73478e9f15b..dffe29fc2bfd8a2b8cc87e592595927ebb792c62 100644 (file)
           Tooltip on top
         </button>
 
-        <div id="carouselExampleIndicators" class="carousel slide mt-2" data-bs-ride="carousel">
-          <div class="carousel-indicators">
-            <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="0" aria-label="Slide 1"></button>
-            <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="1" class="active" aria-current="true" aria-label="Slide 2"></button>
-            <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="2" aria-label="Slide 3"></button>
-          </div>
-
+        <div id="carouselExampleIndicators" class="carousel slide mt-2" data-bs-autoplay="true">
           <div class="carousel-inner">
             <div class="carousel-item">
               <img class="d-block w-100" alt="First slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3EFirst%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
-              <div class="carousel-caption d-none md:d-block">
-                <h5>First slide label</h5>
-                <p>Nulla vitae elit libero, a pharetra augue mollis interdum.</p>
-              </div>
             </div>
             <div class="carousel-item active">
               <img class="d-block w-100" alt="Second slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3ESecond%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
-              <div class="carousel-caption d-none md:d-block">
-                <h5>Second slide label</h5>
-                <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
-              </div>
             </div>
             <div class="carousel-item">
               <img class="d-block w-100" alt="Third slide" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22800%22%20height%3D%22400%22%20preserveAspectRatio%3D%22none%22%20viewBox%3D%220%200%20800%20400%22%3E%3Cpath%20fill%3D%22%23777%22%20d%3D%22M0%200h800v400H0z%22%2F%3E%3Ctext%20x%3D%22285.922%22%20y%3D%22217.7%22%20fill%3D%22%23555%22%20font-family%3D%22Helvetica%2Cmonospace%22%20font-size%3D%2240pt%22%20font-weight%3D%22400%22%3EThird%20slide%3C%2Ftext%3E%3C%2Fsvg%3E">
-              <div class="carousel-caption d-none md:d-block">
-                <h5>Third slide label</h5>
-                <p>Praesent commodo cursus magna, vel scelerisque nisl consectetur.</p>
-              </div>
             </div>
           </div>
 
-          <a class="carousel-control-prev" href="#carouselExampleIndicators" role="button" data-bs-slide="prev">
-            <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-            <span class="visually-hidden">Previous</span>
-          </a>
-          <a class="carousel-control-next" href="#carouselExampleIndicators" role="button" data-bs-slide="next">
-            <span class="carousel-control-next-icon" aria-hidden="true"></span>
-            <span class="visually-hidden">Next</span>
-          </a>
+          <div class="d-flex justify-content-between align-items-center mt-2">
+            <div class="carousel-indicators">
+              <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="0" aria-label="Slide 1"></button>
+              <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="1" class="active" aria-current="true" aria-label="Slide 2"></button>
+              <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="2" aria-label="Slide 3"></button>
+            </div>
+            <div>
+              <button class="btn-icon btn-sm carousel-control-play-pause" type="button" data-bs-target="#carouselExampleIndicators" aria-label="Pause" data-bs-pause-label="Pause" data-bs-play-label="Play">
+                <span class="carousel-icon-pause" aria-hidden="true"></span>
+                <span class="carousel-icon-play" aria-hidden="true"></span>
+              </button>
+              <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide="prev">
+                <span class="carousel-icon-prev" aria-hidden="true"></span>
+                <span class="visually-hidden">Previous</span>
+              </button>
+              <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide="next">
+                <span class="carousel-icon-next" aria-hidden="true"></span>
+                <span class="visually-hidden">Next</span>
+              </button>
+            </div>
+          </div>
         </div>
       </div>
     </div>
index 3204fa66e191b10026d8d02252e83e836c903f76..86d7285584e4de4305689bba0722a9b33e54bd8e 100644 (file)
 import Carousel from '../../src/carousel.js'
 import EventHandler from '../../src/dom/event-handler.js'
-import { isRTL, noop } from '../../src/util/index.js'
-import Swipe from '../../src/util/swipe.js'
 import {
   clearFixture, createEvent, getFixture
 } from '../helpers/fixture.js'
 
 describe('Carousel', () => {
-  const { Simulator, PointerEvent } = window
-  const originWinPointerEvent = PointerEvent
-  const supportPointerEvent = Boolean(PointerEvent)
+  let fixtureEl
+  let realIntersectionObserver
+  let scrollBySpy
+
+  // A no-op IntersectionObserver so the real one doesn't fire during tests;
+  // active-slide syncing is driven explicitly via `_handleIntersection`.
+  class MockIntersectionObserver {
+    constructor(callback, options = {}) {
+      this.callback = callback
+      this.root = options.root ?? null
+      this.thresholds = options.threshold ?? []
+    }
 
-  const cssStyleCarousel = '.carousel.pointer-event { touch-action: none; }'
+    observe() {}
 
-  const stylesCarousel = document.createElement('style')
-  stylesCarousel.type = 'text/css'
-  stylesCarousel.append(document.createTextNode(cssStyleCarousel))
+    unobserve() {}
 
-  const clearPointerEvents = () => {
-    window.PointerEvent = null
+    disconnect() {}
   }
 
-  const restorePointerEvents = () => {
-    window.PointerEvent = originWinPointerEvent
+  const basicMarkup = ({ classes = 'carousel slide', autoplay = false, indicators = false } = {}) => {
+    const autoplayAttr = autoplay ? ' data-bs-autoplay="true"' : ''
+    const indicatorsMarkup = indicators ?
+      [
+        '  <div class="carousel-indicators">',
+        '    <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="0" class="active"></button>',
+        '    <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="1"></button>',
+        '    <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="2"></button>',
+        '  </div>'
+      ].join('') :
+      ''
+
+    return [
+      `<div id="myCarousel" class="${classes}"${autoplayAttr}>`,
+      indicatorsMarkup,
+      '  <div class="carousel-inner">',
+      '    <div id="item1" class="carousel-item active">item 1</div>',
+      '    <div id="item2" class="carousel-item">item 2</div>',
+      '    <div id="item3" class="carousel-item">item 3</div>',
+      '  </div>',
+      '</div>'
+    ].join('')
   }
 
-  let fixtureEl
+  // Give the viewport and items a deterministic horizontal layout so the
+  // bounding-rect maths in `_scrollToIndex` produces predictable scroll deltas.
+  // (Unit tests run without the carousel CSS, so real layout would collapse
+  // every item to the same x and yield a zero delta.)
+  const stubLayout = (carousel, { viewportLeft = 0, viewportWidth = 300, itemWidth = 100, gap = 0 } = {}) => {
+    const rect = (left, width) => ({
+      left, width, right: left + width, top: 0, bottom: 0, height: 0, x: left, y: 0, toJSON() {}
+    })
+
+    spyOn(carousel._viewport, 'getBoundingClientRect').and.returnValue(rect(viewportLeft, viewportWidth))
+
+    for (const [index, item] of carousel._getItems().entries()) {
+      spyOn(item, 'getBoundingClientRect').and.returnValue(rect(viewportLeft + (index * (itemWidth + gap)), itemWidth))
+    }
+  }
+
+  const intersect = (carousel, ratios) => {
+    const items = carousel._getItems()
+    const entries = items.map((target, index) => ({
+      target,
+      isIntersecting: (ratios[index] ?? 0) > 0,
+      intersectionRatio: ratios[index] ?? 0
+    }))
+    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()
   })
 
+  beforeEach(() => {
+    realIntersectionObserver = window.IntersectionObserver
+    window.IntersectionObserver = MockIntersectionObserver
+    scrollBySpy = spyOn(Element.prototype, 'scrollBy')
+  })
+
   afterEach(() => {
+    window.IntersectionObserver = realIntersectionObserver
+    document.documentElement.dir = ''
     clearFixture()
   })
 
@@ -45,6 +115,22 @@ describe('Carousel', () => {
     it('should return plugin default config', () => {
       expect(Carousel.Default).toEqual(jasmine.any(Object))
     })
+
+    it('should default autoplay to false and pause to hover', () => {
+      expect(Carousel.Default.autoplay).toBeFalse()
+      expect(Carousel.Default.pause).toEqual('hover')
+    })
+
+    it('should default `ends` to `loop`', () => {
+      expect(Carousel.Default.ends).toEqual('loop')
+    })
+
+    it('should fall back to the default `ends` for unknown values', () => {
+      fixtureEl.innerHTML = basicMarkup()
+
+      const carousel = new Carousel('#myCarousel', { ends: 'nope' })
+      expect(carousel._config.ends).toEqual('loop')
+    })
   })
 
   describe('DATA_KEY', () => {
@@ -55,7 +141,7 @@ describe('Carousel', () => {
 
   describe('constructor', () => {
     it('should take care of element either passed as a CSS selector or DOM element', () => {
-      fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
+      fixtureEl.innerHTML = basicMarkup()
 
       const carouselEl = fixtureEl.querySelector('#myCarousel')
       const carouselBySelector = new Carousel('#myCarousel')
@@ -65,1448 +151,1298 @@ describe('Carousel', () => {
       expect(carouselByElement._element).toEqual(carouselEl)
     })
 
-    it('should start cycling if `ride`===`carousel`', () => {
-      fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="carousel"></div>'
+    it('should find the scroll viewport, indicators and play/pause control', () => {
+      fixtureEl.innerHTML = basicMarkup({ indicators: true })
 
       const carousel = new Carousel('#myCarousel')
-      expect(carousel._interval).not.toBeNull()
-    })
 
-    it('should not start cycling if `ride`!==`carousel`', () => {
-      fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="true"></div>'
-
-      const carousel = new Carousel('#myCarousel')
-      expect(carousel._interval).toBeNull()
+      expect(carousel._viewport).toEqual(fixtureEl.querySelector('.carousel-inner'))
+      expect(carousel._indicatorsElement).toEqual(fixtureEl.querySelector('.carousel-indicators'))
     })
 
-    it('should go to next item if right arrow key is pressed', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div id="item2" class="carousel-item">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
-
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, {
-          keyboard: true
-        })
-
-        const spy = spyOn(carousel, '_keydown').and.callThrough()
-
-        carouselEl.addEventListener('slid.bs.carousel', () => {
-          expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2'))
-          expect(spy).toHaveBeenCalled()
-          resolve()
-        })
-
-        const keydown = createEvent('keydown')
-        keydown.key = 'ArrowRight'
-
-        carouselEl.dispatchEvent(keydown)
-      })
-    })
-
-    it('should ignore keyboard events if data-bs-keyboard=false', () => {
+    it('should set the initial active index from the active item', () => {
       fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide" data-bs-keyboard="false">',
+        '<div id="myCarousel" class="carousel slide">',
         '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div id="item2" class="carousel-item">item 2</div>',
+        '    <div class="carousel-item">item 1</div>',
+        '    <div class="carousel-item active">item 2</div>',
+        '    <div class="carousel-item">item 3</div>',
         '  </div>',
         '</div>'
       ].join('')
 
-      const spy = spyOn(EventHandler, 'trigger').and.callThrough()
-      const carouselEl = fixtureEl.querySelector('#myCarousel')
-      // eslint-disable-next-line no-new
-      new Carousel('#myCarousel')
-      expect(spy).not.toHaveBeenCalledWith(carouselEl, 'keydown.bs.carousel', jasmine.any(Function))
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._activeIndex).toEqual(1)
     })
 
-    it('should ignore mouse events if data-bs-pause=false', () => {
-      fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide" data-bs-pause="false">',
-        '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div id="item2" class="carousel-item">item 2</div>',
-        '  </div>',
-        '</div>'
-      ].join('')
+    it('should start cycling if `autoplay` is `true`', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
 
-      const spy = spyOn(EventHandler, 'trigger').and.callThrough()
-      const carouselEl = fixtureEl.querySelector('#myCarousel')
-      // eslint-disable-next-line no-new
-      new Carousel('#myCarousel')
-      expect(spy).not.toHaveBeenCalledWith(carouselEl, 'hover.bs.carousel', jasmine.any(Function))
-    })
-
-    it('should go to previous item if left arrow key is pressed', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div id="item1" class="carousel-item">item 1</div>',
-          '    <div class="carousel-item active">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
-
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, {
-          keyboard: true
-        })
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._interval).not.toBeNull()
+      expect(carousel._playing).toBeTrue()
+    })
 
-        const spy = spyOn(carousel, '_keydown').and.callThrough()
+    it('should not start cycling if `autoplay` is not `true`', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        carouselEl.addEventListener('slid.bs.carousel', () => {
-          expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1'))
-          expect(spy).toHaveBeenCalled()
-          resolve()
-        })
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._interval).toBeNull()
+    })
 
-        const keydown = createEvent('keydown')
-        keydown.key = 'ArrowLeft'
+    it('should observe items for active syncing', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        carouselEl.dispatchEvent(keydown)
-      })
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._observer).toEqual(jasmine.any(MockIntersectionObserver))
+      expect(carousel._observer.root).toEqual(carousel._viewport)
     })
 
-    it('should not prevent keydown if key is not ARROW_LEFT or ARROW_RIGHT', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div class="carousel-item">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
+    it('should not observe items in fade mode', () => {
+      fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-fade' })
 
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, {
-          keyboard: true
-        })
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._observer).toBeNull()
+    })
 
-        const spy = spyOn(carousel, '_keydown').and.callThrough()
+    it('should fall back to the element as the viewport when there is no inner', () => {
+      fixtureEl.innerHTML = '<div id="myCarousel" class="carousel"><div class="carousel-item active">item 1</div></div>'
 
-        carouselEl.addEventListener('keydown', event => {
-          expect(spy).toHaveBeenCalled()
-          expect(event.defaultPrevented).toBeFalse()
-          resolve()
-        })
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._viewport).toEqual(carousel._element)
+    })
 
-        const keydown = createEvent('keydown')
-        keydown.key = 'ArrowDown'
+    it('should not create an observer when IntersectionObserver is unavailable', () => {
+      const original = window.IntersectionObserver
+      window.IntersectionObserver = undefined
 
-        carouselEl.dispatchEvent(keydown)
-      })
+      try {
+        fixtureEl.innerHTML = basicMarkup()
+
+        const carousel = new Carousel('#myCarousel')
+        expect(carousel._observer).toBeNull()
+      } finally {
+        window.IntersectionObserver = original
+      }
     })
+  })
 
-    it('should ignore keyboard events within <input>s and <textarea>s', () => {
-      fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide">',
-        '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">',
-        '      <input type="text">',
-        '      <textarea></textarea>',
-        '    </div>',
-        '    <div class="carousel-item"></div>',
-        '    <div class="carousel-item">item 3</div>',
-        '  </div>',
-        '</div>'
-      ].join('')
+  describe('navigation', () => {
+    it('should scroll the viewport to the next item and fire `slide`', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
       const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const input = fixtureEl.querySelector('input')
-      const textarea = fixtureEl.querySelector('textarea')
-      const carousel = new Carousel(carouselEl, {
-        keyboard: true
-      })
-
-      const spyKeydown = spyOn(carousel, '_keydown').and.callThrough()
-      const spySlide = spyOn(carousel, '_slide')
+      const carousel = new Carousel(carouselEl)
+      stubLayout(carousel)
+      const slideSpy = jasmine.createSpy('slide')
+      EventHandler.on(carouselEl, 'slide.bs.carousel', slideSpy)
 
-      const keydown = createEvent('keydown', { bubbles: true, cancelable: true })
-      keydown.key = 'ArrowRight'
-      Object.defineProperty(keydown, 'target', {
-        value: input,
-        writable: true,
-        configurable: true
-      })
+      carousel.next()
 
-      input.dispatchEvent(keydown)
+      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(slideSpy).toHaveBeenCalledTimes(1)
+      expect(slideSpy.calls.mostRecent().args[0].to).toEqual(1)
+      expect(slideSpy.calls.mostRecent().args[0].direction).toEqual('left')
+    })
 
-      expect(spyKeydown).toHaveBeenCalled()
-      expect(spySlide).not.toHaveBeenCalled()
+    it('should disable scroll snapping for the duration of the programmatic scroll', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      spyKeydown.calls.reset()
-      spySlide.calls.reset()
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel)
 
-      Object.defineProperty(keydown, 'target', {
-        value: textarea
-      })
-      textarea.dispatchEvent(keydown)
+      carousel.next()
 
-      expect(spyKeydown).toHaveBeenCalled()
-      expect(spySlide).not.toHaveBeenCalled()
+      // `scroll-snap-stop: always` would otherwise clamp the scroll to a single
+      // slide, so snapping is turned off while we drive the viewport.
+      expect(carousel._viewport.style.scrollSnapType).toEqual('none')
     })
 
-    it('should not slide if arrow key is pressed and carousel is sliding', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should not scroll when the `slide` event is prevented', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      const carouselEl = fixtureEl.querySelector('div')
-      const carousel = new Carousel(carouselEl, {})
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl)
+      stubLayout(carousel)
+      EventHandler.on(carouselEl, 'slide.bs.carousel', event => event.preventDefault())
 
-      const spy = spyOn(EventHandler, 'trigger')
+      carousel.next()
 
-      carousel._isSliding = true
+      expect(scrollBySpy).not.toHaveBeenCalled()
+    })
 
-      for (const key of ['ArrowLeft', 'ArrowRight']) {
-        const keydown = createEvent('keydown')
-        keydown.key = key
+    it('should wrap to the last item when going prev from the first (wrap: true)', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        carouselEl.dispatchEvent(keydown)
-      }
+      const carousel = new Carousel('#myCarousel', { ends: 'wrap' })
+      stubLayout(carousel)
+      carousel.prev()
 
-      expect(spy).not.toHaveBeenCalled()
+      // item3 is two item-widths (200) to the right — a full multi-slide jump
+      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
     })
 
-    it('should wrap around from end to start when wrap option is true', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div id="one" class="carousel-item active"></div>',
-          '    <div id="two" class="carousel-item"></div>',
-          '    <div id="three" class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
+    it('should not move past the ends when `ends` is `stop`', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, { wrap: true })
-        const getActiveId = () => carouselEl.querySelector('.carousel-item.active').getAttribute('id')
-
-        carouselEl.addEventListener('slid.bs.carousel', event => {
-          const activeId = getActiveId()
-
-          if (activeId === 'two') {
-            carousel.next()
-            return
-          }
-
-          if (activeId === 'three') {
-            carousel.next()
-            return
-          }
-
-          if (activeId === 'one') {
-            // carousel wrapped around and slid from 3rd to 1st slide
-            expect(activeId).toEqual('one')
-            expect(event.from + 1).toEqual(3)
-            resolve()
-          }
-        })
-
-        carousel.next()
-      })
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      stubLayout(carousel)
+      carousel.prev()
+
+      expect(scrollBySpy).not.toHaveBeenCalled()
     })
 
-    it('should stay at the start when the prev method is called and wrap is false', () => {
-      return new Promise((resolve, reject) => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div id="one" class="carousel-item active"></div>',
-          '    <div id="two" class="carousel-item"></div>',
-          '    <div id="three" class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
+    it('should center the active slide when `.carousel-center` is present', () => {
+      fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-center' })
 
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const firstElement = fixtureEl.querySelector('#one')
-        const carousel = new Carousel(carouselEl, { wrap: false })
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel, { viewportWidth: 300, itemWidth: 200 })
+      carousel.next()
+
+      // 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)
+    })
 
-        carouselEl.addEventListener('slid.bs.carousel', () => {
-          reject(new Error('carousel slid when it should not have slid'))
-        })
+    it('should rest the slide at the scroll-padding (peek) offset', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        carousel.prev()
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel)
+      // Emulate `--bs-carousel-items-peek` via the resulting scroll-padding so
+      // the slide settles where snapping will (no secondary snap afterwards).
+      carousel._viewport.style.scrollPaddingInlineStart = '30px'
+      carousel.next()
 
-        setTimeout(() => {
-          expect(firstElement).toHaveClass('active')
-          resolve()
-        }, 10)
-      })
+      // itemLeft 100 - (viewportLeft 0 + padStart 30) = 70 (vs 100 without peek)
+      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(70)
     })
 
-    it('should not add touch event listeners if touch = false', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should do nothing when navigating to the current index', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      const carouselEl = fixtureEl.querySelector('div')
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel)
+      carousel.to(0)
 
-      const spy = spyOn(Carousel.prototype, '_addTouchEventListeners')
+      expect(scrollBySpy).not.toHaveBeenCalled()
+    })
 
-      const carousel = new Carousel(carouselEl, {
-        touch: false
-      })
+    it('should not advance past the last item when `ends` is `stop`', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      expect(spy).not.toHaveBeenCalled()
-      expect(carousel._swipeHelper).toBeNull()
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      stubLayout(carousel)
+      carousel._activeIndex = 2
+      carousel.next()
+
+      expect(scrollBySpy).not.toHaveBeenCalled()
     })
 
-    it('should not add touch event listeners if touch supported = false', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should ignore a non-numeric index', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      const carouselEl = fixtureEl.querySelector('div')
-      spyOn(Swipe, 'isSupported').and.returnValue(false)
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel)
+      carousel.to('not-a-number')
 
-      const carousel = new Carousel(carouselEl)
-      EventHandler.off(carouselEl, Carousel.EVENT_KEY)
+      expect(scrollBySpy).not.toHaveBeenCalled()
+    })
+
+    it('should scroll without smooth behavior when reduced motion is preferred', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      const spy = spyOn(carousel, '_addTouchEventListeners')
+      spyOn(window, 'matchMedia').and.returnValue({ matches: true })
 
-      carousel._addEventListeners()
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel)
+      carousel.next()
 
-      expect(spy).not.toHaveBeenCalled()
-      expect(carousel._swipeHelper).toBeNull()
+      // `'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')
     })
 
-    it('should add touch event listeners by default', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should not scroll when the target item is missing', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      const carouselEl = fixtureEl.querySelector('div')
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel)
+      carousel._scrollToIndex(99)
+
+      expect(scrollBySpy).not.toHaveBeenCalled()
+    })
 
-      spyOn(Carousel.prototype, '_addTouchEventListeners')
+    it('should report mirrored slide directions in RTL', () => {
+      fixtureEl.innerHTML = basicMarkup()
+      document.documentElement.dir = 'rtl'
 
-      // Headless browser does not support touch events, so need to fake it
-      // to test that touch events are add properly.
-      document.documentElement.ontouchstart = noop
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
       const carousel = new Carousel(carouselEl)
+      stubLayout(carousel)
+      const slideSpy = jasmine.createSpy('slide')
+      EventHandler.on(carouselEl, 'slide.bs.carousel', slideSpy)
 
-      expect(carousel._addTouchEventListeners).toHaveBeenCalled()
-    })
-
-    it('should allow swiperight and call _slide (prev) with pointer events', () => {
-      return new Promise(resolve => {
-        if (!supportPointerEvent) {
-          expect().nothing()
-          resolve()
-          return
-        }
-
-        document.documentElement.ontouchstart = noop
-        document.head.append(stylesCarousel)
-        Simulator.setType('pointer')
-
-        fixtureEl.innerHTML = [
-          '<div class="carousel">',
-          '  <div class="carousel-inner">',
-          '    <div id="item" class="carousel-item">',
-          '      <img alt="">',
-          '    </div>',
-          '    <div class="carousel-item active">',
-          '      <img alt="">',
-          '    </div>',
-          '  </div>',
-          '</div>'
-        ].join('')
-
-        const carouselEl = fixtureEl.querySelector('.carousel')
-        const item = fixtureEl.querySelector('#item')
-        const carousel = new Carousel(carouselEl)
+      carousel.next()
+      expect(slideSpy.calls.mostRecent().args[0].direction).toEqual('right')
 
-        const spy = spyOn(carousel, '_slide').and.callThrough()
-
-        carouselEl.addEventListener('slid.bs.carousel', event => {
-          expect(item).toHaveClass('active')
-          expect(spy).toHaveBeenCalledWith('prev')
-          expect(event.direction).toEqual('right')
-          stylesCarousel.remove()
-          delete document.documentElement.ontouchstart
-          resolve()
-        })
-
-        Simulator.gestures.swipe(carouselEl, {
-          deltaX: 300,
-          deltaY: 0
-        })
-      })
+      carousel._activeIndex = 1
+      carousel.to(0)
+      expect(slideSpy.calls.mostRecent().args[0].direction).toEqual('left')
     })
 
-    it('should allow swipeleft and call next with pointer events', () => {
-      return new Promise(resolve => {
-        if (!supportPointerEvent) {
-          expect().nothing()
-          resolve()
-          return
-        }
-
-        document.documentElement.ontouchstart = noop
-        document.head.append(stylesCarousel)
-        Simulator.setType('pointer')
-
-        fixtureEl.innerHTML = [
-          '<div class="carousel">',
-          '  <div class="carousel-inner">',
-          '    <div id="item" class="carousel-item active">',
-          '      <img alt="">',
-          '    </div>',
-          '    <div class="carousel-item">',
-          '      <img alt="">',
-          '    </div>',
-          '  </div>',
-          '</div>'
-        ].join('')
-
-        const carouselEl = fixtureEl.querySelector('.carousel')
-        const item = fixtureEl.querySelector('#item')
-        const carousel = new Carousel(carouselEl)
+    it('should step from the live scroll position, not a stale active index', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const spy = spyOn(carousel, '_slide').and.callThrough()
-
-        carouselEl.addEventListener('slid.bs.carousel', event => {
-          expect(item).not.toHaveClass('active')
-          expect(spy).toHaveBeenCalledWith('next')
-          expect(event.direction).toEqual('left')
-          stylesCarousel.remove()
-          delete document.documentElement.ontouchstart
-          resolve()
-        })
-
-        Simulator.gestures.swipe(carouselEl, {
-          pos: [300, 10],
-          deltaX: -300,
-          deltaY: 0
-        })
+      const carousel = new Carousel('#myCarousel')
+      const rect = (left, width) => ({
+        left, width, right: left + width, top: 0, bottom: 0, height: 0, x: left, y: 0, toJSON() {}
       })
-    })
 
-    it('should allow swiperight and call _slide (prev) with touch events', () => {
-      return new Promise(resolve => {
-        Simulator.setType('touch')
-        clearPointerEvents()
-        document.documentElement.ontouchstart = noop
-
-        fixtureEl.innerHTML = [
-          '<div class="carousel">',
-          '  <div class="carousel-inner">',
-          '    <div id="item" class="carousel-item">',
-          '      <img alt="">',
-          '    </div>',
-          '    <div class="carousel-item active">',
-          '      <img alt="">',
-          '    </div>',
-          '  </div>',
-          '</div>'
-        ].join('')
-
-        const carouselEl = fixtureEl.querySelector('.carousel')
-        const item = fixtureEl.querySelector('#item')
-        const carousel = new Carousel(carouselEl)
+      // Scrollable layout currently resting with item 2 (index 2) at the start.
+      Object.defineProperty(carousel._viewport, 'scrollWidth', { configurable: true, get: () => 900 })
+      Object.defineProperty(carousel._viewport, 'clientWidth', { configurable: true, get: () => 300 })
+      spyOn(carousel._viewport, 'getBoundingClientRect').and.returnValue(rect(0, 300))
+      for (const [index, item] of carousel._getItems().entries()) {
+        spyOn(item, 'getBoundingClientRect').and.returnValue(rect((index - 2) * 100, 100))
+      }
 
-        const spy = spyOn(carousel, '_slide').and.callThrough()
-
-        carouselEl.addEventListener('slid.bs.carousel', event => {
-          expect(item).toHaveClass('active')
-          expect(spy).toHaveBeenCalledWith('prev')
-          expect(event.direction).toEqual('right')
-          delete document.documentElement.ontouchstart
-          restorePointerEvents()
-          resolve()
-        })
-
-        Simulator.gestures.swipe(carouselEl, {
-          deltaX: 300,
-          deltaY: 0
-        })
-      })
+      // The IntersectionObserver hasn't caught up, so the tracked index is stale.
+      carousel._activeIndex = 0
+
+      // Navigation must measure from the real position (item 2), not the stale 0.
+      expect(carousel._navIndex()).toEqual(2)
     })
 
-    it('should allow swipeleft and call _slide (next) with touch events', () => {
-      return new Promise(resolve => {
-        Simulator.setType('touch')
-        clearPointerEvents()
-        document.documentElement.ontouchstart = noop
-
-        fixtureEl.innerHTML = [
-          '<div class="carousel">',
-          '  <div class="carousel-inner">',
-          '    <div id="item" class="carousel-item active">',
-          '      <img alt="">',
-          '    </div>',
-          '    <div class="carousel-item">',
-          '      <img alt="">',
-          '    </div>',
-          '  </div>',
-          '</div>'
-        ].join('')
-
-        const carouselEl = fixtureEl.querySelector('.carousel')
-        const item = fixtureEl.querySelector('#item')
-        const carousel = new Carousel(carouselEl)
+    it('should cancel a pending snap-restore frame when navigating again', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const spy = spyOn(carousel, '_slide').and.callThrough()
-
-        carouselEl.addEventListener('slid.bs.carousel', event => {
-          expect(item).not.toHaveClass('active')
-          expect(spy).toHaveBeenCalledWith('next')
-          expect(event.direction).toEqual('left')
-          delete document.documentElement.ontouchstart
-          restorePointerEvents()
-          resolve()
-        })
-
-        Simulator.gestures.swipe(carouselEl, {
-          pos: [300, 10],
-          deltaX: -300,
-          deltaY: 0
-        })
-      })
-    })
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel)
+      const cancelSpy = spyOn(window, 'cancelAnimationFrame').and.callThrough()
 
-    it('should not slide when swiping and carousel is sliding', () => {
-      return new Promise(resolve => {
-        Simulator.setType('touch')
-        clearPointerEvents()
-        document.documentElement.ontouchstart = noop
-
-        fixtureEl.innerHTML = [
-          '<div class="carousel">',
-          '  <div class="carousel-inner">',
-          '    <div id="item" class="carousel-item active">',
-          '      <img alt="">',
-          '    </div>',
-          '    <div class="carousel-item">',
-          '      <img alt="">',
-          '    </div>',
-          '  </div>',
-          '</div>'
-        ].join('')
-
-        const carouselEl = fixtureEl.querySelector('.carousel')
-        const carousel = new Carousel(carouselEl)
-        carousel._isSliding = true
-
-        const spy = spyOn(EventHandler, 'trigger')
-
-        Simulator.gestures.swipe(carouselEl, {
-          deltaX: 300,
-          deltaY: 0
-        })
-
-        Simulator.gestures.swipe(carouselEl, {
-          pos: [300, 10],
-          deltaX: -300,
-          deltaY: 0
-        })
-
-        setTimeout(() => {
-          expect(spy).not.toHaveBeenCalled()
-          delete document.documentElement.ontouchstart
-          restorePointerEvents()
-          resolve()
-        }, 300)
-      })
+      carousel.next() // schedules a snap-restore frame
+      carousel.to(2) // navigates again before it settles, cancelling the pending frame
+
+      expect(cancelSpy).toHaveBeenCalled()
     })
 
-    it('should not allow pinch with touch events', () => {
-      return new Promise(resolve => {
-        Simulator.setType('touch')
-        clearPointerEvents()
-        document.documentElement.ontouchstart = noop
+    it('should restore snapping immediately when requestAnimationFrame is unavailable', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        fixtureEl.innerHTML = '<div class="carousel"></div>'
+      const carousel = new Carousel('#myCarousel')
+      stubLayout(carousel)
+      const original = window.requestAnimationFrame
+      window.requestAnimationFrame = undefined
 
-        const carouselEl = fixtureEl.querySelector('.carousel')
-        const carousel = new Carousel(carouselEl)
+      carousel.next()
+      expect(carousel._viewport.style.scrollSnapType).toEqual('')
 
-        Simulator.gestures.swipe(carouselEl, {
-          pos: [300, 10],
-          deltaX: -300,
-          deltaY: 0,
-          touches: 2
-        }, () => {
-          restorePointerEvents()
-          delete document.documentElement.ontouchstart
-          expect(carousel._swipeHelper._deltaX).toEqual(0)
-          resolve()
-        })
-      })
+      window.requestAnimationFrame = original
     })
 
-    it('should call pause method on mouse over with pause equal to hover', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = '<div class="carousel"></div>'
+    it('should restore snapping as soon as the viewport reaches the scroll target', async () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const carouselEl = fixtureEl.querySelector('.carousel')
-        const carousel = new Carousel(carouselEl)
-
-        const spy = spyOn(carousel, 'pause')
+      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 })
 
-        const mouseOverEvent = createEvent('mouseover')
-        carouselEl.dispatchEvent(mouseOverEvent)
+      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)
 
-        setTimeout(() => {
-          expect(spy).toHaveBeenCalled()
-          resolve()
-        }, 10)
-      })
+      expect(carousel._viewport.style.scrollSnapType).toEqual('')
     })
+  })
 
-    it('should call `maybeEnableCycle` on mouse out with pause equal to hover', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = '<div class="carousel" data-bs-ride="true"></div>'
+  describe('end behavior (`ends`)', () => {
+    it('should not move past either end when `ends` is `stop`', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const carouselEl = fixtureEl.querySelector('.carousel')
-        const carousel = new Carousel(carouselEl)
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      stubLayout(carousel)
 
-        const spyEnable = spyOn(carousel, '_maybeEnableCycle').and.callThrough()
-        const spyCycle = spyOn(carousel, 'cycle')
+      carousel.prev()
+      expect(scrollBySpy).not.toHaveBeenCalled()
 
-        const mouseOutEvent = createEvent('mouseout')
-        carouselEl.dispatchEvent(mouseOutEvent)
+      carousel._activeIndex = 2
+      carousel.next()
+      expect(scrollBySpy).not.toHaveBeenCalled()
+    })
 
-        setTimeout(() => {
-          expect(spyEnable).toHaveBeenCalled()
-          expect(spyCycle).toHaveBeenCalled()
-          resolve()
-        }, 10)
-      })
+    it('should wrap around at both ends when `ends` is `wrap`', () => {
+      fixtureEl.innerHTML = basicMarkup()
+
+      const carousel = new Carousel('#myCarousel', { ends: 'wrap' })
+      stubLayout(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)
     })
   })
 
-  describe('next', () => {
-    it('should not slide if the carousel is sliding', () => {
-      fixtureEl.innerHTML = '<div></div>'
-
-      const carouselEl = fixtureEl.querySelector('div')
-      const carousel = new Carousel(carouselEl, {})
+  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 () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      const spy = spyOn(EventHandler, 'trigger')
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl, { ends: 'loop' })
+      stubLayout(carousel)
+      const slidSpy = jasmine.createSpy('slid')
+      EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
 
-      carousel._isSliding = true
+      carousel._activeIndex = 2
       carousel.next()
 
-      expect(spy).not.toHaveBeenCalled()
+      // A clone is appended for the duration of the animation
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).not.toBeNull()
+      expect(carousel._looping).toBeTrue()
+
+      await flushFrames()
+
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
+      expect(carousel._activeIndex).toEqual(0)
+      expect(carousel._looping).toBeFalse()
+      expect(slidSpy.calls.mostRecent().args[0].to).toEqual(0)
+      expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('left')
     })
 
-    it('should not fire slid when slide is prevented', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = '<div></div>'
+    it('should continue into a transient clone and teleport to the last slide when going prev from the first', async () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const carouselEl = fixtureEl.querySelector('div')
-        const carousel = new Carousel(carouselEl, {})
-        let slidEvent = false
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl, { ends: 'loop' })
+      stubLayout(carousel)
+      const slidSpy = jasmine.createSpy('slid')
+      EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
 
-        const doneTest = () => {
-          setTimeout(() => {
-            expect(slidEvent).toBeFalse()
-            resolve()
-          }, 20)
-        }
+      carousel.prev()
 
-        carouselEl.addEventListener('slide.bs.carousel', event => {
-          event.preventDefault()
-          doneTest()
-        })
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).not.toBeNull()
 
-        carouselEl.addEventListener('slid.bs.carousel', () => {
-          slidEvent = true
-        })
+      await flushFrames()
 
-        carousel.next()
-      })
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
+      expect(carousel._activeIndex).toEqual(2)
+      expect(slidSpy.calls.mostRecent().args[0].to).toEqual(2)
+      expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('right')
     })
 
-    it('should fire slide event with: direction, relatedTarget, from and to', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div class="carousel-item">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
+    it('should report mirrored loop directions in RTL', async () => {
+      fixtureEl.innerHTML = basicMarkup()
+      document.documentElement.dir = 'rtl'
 
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, {})
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl, { ends: 'loop' })
+      stubLayout(carousel)
+      const slidSpy = jasmine.createSpy('slid')
+      EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
 
-        const onSlide = event => {
-          expect(event.direction).toEqual('left')
-          expect(event.relatedTarget).toHaveClass('carousel-item')
-          expect(event.from).toEqual(0)
-          expect(event.to).toEqual(1)
+      carousel._activeIndex = 2
+      carousel.next()
+      await flushFrames()
 
-          carouselEl.removeEventListener('slide.bs.carousel', onSlide)
-          carouselEl.addEventListener('slide.bs.carousel', onSlide2)
+      expect(slidSpy.calls.mostRecent().args[0].direction).toEqual('right')
+    })
 
-          carousel.prev()
-        }
+    it('should not start a transition when the slide event is prevented', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const onSlide2 = event => {
-          expect(event.direction).toEqual('right')
-          resolve()
-        }
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl, { ends: 'loop' })
+      stubLayout(carousel)
+      EventHandler.on(carouselEl, 'slide.bs.carousel', event => event.preventDefault())
 
-        carouselEl.addEventListener('slide.bs.carousel', onSlide)
-        carousel.next()
-      })
+      carousel.prev()
+
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
+      expect(carousel._looping).toBeFalse()
     })
 
-    it('should fire slid event with: direction, relatedTarget, from and to', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div class="carousel-item">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
+    it('should ignore re-entrant navigation while a transition is in flight', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, {})
+      const carousel = new Carousel('#myCarousel', { ends: 'loop' })
+      stubLayout(carousel)
+      carousel._looping = true
 
-        const onSlid = event => {
-          expect(event.direction).toEqual('left')
-          expect(event.relatedTarget).toHaveClass('carousel-item')
-          expect(event.from).toEqual(0)
-          expect(event.to).toEqual(1)
+      carousel.to(1)
 
-          carouselEl.removeEventListener('slid.bs.carousel', onSlid)
-          carouselEl.addEventListener('slid.bs.carousel', onSlid2)
+      expect(scrollBySpy).not.toHaveBeenCalled()
+    })
 
-          carousel.prev()
-        }
+    it('should not move the active index from intersection churn while looping', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-        const onSlid2 = event => {
-          expect(event.direction).toEqual('right')
-          resolve()
-        }
+      const carousel = new Carousel('#myCarousel', { ends: 'loop' })
+      stubLayout(carousel)
+      carousel._activeIndex = 0
+      carousel._looping = true
 
-        carouselEl.addEventListener('slid.bs.carousel', onSlid)
-        carousel.next()
-      })
+      intersect(carousel, [0, 0, 1])
+
+      expect(carousel._activeIndex).toEqual(0)
     })
 
-    it('should update the active element to the next item before sliding', () => {
-      fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide">',
-        '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div id="secondItem" class="carousel-item">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
-        '  </div>',
-        '</div>'
-      ].join('')
+    it('should fall back to a plain wrap for multi-item layouts', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
       const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const secondItemEl = fixtureEl.querySelector('#secondItem')
-      const carousel = new Carousel(carouselEl)
+      carouselEl.style.setProperty('--bs-carousel-items', '2')
+      const carousel = new Carousel(carouselEl, { ends: 'loop' })
+      stubLayout(carousel)
 
-      carousel.next()
+      carousel.prev()
+
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
+      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+    })
 
-      expect(carousel._activeElement).toEqual(secondItemEl)
+    it('should fall back to a plain wrap for centered layouts', () => {
+      fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-center' })
+
+      const carousel = new Carousel('#myCarousel', { ends: 'loop' })
+      stubLayout(carousel)
+
+      carousel.prev()
+
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
+      expect(scrollBySpy).toHaveBeenCalled()
     })
 
-    it('should continue cycling if it was already', () => {
+    it('should fall back to a plain wrap with fewer than two slides', () => {
       fixtureEl.innerHTML = [
         '<div id="myCarousel" class="carousel slide">',
         '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div class="carousel-item">item 2</div>',
+        '    <div id="item1" class="carousel-item active">item 1</div>',
         '  </div>',
         '</div>'
       ].join('')
 
+      const carousel = new Carousel('#myCarousel', { ends: 'loop' })
+      stubLayout(carousel)
+
+      carousel.prev()
+
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
+    })
+
+    it('should fall back to a plain wrap under reduced motion', () => {
+      fixtureEl.innerHTML = basicMarkup()
+      spyOn(window, 'matchMedia').and.returnValue({ matches: true })
+
+      const carousel = new Carousel('#myCarousel', { ends: 'loop' })
+      stubLayout(carousel)
+
+      carousel.prev()
+
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
+      expect(scrollBySpy.calls.mostRecent().args[0].left).toEqual(200)
+    })
+
+    it('should fall back to a plain wrap when a peek is configured', () => {
+      fixtureEl.innerHTML = basicMarkup()
       const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const carousel = new Carousel(carouselEl)
-      const spy = spyOn(carousel, 'cycle')
+      carouselEl.style.setProperty('--bs-carousel-items-peek', '16px')
 
-      carousel.next()
-      expect(spy).not.toHaveBeenCalled()
+      const carousel = new Carousel(carouselEl, { ends: 'loop' })
+      stubLayout(carousel)
 
-      carousel.cycle()
-      carousel.next()
-      expect(spy).toHaveBeenCalledTimes(1)
-    })
-
-    it('should update indicators if present', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-indicators">',
-          '    <button type="button" id="firstIndicator" data-bs-target="myCarousel" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>',
-          '    <button type="button" id="secondIndicator" data-bs-target="myCarousel" data-bs-slide-to="1" aria-label="Slide 2"></button>',
-          '    <button type="button" data-bs-target="myCarousel" data-bs-slide-to="2" aria-label="Slide 3"></button>',
-          '  </div>',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div class="carousel-item" data-bs-interval="7">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
+      carousel.prev()
 
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const firstIndicator = fixtureEl.querySelector('#firstIndicator')
-        const secondIndicator = fixtureEl.querySelector('#secondIndicator')
-        const carousel = new Carousel(carouselEl)
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
+    })
 
-        carouselEl.addEventListener('slid.bs.carousel', () => {
-          expect(firstIndicator).not.toHaveClass('active')
-          expect(firstIndicator.hasAttribute('aria-current')).toBeFalse()
-          expect(secondIndicator).toHaveClass('active')
-          expect(secondIndicator.getAttribute('aria-current')).toEqual('true')
-          resolve()
-        })
+    it('should fall back to a plain wrap for variable-width (`.carousel-auto`) layouts', () => {
+      fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-auto' })
 
-        carousel.next()
-      })
+      const carousel = new Carousel('#myCarousel', { ends: 'loop' })
+      stubLayout(carousel)
+
+      carousel.prev()
+
+      expect(carousel._viewport.querySelector('.carousel-item-clone')).toBeNull()
     })
+  })
 
-    it('should call next()/prev() instance methods when clicking the respective direction buttons', () => {
-      fixtureEl.innerHTML = [
-        '<div id="carousel" class="carousel slide">',
+  describe('disable end controls', () => {
+    const controlsMarkup = ({ slides = 3 } = {}) => {
+      const items = Array.from({ length: slides }, (_, index) =>
+        `    <div id="item${index + 1}" class="carousel-item${index === 0 ? ' active' : ''}">item ${index + 1}</div>`
+      ).join('')
+
+      return [
+        '<div id="myCarousel" class="carousel slide">',
         '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div class="carousel-item">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
+        items,
         '  </div>',
-        '  <button class="carousel-control-prev" type="button" data-bs-target="#carousel" data-bs-slide="prev"></button>',
-        '  <button class="carousel-control-next" type="button" data-bs-target="#carousel" data-bs-slide="next"></button>',
+        '  <button id="prev" class="btn-icon btn-sm" type="button" data-bs-target="#myCarousel" data-bs-slide="prev"></button>',
+        '  <button id="next" class="btn-icon btn-sm" type="button" data-bs-target="#myCarousel" data-bs-slide="next"></button>',
         '</div>'
       ].join('')
+    }
 
-      const carouselEl = fixtureEl.querySelector('#carousel')
-      const prevBtnEl = fixtureEl.querySelector('.carousel-control-prev')
-      const nextBtnEl = fixtureEl.querySelector('.carousel-control-next')
-
-      const carousel = new Carousel(carouselEl)
-      const nextSpy = spyOn(carousel, 'next')
-      const prevSpy = spyOn(carousel, 'prev')
-      const spyEnable = spyOn(carousel, '_maybeEnableCycle')
+    it('should disable the prev control on the first slide when `ends` is `stop`', () => {
+      fixtureEl.innerHTML = controlsMarkup()
 
-      nextBtnEl.click()
-      prevBtnEl.click()
+      new Carousel('#myCarousel', { ends: 'stop' }) // eslint-disable-line no-new
 
-      expect(nextSpy).toHaveBeenCalled()
-      expect(prevSpy).toHaveBeenCalled()
-      expect(spyEnable).toHaveBeenCalled()
+      expect(fixtureEl.querySelector('#prev').disabled).toBeTrue()
+      expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
     })
-  })
 
-  describe('nextWhenVisible', () => {
-    it('should not call next when the page is not visible', () => {
-      fixtureEl.innerHTML = [
-        '<div style="display: none;">',
-        '  <div class="carousel"></div>',
-        '</div>'
-      ].join('')
+    it('should disable the next control on the last slide when `ends` is `stop`', () => {
+      fixtureEl.innerHTML = controlsMarkup()
 
-      const carouselEl = fixtureEl.querySelector('.carousel')
-      const carousel = new Carousel(carouselEl)
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      carousel._activeIndex = 2
+      carousel._refreshActiveState()
 
-      const spy = spyOn(carousel, 'next')
+      expect(fixtureEl.querySelector('#next').disabled).toBeTrue()
+      expect(fixtureEl.querySelector('#prev').disabled).toBeFalse()
+    })
 
-      carousel.nextWhenVisible()
+    it('should not disable any control when `ends` is `wrap`', () => {
+      fixtureEl.innerHTML = controlsMarkup()
+
+      new Carousel('#myCarousel', { ends: 'wrap' }) // eslint-disable-line no-new
 
-      expect(spy).not.toHaveBeenCalled()
+      expect(fixtureEl.querySelector('#prev').disabled).toBeFalse()
+      expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
     })
-  })
 
-  describe('prev', () => {
-    it('should not slide if the carousel is sliding', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should not disable any control when `ends` is `loop`', () => {
+      fixtureEl.innerHTML = controlsMarkup()
+
+      new Carousel('#myCarousel', { ends: 'loop' }) // eslint-disable-line no-new
 
-      const carouselEl = fixtureEl.querySelector('div')
-      const carousel = new Carousel(carouselEl, {})
+      expect(fixtureEl.querySelector('#prev').disabled).toBeFalse()
+      expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
+    })
 
-      const spy = spyOn(EventHandler, 'trigger')
+    it('should disable both controls on a single-slide carousel', () => {
+      fixtureEl.innerHTML = controlsMarkup({ slides: 1 })
 
-      carousel._isSliding = true
-      carousel.prev()
+      new Carousel('#myCarousel', { ends: 'stop' }) // eslint-disable-line no-new
 
-      expect(spy).not.toHaveBeenCalled()
+      expect(fixtureEl.querySelector('#prev').disabled).toBeTrue()
+      expect(fixtureEl.querySelector('#next').disabled).toBeTrue()
     })
-  })
 
-  describe('pause', () => {
-    it('should trigger transitionend if the carousel have carousel-item-next or carousel-item-prev class, cause is sliding', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div class="carousel-item carousel-item-next">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '  <div class="carousel-control-prev"></div>',
-          '  <div class="carousel-control-next"></div>',
-          '</div>'
-        ].join('')
+    it('should move focus off a control that becomes disabled', () => {
+      fixtureEl.innerHTML = controlsMarkup()
 
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl)
-        const spy = spyOn(carousel, '_clearInterval')
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      const prev = fixtureEl.querySelector('#prev')
+      const next = fixtureEl.querySelector('#next')
 
-        carouselEl.addEventListener('transitionend', () => {
-          expect(spy).toHaveBeenCalled()
-          resolve()
-        })
+      // Land on a middle slide so prev is enabled, then focus it
+      carousel._activeIndex = 1
+      carousel._refreshActiveState()
+      prev.focus()
+      expect(document.activeElement).toEqual(prev)
 
-        carousel._slide('next')
-        carousel.pause()
-      })
+      // Back to the first slide: prev becomes disabled, focus shifts to next
+      carousel._activeIndex = 0
+      carousel._refreshActiveState()
+
+      expect(prev.disabled).toBeTrue()
+      expect(document.activeElement).toEqual(next)
     })
-  })
 
-  describe('cycle', () => {
-    it('should set an interval', () => {
-      fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide">',
-        '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div class="carousel-item">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
-        '  </div>',
-        '  <div class="carousel-control-prev"></div>',
-        '  <div class="carousel-control-next"></div>',
-        '</div>'
-      ].join('')
+    it('should move focus to the prev control when the focused next control becomes disabled', () => {
+      fixtureEl.innerHTML = controlsMarkup()
 
-      const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const carousel = new Carousel(carouselEl)
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      const prev = fixtureEl.querySelector('#prev')
+      const next = fixtureEl.querySelector('#next')
 
-      const spy = spyOn(window, 'setInterval').and.callThrough()
+      // Land on a middle slide so next is enabled, then focus it
+      carousel._activeIndex = 1
+      carousel._refreshActiveState()
+      next.focus()
+      expect(document.activeElement).toEqual(next)
 
-      carousel.cycle()
+      // Advance to the last slide: next becomes disabled, focus shifts to prev
+      carousel._activeIndex = 2
+      carousel._refreshActiveState()
 
-      expect(spy).toHaveBeenCalled()
+      expect(next.disabled).toBeTrue()
+      expect(document.activeElement).toEqual(prev)
     })
 
-    it('should clear interval if there is one', () => {
+    it('should fall back to the viewport when there is no opposite control to focus', () => {
       fixtureEl.innerHTML = [
         '<div id="myCarousel" class="carousel slide">',
         '  <div class="carousel-inner">',
         '    <div class="carousel-item active">item 1</div>',
         '    <div class="carousel-item">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
         '  </div>',
-        '  <div class="carousel-control-prev"></div>',
-        '  <div class="carousel-control-next"></div>',
+        '  <button id="prev" class="btn-icon btn-sm" type="button" data-bs-target="#myCarousel" data-bs-slide="prev"></button>',
         '</div>'
       ].join('')
 
-      const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const carousel = new Carousel(carouselEl)
-
-      carousel._interval = setInterval(noop, 10)
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      const prev = fixtureEl.querySelector('#prev')
 
-      const spySet = spyOn(window, 'setInterval').and.callThrough()
-      const spyClear = spyOn(window, 'clearInterval').and.callThrough()
+      carousel._activeIndex = 1
+      carousel._refreshActiveState()
+      prev.focus()
+      expect(document.activeElement).toEqual(prev)
 
-      carousel.cycle()
+      // Back to the first slide with no next control: focus leaves the now-disabled prev
+      carousel._activeIndex = 0
+      carousel._refreshActiveState()
 
-      expect(spySet).toHaveBeenCalled()
-      expect(spyClear).toHaveBeenCalled()
+      expect(prev.disabled).toBeTrue()
+      expect(document.activeElement).not.toEqual(prev)
     })
 
-    it('should get interval from data attribute on the active item element', () => {
-      fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide">',
-        '  <div class="carousel-inner">',
-        '    <div class="carousel-item active" data-bs-interval="7">item 1</div>',
-        '    <div id="secondItem" class="carousel-item" data-bs-interval="9385">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
-        '  </div>',
-        '</div>'
-      ].join('')
+    // Multi-item/peek/variable-width layouts can't bring the last slide to the
+    // left edge, so the controls are driven by the real scroll extent instead.
+    const stubScroll = (carousel, { scrollLeft, scrollWidth = 900, clientWidth = 300 }) => {
+      Object.defineProperty(carousel._viewport, 'scrollWidth', { configurable: true, get: () => scrollWidth })
+      Object.defineProperty(carousel._viewport, 'clientWidth', { configurable: true, get: () => clientWidth })
+      Object.defineProperty(carousel._viewport, 'scrollLeft', { configurable: true, get: () => scrollLeft })
+    }
 
-      const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const secondItemEl = fixtureEl.querySelector('#secondItem')
-      const carousel = new Carousel(carouselEl, {
-        interval: 1814
-      })
+    it('should disable only the prev control at the start of a scrollable (multi-item) carousel', () => {
+      fixtureEl.innerHTML = controlsMarkup({ slides: 6 })
 
-      expect(carousel._config.interval).toEqual(1814)
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      stubScroll(carousel, { scrollLeft: 0 })
+      carousel._updateEndControls()
 
-      carousel.cycle()
+      expect(fixtureEl.querySelector('#prev').disabled).toBeTrue()
+      expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
+    })
 
-      expect(carousel._config.interval).toEqual(7)
+    it('should disable only the next control at the end of a scrollable (multi-item) carousel', () => {
+      fixtureEl.innerHTML = controlsMarkup({ slides: 6 })
 
-      carousel._activeElement = secondItemEl
-      carousel.cycle()
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      // Active index never reaches the last slide in a multi-item layout, but
+      // the scroll position is at its maximum.
+      stubScroll(carousel, { scrollLeft: 600 })
+      carousel._updateEndControls()
 
-      expect(carousel._config.interval).toEqual(9385)
+      expect(fixtureEl.querySelector('#next').disabled).toBeTrue()
+      expect(fixtureEl.querySelector('#prev').disabled).toBeFalse()
     })
-  })
-
-  describe('to', () => {
-    it('should go directly to the provided index', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div id="item1" class="carousel-item active">item 1</div>',
-          '    <div class="carousel-item">item 2</div>',
-          '    <div id="item3" class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
-
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, {})
 
-        expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1'))
+    it('should enable both controls in the middle of a scrollable carousel', () => {
+      fixtureEl.innerHTML = controlsMarkup({ slides: 6 })
 
-        carousel.to(2)
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      stubScroll(carousel, { scrollLeft: 300 })
+      carousel._updateEndControls()
 
-        carouselEl.addEventListener('slid.bs.carousel', () => {
-          expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3'))
-          resolve()
-        })
-      })
+      expect(fixtureEl.querySelector('#prev').disabled).toBeFalse()
+      expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
     })
 
-    it('should return to a previous slide if the provided index is lower than the current', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item">item 1</div>',
-          '    <div id="item2" class="carousel-item">item 2</div>',
-          '    <div id="item3" class="carousel-item active">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
+    it('should refresh the end controls once a programmatic scroll settles at the extent', async () => {
+      fixtureEl.innerHTML = controlsMarkup({ slides: 6 })
 
-        const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, {})
+      const carousel = new Carousel('#myCarousel', { ends: 'stop' })
+      stubLayout(carousel)
+      // Rest at the scroll extent: the IntersectionObserver won't fire again, so
+      // only the settle callback can disable `next`.
+      stubScroll(carousel, { scrollLeft: 600 })
 
-        expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3'))
+      expect(fixtureEl.querySelector('#next').disabled).toBeFalse()
 
-        carousel.to(1)
+      carousel._scrollToIndex(1)
+      await flushFrames()
 
-        carouselEl.addEventListener('slid.bs.carousel', () => {
-          expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2'))
-          resolve()
-        })
-      })
+      expect(fixtureEl.querySelector('#next').disabled).toBeTrue()
     })
+  })
 
-    it('should do nothing if a wrong index is provided', () => {
-      fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide">',
-        '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div class="carousel-item" data-bs-interval="7">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
-        '  </div>',
-        '</div>'
-      ].join('')
+  describe('active sync (IntersectionObserver)', () => {
+    it('should mark the most visible item active and fire `slid`', () => {
+      fixtureEl.innerHTML = basicMarkup({ indicators: true })
 
       const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const carousel = new Carousel(carouselEl, {})
+      const carousel = new Carousel(carouselEl)
+      const slidSpy = jasmine.createSpy('slid')
+      EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
+
+      intersect(carousel, [0.1, 1, 0])
 
-      const spy = spyOn(carousel, '_slide')
+      expect(fixtureEl.querySelector('#item2')).toHaveClass('active')
+      expect(fixtureEl.querySelector('#item1')).not.toHaveClass('active')
+      expect(carousel._activeIndex).toEqual(1)
+      expect(slidSpy).toHaveBeenCalledTimes(1)
+      expect(slidSpy.calls.mostRecent().args[0].to).toEqual(1)
+    })
 
-      carousel.to(25)
+    it('should update the active indicator', () => {
+      fixtureEl.innerHTML = basicMarkup({ indicators: true })
 
-      expect(spy).not.toHaveBeenCalled()
+      const carousel = new Carousel('#myCarousel')
+      intersect(carousel, [0, 0, 1])
 
-      spy.calls.reset()
+      const active = fixtureEl.querySelector('.carousel-indicators .active')
+      expect(active.getAttribute('data-bs-slide-to')).toEqual('2')
+      expect(active.getAttribute('aria-current')).toEqual('true')
+    })
 
-      carousel.to(-5)
+    it('should keep the left-most item active when several are equally visible', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      expect(spy).not.toHaveBeenCalled()
+      const carousel = new Carousel('#myCarousel')
+      carousel._activeIndex = 2
+      intersect(carousel, [1, 1, 0])
+
+      expect(carousel._activeIndex).toEqual(0)
     })
 
-    it('should not continue if the provided is the same compare to the current one', () => {
-      fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide">',
-        '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div class="carousel-item" data-bs-interval="7">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
-        '  </div>',
-        '</div>'
-      ].join('')
+    it('should not fire `slid` when the active item does not change', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
       const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const carousel = new Carousel(carouselEl, {})
+      const carousel = new Carousel(carouselEl)
+      const slidSpy = jasmine.createSpy('slid')
+      EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
 
-      const spy = spyOn(carousel, '_slide')
+      intersect(carousel, [1, 0, 0])
 
-      carousel.to(0)
+      expect(slidSpy).not.toHaveBeenCalled()
+    })
+
+    it('should keep the left-most slide active when it rests a hair less visible than its neighbors', () => {
+      fixtureEl.innerHTML = basicMarkup()
+
+      const carousel = new Carousel('#myCarousel')
+      // Multi-item rest: the intended left-most slide is ~1px clipped (0.99) while
+      // the fully-in neighbor reads 1.0. A strict max would inflate to index 2.
+      intersect(carousel, [0, 0.99, 1])
 
-      expect(spy).not.toHaveBeenCalled()
+      expect(carousel._activeIndex).toEqual(1)
     })
 
-    it('should wait before performing to if a slide is sliding', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div class="carousel-item" data-bs-interval="7">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '</div>'
-        ].join('')
+    it('should sync the active slide and fire `slid` after settling when there is no observer', async () => {
+      const original = window.IntersectionObserver
+      window.IntersectionObserver = undefined
 
+      try {
+        fixtureEl.innerHTML = basicMarkup()
         const carouselEl = fixtureEl.querySelector('#myCarousel')
-        const carousel = new Carousel(carouselEl, {})
+        const carousel = new Carousel(carouselEl)
+        stubLayout(carousel)
 
-        const spyOne = spyOn(EventHandler, 'one').and.callThrough()
-        const spySlide = spyOn(carousel, '_slide')
+        const slidSpy = jasmine.createSpy('slid')
+        EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
 
-        carousel._isSliding = true
         carousel.to(1)
+        await flushFrames()
 
-        expect(spySlide).not.toHaveBeenCalled()
-        expect(spyOne).toHaveBeenCalled()
+        expect(carousel._activeIndex).toEqual(1)
+        expect(slidSpy).toHaveBeenCalled()
+      } finally {
+        window.IntersectionObserver = original
+      }
+    })
+  })
 
-        const spyTo = spyOn(carousel, 'to')
+  describe('fade mode', () => {
+    it('should crossfade by toggling the active class without scrolling', () => {
+      fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-fade' })
 
-        EventHandler.trigger(carouselEl, 'slid.bs.carousel')
+      const carousel = new Carousel('#myCarousel')
+      carousel.to(1)
 
-        setTimeout(() => {
-          expect(spyTo).toHaveBeenCalledWith(1)
-          resolve()
-        })
-      })
+      expect(fixtureEl.querySelector('#item2')).toHaveClass('active')
+      expect(fixtureEl.querySelector('#item1')).not.toHaveClass('active')
+      expect(scrollBySpy).not.toHaveBeenCalled()
     })
-  })
 
-  describe('rtl function', () => {
-    it('"_directionToOrder" and "_orderToDirection" must return the right results', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should not use the View Transition API for the fade', () => {
+      fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-fade' })
+
+      if (typeof document.startViewTransition === 'function') {
+        spyOn(document, 'startViewTransition').and.callThrough()
+      }
 
-      const carouselEl = fixtureEl.querySelector('div')
-      const carousel = new Carousel(carouselEl, {})
+      const carousel = new Carousel('#myCarousel')
+      carousel.to(1)
 
-      expect(carousel._directionToOrder('left')).toEqual('next')
-      expect(carousel._directionToOrder('right')).toEqual('prev')
+      if (typeof document.startViewTransition === 'function') {
+        expect(document.startViewTransition).not.toHaveBeenCalled()
+      }
 
-      expect(carousel._orderToDirection('next')).toEqual('left')
-      expect(carousel._orderToDirection('prev')).toEqual('right')
+      expect(fixtureEl.querySelector('#item2')).toHaveClass('active')
     })
 
-    it('"_directionToOrder" and "_orderToDirection" must return the right results when rtl=true', () => {
-      document.documentElement.dir = 'rtl'
-      fixtureEl.innerHTML = '<div></div>'
+    it('should fire `slide` then `slid` once each', () => {
+      fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-fade' })
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl)
 
-      const carouselEl = fixtureEl.querySelector('div')
-      const carousel = new Carousel(carouselEl, {})
-      expect(isRTL()).toBeTrue()
+      const slideSpy = jasmine.createSpy('slide')
+      const slidSpy = jasmine.createSpy('slid')
+      EventHandler.on(carouselEl, 'slide.bs.carousel', slideSpy)
+      EventHandler.on(carouselEl, 'slid.bs.carousel', slidSpy)
 
-      expect(carousel._directionToOrder('left')).toEqual('prev')
-      expect(carousel._directionToOrder('right')).toEqual('next')
+      carousel.to(1)
 
-      expect(carousel._orderToDirection('next')).toEqual('right')
-      expect(carousel._orderToDirection('prev')).toEqual('left')
-      document.documentElement.dir = 'ltl'
+      expect(slideSpy).toHaveBeenCalledTimes(1)
+      expect(slidSpy).toHaveBeenCalledTimes(1)
     })
+  })
 
-    it('"_slide" has to call _directionToOrder and "_orderToDirection"', () => {
-      fixtureEl.innerHTML = '<div></div>'
+  describe('autoplay', () => {
+    it('should clear the interval on pause', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
 
-      const carouselEl = fixtureEl.querySelector('div')
-      const carousel = new Carousel(carouselEl, {})
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._interval).not.toBeNull()
 
-      const spy = spyOn(carousel, '_orderToDirection').and.callThrough()
+      carousel.pause()
+      expect(carousel._interval).toBeNull()
+    })
 
-      carousel._slide(carousel._directionToOrder('left'))
-      expect(spy).toHaveBeenCalledWith('next')
+    it('should not advance when the page is not visible', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      carousel._slide(carousel._directionToOrder('right'))
-      expect(spy).toHaveBeenCalledWith('prev')
-    })
+      const carousel = new Carousel('#myCarousel')
+      const nextSpy = spyOn(carousel, 'next')
+      spyOnProperty(document, 'visibilityState', 'get').and.returnValue('hidden')
 
-    it('"_slide" has to call "_directionToOrder" and "_orderToDirection" when rtl=true', () => {
-      document.documentElement.dir = 'rtl'
-      fixtureEl.innerHTML = '<div></div>'
+      carousel.nextWhenVisible()
+      expect(nextSpy).not.toHaveBeenCalled()
+    })
 
-      const carouselEl = fixtureEl.querySelector('div')
-      const carousel = new Carousel(carouselEl, {})
-      const spy = spyOn(carousel, '_orderToDirection').and.callThrough()
+    it('should respect a per-item `data-bs-interval`', () => {
+      fixtureEl.innerHTML = [
+        '<div id="myCarousel" class="carousel slide">',
+        '  <div class="carousel-inner">',
+        '    <div class="carousel-item active" data-bs-interval="2000">item 1</div>',
+        '    <div class="carousel-item" data-bs-interval="4000">item 2</div>',
+        '  </div>',
+        '</div>'
+      ].join('')
 
-      carousel._slide(carousel._directionToOrder('left'))
-      expect(spy).toHaveBeenCalledWith('prev')
+      const carousel = new Carousel('#myCarousel', { interval: 5000 })
+      expect(carousel._itemInterval()).toEqual(2000)
+      expect(carousel._itemInterval(1)).toEqual(4000)
+    })
 
-      carousel._slide(carousel._directionToOrder('right'))
-      expect(spy).toHaveBeenCalledWith('next')
+    it('should fall back to the configured interval when the item has none', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      document.documentElement.dir = 'ltl'
+      const carousel = new Carousel('#myCarousel', { interval: 4000 })
+      expect(carousel._itemInterval()).toEqual(4000)
     })
-  })
 
-  describe('dispose', () => {
-    it('should destroy a carousel', () => {
+    it('should schedule the next wait from the slide being navigated to, not the current one', () => {
+      // The slide we're leaving has a long interval, the one we advance to a
+      // short one. The next wait must use the upcoming slide's own interval.
       fixtureEl.innerHTML = [
         '<div id="myCarousel" class="carousel slide">',
         '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
-        '    <div class="carousel-item" data-bs-interval="7">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
+        '    <div class="carousel-item active" data-bs-interval="10000">item 1</div>',
+        '    <div class="carousel-item" data-bs-interval="2000">item 2</div>',
         '  </div>',
         '</div>'
       ].join('')
 
+      const carousel = new Carousel('#myCarousel', { interval: 5000 })
+      const upcoming = carousel._upcomingIndex()
+
+      expect(upcoming).toEqual(1)
+      expect(carousel._itemInterval(upcoming)).toEqual(2000)
+    })
+
+    it('should resume cycling on mouse leave only while playing', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
+
       const carouselEl = fixtureEl.querySelector('#myCarousel')
-      const addEventSpy = spyOn(carouselEl, 'addEventListener').and.callThrough()
-      const removeEventSpy = spyOn(EventHandler, 'off').and.callThrough()
+      const carousel = new Carousel(carouselEl)
+      const cycleSpy = spyOn(carousel, 'cycle')
+
+      // Bootstrap's EventHandler maps `mouseleave` listeners onto `mouseout`
+      carouselEl.dispatchEvent(createEvent('mouseout'))
+      expect(cycleSpy).toHaveBeenCalled()
+    })
 
-      // Headless browser does not support touch events, so need to fake it
-      // to test that touch events are add/removed properly.
-      document.documentElement.ontouchstart = noop
+    it('should not resume cycling on mouse leave after the user paused', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
 
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
       const carousel = new Carousel(carouselEl)
-      const swipeHelperSpy = spyOn(carousel._swipeHelper, 'dispose').and.callThrough()
-
-      const expectedArgs = [
-        ['keydown', jasmine.any(Function), jasmine.any(Boolean)],
-        ['mouseover', jasmine.any(Function), jasmine.any(Boolean)],
-        ['mouseout', jasmine.any(Function), jasmine.any(Boolean)],
-        ...(carousel._swipeHelper._supportPointerEvents ?
-          [
-            ['pointerdown', jasmine.any(Function), jasmine.any(Boolean)],
-            ['pointerup', jasmine.any(Function), jasmine.any(Boolean)]
-          ] :
-          [
-            ['touchstart', jasmine.any(Function), jasmine.any(Boolean)],
-            ['touchmove', jasmine.any(Function), jasmine.any(Boolean)],
-            ['touchend', jasmine.any(Function), jasmine.any(Boolean)]
-          ])
-      ]
-
-      expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs)
+      carousel._pauseFromInteraction()
+      const cycleSpy = spyOn(carousel, 'cycle')
 
-      carousel.dispose()
+      carouselEl.dispatchEvent(createEvent('mouseout'))
+      expect(cycleSpy).not.toHaveBeenCalled()
+    })
 
-      expect(carousel._swipeHelper).toBeNull()
-      expect(removeEventSpy).toHaveBeenCalledWith(carouselEl, Carousel.EVENT_KEY)
-      expect(swipeHelperSpy).toHaveBeenCalled()
+    it('should pause on hover when `pause` is `hover`', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
 
-      delete document.documentElement.ontouchstart
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl)
+      expect(carousel._interval).not.toBeNull()
+
+      // Bootstrap's EventHandler maps `mouseenter` listeners onto `mouseover`
+      carouselEl.dispatchEvent(createEvent('mouseover'))
+      expect(carousel._interval).toBeNull()
     })
-  })
 
-  describe('getInstance', () => {
-    it('should return carousel instance', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should not pause on hover when `pause` is `false`', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
 
-      const div = fixtureEl.querySelector('div')
-      const carousel = new Carousel(div)
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl, { pause: false })
 
-      expect(Carousel.getInstance(div)).toEqual(carousel)
-      expect(Carousel.getInstance(div)).toBeInstanceOf(Carousel)
+      carouselEl.dispatchEvent(createEvent('mouseover'))
+      expect(carousel._interval).not.toBeNull()
     })
 
-    it('should return null when there is no carousel instance', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should toggle the playing class and expose the interval while cycling', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
+
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl, { interval: 3000 })
+
+      expect(carouselEl).toHaveClass('carousel-playing')
+      expect(carouselEl.style.getPropertyValue('--bs-carousel-interval')).toEqual('3000ms')
+
+      carousel.pause()
+      expect(carouselEl).not.toHaveClass('carousel-playing')
+    })
+
+    it('should stop cycling at the last slide when `ends` is `stop`', () => {
+      jasmine.clock().install()
+
+      try {
+        fixtureEl.innerHTML = basicMarkup({ autoplay: true })
+
+        const carousel = new Carousel('#myCarousel', { ends: 'stop', interval: 1000 })
+        carousel._activeIndex = 2
+        carousel.cycle()
+        expect(carousel._interval).not.toBeNull()
 
-      const div = fixtureEl.querySelector('div')
+        jasmine.clock().tick(1001)
 
-      expect(Carousel.getInstance(div)).toBeNull()
+        expect(carousel._interval).toBeNull()
+      } finally {
+        jasmine.clock().uninstall()
+      }
     })
   })
 
-  describe('getOrCreateInstance', () => {
-    it('should return carousel instance', () => {
-      fixtureEl.innerHTML = '<div></div>'
+  describe('stop on interaction (WCAG 2.2.2)', () => {
+    it('should stop autoplay when navigating with the keyboard', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
+
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl)
+      expect(carousel._playing).toBeTrue()
 
-      const div = fixtureEl.querySelector('div')
-      const carousel = new Carousel(div)
+      const keydown = createEvent('keydown')
+      keydown.key = 'ArrowRight'
+      carouselEl.dispatchEvent(keydown)
 
-      expect(Carousel.getOrCreateInstance(div)).toEqual(carousel)
-      expect(Carousel.getInstance(div)).toEqual(Carousel.getOrCreateInstance(div, {}))
-      expect(Carousel.getOrCreateInstance(div)).toBeInstanceOf(Carousel)
+      expect(carousel._playing).toBeFalse()
+      expect(carousel._interval).toBeNull()
     })
 
-    it('should return new instance when there is no carousel instance', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should stop autoplay when the track is dragged/tapped (pointerdown)', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
+
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._playing).toBeTrue()
 
-      const div = fixtureEl.querySelector('div')
+      carousel._viewport.dispatchEvent(createEvent('pointerdown'))
 
-      expect(Carousel.getInstance(div)).toBeNull()
-      expect(Carousel.getOrCreateInstance(div)).toBeInstanceOf(Carousel)
+      expect(carousel._playing).toBeFalse()
+      expect(carousel._interval).toBeNull()
     })
+  })
 
-    it('should return new instance when there is no carousel instance with given configuration', () => {
-      fixtureEl.innerHTML = '<div></div>'
+  describe('keyboard', () => {
+    it('should go to the next item on right arrow and previous on left arrow', () => {
+      fixtureEl.innerHTML = basicMarkup()
+
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl)
+      const nextSpy = spyOn(carousel, 'next')
+      const prevSpy = spyOn(carousel, 'prev')
 
-      const div = fixtureEl.querySelector('div')
+      const right = createEvent('keydown')
+      right.key = 'ArrowRight'
+      carouselEl.dispatchEvent(right)
 
-      expect(Carousel.getInstance(div)).toBeNull()
-      const carousel = Carousel.getOrCreateInstance(div, {
-        interval: 1
-      })
-      expect(carousel).toBeInstanceOf(Carousel)
+      const left = createEvent('keydown')
+      left.key = 'ArrowLeft'
+      carouselEl.dispatchEvent(left)
 
-      expect(carousel._config.interval).toEqual(1)
+      expect(nextSpy).toHaveBeenCalled()
+      expect(prevSpy).toHaveBeenCalled()
     })
 
-    it('should return the instance when exists without given configuration', () => {
-      fixtureEl.innerHTML = '<div></div>'
+    it('should ignore keystrokes from inputs and textareas', () => {
+      fixtureEl.innerHTML = [
+        '<div id="myCarousel" class="carousel slide">',
+        '  <div class="carousel-inner"><div class="carousel-item active"><input type="text"></div></div>',
+        '</div>'
+      ].join('')
 
-      const div = fixtureEl.querySelector('div')
-      const carousel = new Carousel(div, {
-        interval: 1
-      })
-      expect(Carousel.getInstance(div)).toEqual(carousel)
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl)
+      const nextSpy = spyOn(carousel, 'next')
 
-      const carousel2 = Carousel.getOrCreateInstance(div, {
-        interval: 2
-      })
-      expect(carousel).toBeInstanceOf(Carousel)
-      expect(carousel2).toEqual(carousel)
+      const keydown = createEvent('keydown', { bubbles: true })
+      keydown.key = 'ArrowRight'
+      carouselEl.querySelector('input').dispatchEvent(keydown)
 
-      expect(carousel2._config.interval).toEqual(1)
+      expect(nextSpy).not.toHaveBeenCalled()
     })
-  })
 
-  describe('data-api', () => {
-    it('should init carousels with data-bs-ride="carousel" on load', () => {
-      fixtureEl.innerHTML = '<div data-bs-ride="carousel"></div>'
+    it('should not react to keyboard when `keyboard` is `false`', () => {
+      fixtureEl.innerHTML = basicMarkup()
 
-      const carouselEl = fixtureEl.querySelector('div')
-      const loadEvent = createEvent('load')
+      const carouselEl = fixtureEl.querySelector('#myCarousel')
+      const carousel = new Carousel(carouselEl, { keyboard: false })
+      const nextSpy = spyOn(carousel, 'next')
 
-      window.dispatchEvent(loadEvent)
-      const carousel = Carousel.getInstance(carouselEl)
-      expect(carousel._interval).not.toBeNull()
+      const keydown = createEvent('keydown')
+      keydown.key = 'ArrowRight'
+      carouselEl.dispatchEvent(keydown)
+
+      expect(nextSpy).not.toHaveBeenCalled()
     })
+  })
 
-    it('should create carousel and go to the next slide on click (with real button controls)', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div id="item2" class="carousel-item">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '  <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
-          '  <button id="next" class="carousel-control-next" data-bs-target="#myCarousel" type="button" data-bs-slide="next"></button>',
-          '</div>'
-        ].join('')
-
-        const next = fixtureEl.querySelector('#next')
-        const item2 = fixtureEl.querySelector('#item2')
-
-        next.click()
-
-        setTimeout(() => {
-          expect(item2).toHaveClass('active')
-          resolve()
-        }, 10)
-      })
+  describe('play/pause control', () => {
+    const playPauseMarkup = ({ autoplay = true } = {}) => [
+      `<div id="myCarousel" class="carousel slide"${autoplay ? ' data-bs-autoplay="true"' : ''}>`,
+      '  <div class="carousel-inner">',
+      '    <div class="carousel-item active">item 1</div>',
+      '    <div class="carousel-item">item 2</div>',
+      '  </div>',
+      '  <button class="carousel-control-play-pause" type="button" data-bs-target="#myCarousel" data-bs-pause-label="Pause" data-bs-play-label="Play">',
+      '    <span class="carousel-icon-pause"></span>',
+      '    <span class="carousel-icon-play"></span>',
+      '  </button>',
+      '</div>'
+    ].join('')
+
+    it('should reflect the playing state on init', () => {
+      fixtureEl.innerHTML = playPauseMarkup({ autoplay: true })
+      new Carousel('#myCarousel') // eslint-disable-line no-new
+
+      expect(fixtureEl.querySelector('.carousel-control-play-pause')).not.toHaveClass('paused')
     })
 
-    it('should create carousel and go to the next slide on click (using links as controls)', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div id="item2" class="carousel-item">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '  <a class="carousel-control-prev" href="#myCarousel" role="button" data-bs-slide="prev"></a>',
-          '  <a id="next" class="carousel-control-next" href="#myCarousel" role="button" data-bs-slide="next"></a>',
-          '</div>'
-        ].join('')
-
-        const next = fixtureEl.querySelector('#next')
-        const item2 = fixtureEl.querySelector('#item2')
-
-        next.click()
-
-        setTimeout(() => {
-          expect(item2).toHaveClass('active')
-          resolve()
-        }, 10)
-      })
+    it('should reflect the paused state for a static carousel', () => {
+      fixtureEl.innerHTML = playPauseMarkup({ autoplay: false })
+      new Carousel('#myCarousel') // eslint-disable-line no-new
+
+      expect(fixtureEl.querySelector('.carousel-control-play-pause')).toHaveClass('paused')
     })
 
-    it('should create carousel and go to the next slide on click with data-bs-slide-to', () => {
-      return new Promise(resolve => {
-        fixtureEl.innerHTML = [
-          '<div id="myCarousel" class="carousel slide" data-bs-ride="true">',
-          '  <div class="carousel-inner">',
-          '    <div class="carousel-item active">item 1</div>',
-          '    <div id="item2" class="carousel-item">item 2</div>',
-          '    <div class="carousel-item">item 3</div>',
-          '  </div>',
-          '  <div id="next" data-bs-target="#myCarousel" data-bs-slide-to="1"></div>',
-          '</div>'
-        ].join('')
-
-        const next = fixtureEl.querySelector('#next')
-        const item2 = fixtureEl.querySelector('#item2')
-
-        next.click()
-
-        setTimeout(() => {
-          expect(item2).toHaveClass('active')
-          expect(Carousel.getInstance('#myCarousel')._interval).not.toBeNull()
-          resolve()
-        }, 10)
-      })
+    it('should toggle autoplay, the class and the label when clicked', () => {
+      fixtureEl.innerHTML = playPauseMarkup({ autoplay: true })
+
+      const carousel = new Carousel('#myCarousel')
+      const control = fixtureEl.querySelector('.carousel-control-play-pause')
+
+      expect(carousel._interval).not.toBeNull()
+
+      control.click()
+      expect(carousel._interval).toBeNull()
+      expect(carousel._playing).toBeFalse()
+      expect(control).toHaveClass('paused')
+      expect(control.getAttribute('aria-label')).toEqual('Play')
+
+      control.click()
+      expect(carousel._interval).not.toBeNull()
+      expect(carousel._playing).toBeTrue()
+      expect(control).not.toHaveClass('paused')
+      expect(control.getAttribute('aria-label')).toEqual('Pause')
     })
 
-    it('should do nothing if no selector on click on arrows', () => {
+    it('should let the control start autoplay on an otherwise static carousel', () => {
+      fixtureEl.innerHTML = playPauseMarkup({ autoplay: false })
+
+      const carousel = new Carousel('#myCarousel')
+      const control = fixtureEl.querySelector('.carousel-control-play-pause')
+      expect(carousel._interval).toBeNull()
+
+      control.click()
+      expect(carousel._interval).not.toBeNull()
+      expect(carousel._playing).toBeTrue()
+    })
+  })
+
+  describe('data-api', () => {
+    it('should navigate and stop autoplay when clicking a control', () => {
       fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="carousel slide">',
+        '<div id="myCarousel" class="carousel slide" data-bs-autoplay="true">',
         '  <div class="carousel-inner">',
         '    <div class="carousel-item active">item 1</div>',
         '    <div class="carousel-item">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
         '  </div>',
-        '  <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
-        '  <button id="next" class="carousel-control-next" type="button" data-bs-slide="next"></button>',
+        '  <button id="next" class="btn-icon btn-sm" type="button" data-bs-target="#myCarousel" data-bs-slide="next"></button>',
         '</div>'
       ].join('')
 
-      const next = fixtureEl.querySelector('#next')
+      const carousel = Carousel.getOrCreateInstance('#myCarousel')
+      stubLayout(carousel)
 
-      next.click()
+      fixtureEl.querySelector('#next').click()
 
-      expect().nothing()
+      expect(carousel._playing).toBeFalse()
+      expect(carousel._interval).toBeNull()
+      expect(scrollBySpy).toHaveBeenCalled()
     })
 
-    it('should do nothing if no carousel class on click on arrows', () => {
+    it('should go to the previous item with data-bs-slide="prev"', () => {
       fixtureEl.innerHTML = [
-        '<div id="myCarousel" class="slide">',
+        '<div id="myCarousel" class="carousel slide" data-bs-ends="wrap">',
         '  <div class="carousel-inner">',
-        '    <div class="carousel-item active">item 1</div>',
+        '    <div id="item1" class="carousel-item active">item 1</div>',
         '    <div id="item2" class="carousel-item">item 2</div>',
-        '    <div class="carousel-item">item 3</div>',
         '  </div>',
-        '  <button class="carousel-control-prev" data-bs-target="#myCarousel" type="button" data-bs-slide="prev"></button>',
-        '  <button id="next" class="carousel-control-next" data-bs-target="#myCarousel" type="button" data-bs-slide="next"></button>',
+        '  <button id="prev" class="btn-icon btn-sm" type="button" data-bs-target="#myCarousel" data-bs-slide="prev"></button>',
         '</div>'
       ].join('')
 
-      const next = fixtureEl.querySelector('#next')
+      const carousel = Carousel.getOrCreateInstance('#myCarousel')
+      stubLayout(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)
+    })
 
-      next.click()
+    it('should go to a given index with data-bs-slide-to', () => {
+      fixtureEl.innerHTML = basicMarkup({ indicators: true })
+
+      const carousel = Carousel.getOrCreateInstance('#myCarousel')
+      stubLayout(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)
+    })
+
+    it('should toggle play/pause via the data-api', () => {
+      fixtureEl.innerHTML = [
+        '<div id="myCarousel" class="carousel slide" data-bs-autoplay="true">',
+        '  <div class="carousel-inner"><div class="carousel-item active">item 1</div></div>',
+        '  <button id="pp" class="carousel-control-play-pause" type="button" data-bs-target="#myCarousel"></button>',
+        '</div>'
+      ].join('')
+
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._playing).toBeTrue()
+
+      fixtureEl.querySelector('#pp').click()
+      expect(carousel._playing).toBeFalse()
+    })
+
+    it('should init carousels with data-bs-autoplay="true" on load', () => {
+      fixtureEl.innerHTML = '<div id="myCarousel" data-bs-autoplay="true"><div class="carousel-inner"><div class="carousel-item active">item 1</div></div></div>'
+
+      const loadEvent = createEvent('load')
+      window.dispatchEvent(loadEvent)
+
+      const carousel = Carousel.getInstance('#myCarousel')
+      expect(carousel).not.toBeNull()
+      expect(carousel._interval).not.toBeNull()
+    })
+
+    it('should do nothing if the target is not a carousel', () => {
+      fixtureEl.innerHTML = [
+        '<div id="myCarousel" class="slide">',
+        '  <div class="carousel-inner"><div class="carousel-item active">item 1</div></div>',
+        '  <button id="next" class="btn-icon btn-sm" type="button" data-bs-target="#myCarousel" data-bs-slide="next"></button>',
+        '</div>'
+      ].join('')
 
+      fixtureEl.querySelector('#next').click()
       expect().nothing()
     })
   })
+
+  describe('dispose', () => {
+    it('should disconnect the observer', () => {
+      fixtureEl.innerHTML = basicMarkup()
+
+      const carousel = new Carousel('#myCarousel')
+      const disconnectSpy = spyOn(carousel._observer, 'disconnect')
+
+      carousel.dispose()
+      expect(disconnectSpy).toHaveBeenCalled()
+    })
+
+    it('should dispose cleanly in fade mode without an observer', () => {
+      fixtureEl.innerHTML = basicMarkup({ classes: 'carousel slide carousel-fade' })
+
+      const carousel = new Carousel('#myCarousel')
+      expect(carousel._observer).toBeNull()
+      expect(() => carousel.dispose()).not.toThrow()
+    })
+
+    it('should stop autoplay so no timer fires after dispose', () => {
+      jasmine.clock().install()
+
+      try {
+        fixtureEl.innerHTML = basicMarkup({ autoplay: true })
+
+        const carousel = new Carousel('#myCarousel')
+        const advanceSpy = spyOn(carousel, 'nextWhenVisible').and.callThrough()
+        expect(carousel._interval).not.toBeNull()
+
+        carousel.dispose()
+        // Without clearing the timer, the pending callback would fire here and
+        // throw on the now-null `_element`.
+        expect(() => jasmine.clock().tick(10000)).not.toThrow()
+        expect(advanceSpy).not.toHaveBeenCalled()
+      } finally {
+        jasmine.clock().uninstall()
+      }
+    })
+
+    it('should remove the viewport pointerdown listener on dispose', () => {
+      fixtureEl.innerHTML = basicMarkup({ autoplay: true })
+
+      const carousel = new Carousel('#myCarousel')
+      const { _viewport: viewport } = carousel
+
+      carousel.dispose()
+      // A late interaction on the detached viewport must not resurrect autoplay
+      expect(() => viewport.dispatchEvent(createEvent('pointerdown'))).not.toThrow()
+    })
+
+    it('should remove the clone and cancel the pending frame when disposed mid-loop', () => {
+      fixtureEl.innerHTML = basicMarkup()
+
+      const carousel = new Carousel('#myCarousel', { ends: 'loop' })
+      stubLayout(carousel)
+      carousel._activeIndex = 2
+
+      carousel.next()
+      const { _viewport: viewport } = carousel
+      expect(viewport.querySelector('.carousel-item-clone')).not.toBeNull()
+
+      expect(() => carousel.dispose()).not.toThrow()
+      expect(viewport.querySelector('.carousel-item-clone')).toBeNull()
+    })
+  })
 })
index 1b2de52913599e471232ab3dc2df81909a74a89d..134c066f6fc87a97565024d2a47181d57201f05e 100644 (file)
@@ -5,43 +5,46 @@
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
     <title>Carousel</title>
-    <style>
-      .carousel-item {
-        transition: transform 2s ease, opacity .5s ease;
-      }
-    </style>
   </head>
   <body>
     <div class="container">
       <h1>Carousel <small>Bootstrap Visual Test</small></h1>
 
-      <p>The transition duration should be around 2s. Also, the carousel shouldn't slide when its window/tab is hidden. Check the console log.</p>
+      <p>The carousel autoplays and shouldn't slide when its window/tab is hidden. Interacting with it (controls, keyboard, or swipe) stops autoplay for good. Check the console log.</p>
 
-      <div id="carousel-example-generic" class="carousel slide" data-bs-ride="carousel">
-        <div class="carousel-indicators">
-          <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
-          <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="1" aria-label="Slide 2"></button>
-          <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="2" aria-label="Slide 3"></button>
-        </div>
-        <div class="carousel-inner">
+      <div id="carousel-example-generic" class="carousel slide" data-bs-autoplay="true">
+        <div class="carousel-inner rounded-5">
           <div class="carousel-item active">
-            <img src="https://i.imgur.com/iEZgY7Y.jpg" alt="First slide">
+            <img class="d-block w-100" src="https://i.imgur.com/iEZgY7Y.jpg" alt="First slide">
           </div>
           <div class="carousel-item">
-            <img src="https://i.imgur.com/eNWn1Xs.jpg" alt="Second slide">
+            <img class="d-block w-100" src="https://i.imgur.com/eNWn1Xs.jpg" alt="Second slide">
           </div>
           <div class="carousel-item">
-            <img src="https://i.imgur.com/Nm7xoti.jpg" alt="Third slide">
+            <img class="d-block w-100" src="https://i.imgur.com/Nm7xoti.jpg" alt="Third slide">
+          </div>
+        </div>
+        <div class="d-flex justify-content-between align-items-center mt-3">
+          <div class="carousel-indicators">
+            <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+            <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="1" aria-label="Slide 2"></button>
+            <button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="2" aria-label="Slide 3"></button>
+          </div>
+          <div>
+            <button class="btn-icon btn-sm carousel-control-play-pause" data-bs-target="#carousel-example-generic" type="button" aria-label="Pause" data-bs-pause-label="Pause" data-bs-play-label="Play">
+              <span class="carousel-icon-pause" aria-hidden="true"></span>
+              <span class="carousel-icon-play" aria-hidden="true"></span>
+            </button>
+            <button class="btn-icon btn-sm" data-bs-target="#carousel-example-generic" type="button" data-bs-slide="prev">
+              <span class="carousel-icon-prev" aria-hidden="true"></span>
+              <span class="visually-hidden">Previous</span>
+            </button>
+            <button class="btn-icon btn-sm" data-bs-target="#carousel-example-generic" type="button" data-bs-slide="next">
+              <span class="carousel-icon-next" aria-hidden="true"></span>
+              <span class="visually-hidden">Next</span>
+            </button>
           </div>
         </div>
-        <button class="carousel-control-prev" data-bs-target="#carousel-example-generic" type="button" data-bs-slide="prev">
-          <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-          <span class="visually-hidden">Previous</span>
-        </button>
-        <button class="carousel-control-next" data-bs-target="#carousel-example-generic" type="button" data-bs-slide="next">
-          <span class="carousel-control-next-icon" aria-hidden="true"></span>
-          <span class="visually-hidden">Next</span>
-        </button>
       </div>
     </div>
 
       const carousel = document.getElementById('carousel-example-generic')
 
       // Test to show that the carousel doesn't slide when the current tab isn't visible
-      // Test to show that transition-duration can be changed with css
       carousel.addEventListener('slid.bs.carousel', event => {
         t1 = performance.now()
-        console.log(`transition-duration took ${t1 - t0}ms, slid at ${event.timeStamp}`)
+        console.log(`slid took ${t1 - t0}ms, slid at ${event.timeStamp}`)
       })
       carousel.addEventListener('slide.bs.carousel', () => {
         t0 = performance.now()
index 550df2fc31a42911fee763ce0771bc21411734d4..f404212bffb81affaf789b71bf2aff0b9b599dc2 100644 (file)
 @use "config" as *;
-@use "colors" as *;
 @use "functions" as *;
+@use "mixins/border-radius" as *;
+@use "layout/breakpoints" as *;
 @use "mixins/transition" as *;
-@use "mixins/color-mode" as *;
 @use "mixins/mask-icon" as *;
 @use "mixins/tokens" as *;
 
 $carousel-tokens: () !default;
 
+// stylelint-disable custom-property-no-missing-var-function
 // scss-docs-start carousel-tokens
 // stylelint-disable-next-line scss/dollar-variable-default
 $carousel-tokens: defaults(
   (
-    --carousel-control-color: #{$white},
-    --carousel-control-width: 15%,
-    --carousel-control-opacity: .5,
-    --carousel-control-hover-opacity: .9,
-    --carousel-control-transition: opacity .15s ease,
-    --carousel-control-icon-filter: none,
-    --carousel-indicator-width: 30px,
-    --carousel-indicator-height: 3px,
-    --carousel-indicator-hit-area-height: 10px,
-    --carousel-indicator-spacer: 3px,
-    --carousel-indicator-opacity: .5,
-    --carousel-indicator-active-bg: var(--white),
-    --carousel-indicator-active-opacity: 1,
-    --carousel-indicator-transition: opacity .6s ease,
-    --carousel-caption-width: 70%,
-    --carousel-caption-color: var(--white),
-    --carousel-caption-padding-y: 1.25rem,
-    --carousel-caption-spacer: 1.25rem,
-    --carousel-control-icon-width: 2rem,
+    --carousel-gap: .75rem,
+    --carousel-indicator-bg: var(--fg-3),
+    --carousel-indicator-width: .75rem,
+    --carousel-indicator-height: .75rem,
+    --carousel-indicator-spacer: .25rem,
+    --carousel-indicator-transition: "opacity .6s ease, width .3s ease",
+    --carousel-indicator-progress-bg: var(--carousel-indicator-bg),
+    --carousel-control-icon-width: 1rem,
     --carousel-control-prev-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/></svg>"),
     --carousel-control-next-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/></svg>"),
-    --carousel-transition-duration: .6s,
-    --carousel-transition: transform .6s ease-in-out,
+    --carousel-control-pause-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5m5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5'/></svg>"),
+    --carousel-control-play-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z'/></svg>"),
+    // Scroll-snap engine. `gap` must carry a length unit: it feeds the
+    // `.carousel-item` flex-basis `calc()`, and subtracting a unitless `0` from a
+    // percentage is invalid CSS (it would drop the whole declaration and collapse
+    // every slide to its content width). `peek` only feeds `padding-inline`/
+    // `scroll-padding-inline`, so a bare `0` would be valid there, but we keep it
+    // unit-bearing for consistency.
+    --carousel-items: 1,
+    --carousel-items-gap: 0px,
+    --carousel-items-peek: 0px,
+    --carousel-fade-duration: .6s,
   ),
   $carousel-tokens
 );
 // scss-docs-end carousel-tokens
-
-$carousel-dark-tokens: () !default;
-
-// scss-docs-start carousel-dark-tokens
-// stylelint-disable-next-line scss/dollar-variable-default
-$carousel-dark-tokens: defaults(
-  (
-    --carousel-indicator-active-bg: #{$black},
-    --carousel-caption-color: #{$black},
-    --carousel-control-icon-filter: invert(1) grayscale(100),
-  ),
-  $carousel-dark-tokens
-);
-// scss-docs-end carousel-dark-tokens
-
-// Notes on the classes:
-//
-// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)
-//    even when their scroll action started on a carousel, but for compatibility (with Firefox)
-//    we're preventing all actions instead
-// 2. The .carousel-item-start and .carousel-item-end is used to indicate where
-//    the active slide is heading.
-// 3. .active.carousel-item is the current slide.
-// 4. .active.carousel-item-start and .active.carousel-item-end is the current
-//    slide in its in-transition state. Only one of these occurs at a time.
-// 5. .carousel-item-next.carousel-item-start and .carousel-item-prev.carousel-item-end
-//    is the upcoming slide in transition.
+// stylelint-enable custom-property-no-missing-var-function
 
 @layer components {
   .carousel {
-    position: relative;
     @include tokens($carousel-tokens);
-  }
 
-  .carousel.pointer-event {
-    touch-action: pan-y;
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    gap: var(--carousel-gap);
   }
 
+  // The scroll viewport
   .carousel-inner {
-    position: relative;
-    display: flow-root;
+    display: flex;
+    gap: var(--carousel-items-gap);
     width: 100%;
-    overflow: hidden;
+    padding-inline: var(--carousel-items-peek);
+    overflow-x: auto;
+    overscroll-behavior-x: contain;
+    scroll-snap-type: x mandatory;
+    scroll-padding-inline: var(--carousel-items-peek);
+    scrollbar-width: none; // Hide the scrollbar without losing scrollability
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+  }
+
+  // Smooth programmatic/keyboard scrolling, disabled under reduced-motion
+  @media (prefers-reduced-motion: no-preference) {
+    .carousel-inner {
+      scroll-behavior: smooth;
+    }
   }
 
   .carousel-item {
-    position: relative;
-    display: none;
-    float: inline-start;
-    width: 100%;
-    margin-inline-end: -100%;
-    backface-visibility: hidden;
-    @include transition(var(--carousel-transition));
+    // `100%` here is `.carousel-inner`'s content box, which `padding-inline`
+    // has already inset by the peek on each side, so the peek must NOT be
+    // subtracted again — doing so makes every slide `2 * peek` too narrow and
+    // the peek lopsided. Only the inter-slide gaps need removing.
+    flex: 0 0 calc((100% - (var(--carousel-items) - 1) * var(--carousel-items-gap)) / var(--carousel-items));
+    min-width: 0;
+    scroll-snap-align: start;
+    scroll-snap-stop: always;
   }
 
-  .carousel-item.active,
-  .carousel-item-next,
-  .carousel-item-prev {
-    display: block;
+  //
+  // Layout variants
+  //
+
+  // Center the active slide in the viewport (pairs well with `--carousel-items-peek`)
+  .carousel-center {
+    .carousel-item {
+      scroll-snap-align: center;
+    }
   }
 
-  .carousel-item-next:not(.carousel-item-start),
-  .active.carousel-item-end {
-    transform: translateX(100%);
+  // Let each slide size itself; snap points still land on every item
+  .carousel-auto {
+    .carousel-item {
+      flex-basis: auto;
+    }
   }
 
-  .carousel-item-prev:not(.carousel-item-end),
-  .active.carousel-item-start {
-    transform: translateX(-100%);
+  // Responsive items-per-view helpers, e.g. `.carousel-items-2` or `.md:carousel-items-3`.
+  // Defined after `.carousel` so they win on equal specificity.
+  @include loop-breakpoints-up() using ($breakpoint, $prefix) {
+    @include media-breakpoint-up($breakpoint) {
+      @for $i from 1 through 6 {
+        .#{$prefix}carousel-items-#{$i} {
+          --carousel-items: #{$i};
+        }
+      }
+    }
   }
 
   //
   // Alternate transitions
   //
 
+  // Fade can't ride scroll-snap (it stacks slides instead of scrolling), so it
+  // becomes a JavaScript-driven mode: every slide is stacked and the active one
+  // is faded in via a CSS opacity transition.
   .carousel-fade {
-    .carousel-item {
-      opacity: 0;
-      transition-property: opacity;
-      transform: none;
+    .carousel-inner {
+      display: grid;
+      overflow: hidden;
+      scroll-snap-type: none;
     }
 
-    .carousel-item.active,
-    .carousel-item-next.carousel-item-start,
-    .carousel-item-prev.carousel-item-end {
-      z-index: 1;
-      opacity: 1;
-    }
-
-    .active.carousel-item-start,
-    .active.carousel-item-end {
-      z-index: 0;
+    .carousel-item {
+      grid-area: 1 / 1;
+      width: 100%;
+      visibility: hidden;
       opacity: 0;
-      @include transition(opacity 0s var(--carousel-transition-duration));
+      @include transition(opacity var(--carousel-fade-duration) ease, visibility 0s linear var(--carousel-fade-duration));
     }
-  }
 
-  //
-  // Left/right controls for nav
-  //
-
-  .carousel-control-prev,
-  .carousel-control-next {
-    position: absolute;
-    inset-block: 0;
-    z-index: 1;
-    // Use flex for alignment (1-3)
-    display: flex; // 1. allow flex styles
-    align-items: center; // 2. vertically center contents
-    justify-content: center; // 3. horizontally center contents
-    width: var(--carousel-control-width);
-    padding: 0;
-    color: var(--carousel-control-color);
-    text-align: center;
-    background: none;
-    filter: var(--carousel-control-icon-filter);
-    border: 0;
-    opacity: var(--carousel-control-opacity);
-    @include transition(var(--carousel-control-transition));
-
-    // Hover/focus state
-    &:hover,
-    &:focus {
-      color: var(--carousel-control-color);
-      text-decoration: none;
-      outline: 0;
-      opacity: var(--carousel-control-hover-opacity);
+    .carousel-item.active {
+      visibility: visible;
+      opacity: 1;
+      @include transition(opacity var(--carousel-fade-duration) ease);
     }
   }
-  .carousel-control-prev {
-    inset-inline-start: 0;
-    // stylelint-disable-next-line scss/at-function-named-arguments, @stylistic/function-whitespace-after
-    background-image: if(sass($enable-gradients): linear-gradient(90deg, rgb(0 0 0 / .25), rgb(0 0 0 / .001)); else: null);
-  }
-  .carousel-control-next {
-    inset-inline-end: 0;
-    // stylelint-disable-next-line scss/at-function-named-arguments, @stylistic/function-whitespace-after
-    background-image: if(sass($enable-gradients): linear-gradient(270deg, rgb(0 0 0 / .25), rgb(0 0 0 / .001)); else: null);
-  }
 
-  // Icons for within, rendered via CSS mask so they inherit a configurable color
-  .carousel-control-prev-icon,
-  .carousel-control-next-icon {
+  // Icons for within, rendered via CSS mask so they inherit the current text
+  // color (white on the overlay controls, the button color inside `.btn-*`).
+  .carousel-icon-prev,
+  .carousel-icon-next,
+  .carousel-icon-pause,
+  .carousel-icon-play {
     display: inline-block;
     width: var(--carousel-control-icon-width);
     height: var(--carousel-control-icon-width);
-    background-color: var(--carousel-control-color);
+    background-color: currentcolor;
     @include mask-icon($size: 100% 100%, $position: 50%);
   }
 
-  .carousel-control-prev-icon {
+  .carousel-icon-prev {
     mask-image: var(--carousel-control-prev-icon);
   }
 
-  [dir="rtl"] .carousel-control-prev-icon {
+  .carousel-icon-next {
     mask-image: var(--carousel-control-next-icon);
   }
 
-  .carousel-control-next-icon {
-    mask-image: var(--carousel-control-next-icon);
+  [dir="rtl"] .carousel-icon-prev,
+  [dir="rtl"] .carousel-icon-next {
+    transform: scaleX(-1);
   }
 
-  [dir="rtl"] .carousel-control-next-icon {
-    mask-image: var(--carousel-control-prev-icon);
+  .carousel-icon-pause {
+    mask-image: var(--carousel-control-pause-icon);
   }
 
-  // Optional indicator pips/controls
+  .carousel-icon-play {
+    mask-image: var(--carousel-control-play-icon);
+  }
+
+  // Optional play/pause control
   //
-  // Add a container (such as a list) with the following class and add an item (ideally a focusable control,
-  // like a button) with data-bs-target for each slide your carousel holds.
+  // A discoverable toggle so users can stop an autoplaying carousel, as required
+  // by WCAG 2.2.2 (Pause, Stop, Hide). `.carousel-control-play-pause` is only a
+  // behavior hook—JS toggles `.paused` on it and its appearance comes from the
+  // wrapping button (e.g. `.btn-icon`). The button holds both glyphs and we show
+  // whichever `.carousel-icon-*` matches the current state.
+  .carousel-control-play-pause .carousel-icon-play {
+    display: none;
+  }
+
+  .carousel-control-play-pause.paused {
+    .carousel-icon-pause {
+      display: none;
+    }
+
+    .carousel-icon-play {
+      display: inline-block;
+    }
+  }
 
   .carousel-indicators {
-    position: absolute;
-    inset: auto 0 0;
-    z-index: 2;
     display: flex;
+    gap: var(--carousel-indicator-spacer);
     justify-content: center;
-    padding: 0;
-    // Use the .carousel-control's width as margin so we don't overlay those
-    margin-inline: var(--carousel-control-width);
-    margin-bottom: 1rem;
 
     [data-bs-target] {
-      box-sizing: content-box;
       flex: 0 1 auto;
       width: var(--carousel-indicator-width);
       height: var(--carousel-indicator-height);
       padding: 0;
-      margin-inline: var(--carousel-indicator-spacer);
-      text-indent: -999px;
       cursor: pointer;
-      background-color: var(--carousel-indicator-active-bg);
-      background-clip: padding-box;
-      border: 0;
-      // Use transparent borders to increase the hit area by 10px on top and bottom.
-      border-block: var(--carousel-indicator-hit-area-height) solid transparent;
-      opacity: var(--carousel-indicator-opacity);
+      background-color: transparent;
+      border: 1px solid var(--carousel-indicator-bg);
+      @include border-radius(var(--carousel-indicator-width));
       @include transition(var(--carousel-indicator-transition));
     }
 
     .active {
-      opacity: var(--carousel-indicator-active-opacity);
+      width: calc(var(--carousel-indicator-width) * 2.5);
+      background-color: var(--carousel-indicator-bg);
+      border-color: var(--carousel-indicator-bg);
     }
   }
 
-  // Optional captions
-  //
-  //
+  // Autoplay progress: fill the active indicator like a progress bar over the
+  // current slide's interval. The JS adds `.carousel-playing` and sets
+  // `--carousel-interval` (shipped as `--bs-carousel-interval`) while autoplay is
+  // running. The fill restarts on its own each slide because `.active` moves to a
+  // fresh indicator, so its `::after` animation begins from scratch.
+  @if $enable-transitions {
+    @keyframes carousel-indicator-progress {
+      from { inline-size: 0; }
+      to { inline-size: 100%; }
+    }
 
-  .carousel-caption {
-    position: absolute;
-    right: calc((100% - var(--carousel-caption-width)) * .5);
-    bottom: var(--carousel-caption-spacer);
-    left: calc((100% - var(--carousel-caption-width)) * .5);
-    padding-top: var(--carousel-caption-padding-y);
-    padding-bottom: var(--carousel-caption-padding-y);
-    color: var(--carousel-caption-color);
-    text-align: center;
+    .carousel-playing .carousel-indicators .active {
+      @media (prefers-reduced-motion: no-preference) {
+        position: relative;
+        overflow: hidden;
+        // Empty the pill so it reads as a track that the fill grows across.
+        background-color: transparent;
+
+        &::after {
+          position: absolute;
+          inset-block: 0;
+          inset-inline-start: 0;
+          inline-size: 0;
+          content: "";
+          background-color: var(--carousel-indicator-progress-bg);
+          animation: carousel-indicator-progress var(--carousel-interval, 5000ms) linear forwards;
+        }
+      }
+    }
   }
 
-  // Dark mode carousel
-
-  @mixin carousel-dark() {
-    @include tokens($carousel-dark-tokens);
-  }
+  // Overlay layout
+  //
+  // Overlays the prev/next controls, play/pause button, and indicators on top of
+  // the slides (the classic carousel look) instead of stacking them in the flow.
 
-  .carousel-dark {
-    @include carousel-dark();
-  }
+  .carousel-overlay {
+    --carousel-indicator-bg: light-dark(var(--white), var(--black));
 
-  @if $enable-dark-mode {
-    @include color-mode(dark, true) {
-      @include carousel-dark();
+    .carousel-overlay-controls {
+      position: absolute;
+      inset-block-end: 1rem;
+      inset-inline: 1rem;
+      z-index: 1;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
     }
   }
 }
index 96667f43da4f352ffc48242b61b4c7bdd137252d..580f0cc13115b42a63f29ca5103e823efebcbad6 100644 (file)
@@ -39,8 +39,6 @@ $enable-button-pointers:      true !default;
 // $enable-negative-margins:     false !default;
 $enable-deprecation-messages: true !default;
 
-$enable-dark-mode:            true !default;
-
 $color-mode-type:          "media-query" !default;
 $color-contrast-dark:      #000 !default;
 $color-contrast-light:     #fff !default;
index 0607e93595459a9d056536a2de4ef5fddc1f5613..8eace128f2806b1f18335775c909a64490a27cd4 100644 (file)
@@ -5,7 +5,7 @@
 body {
   padding-top: 3rem;
   padding-bottom: 3rem;
-  color: rgb(var(--bs-bs-bs-bs-bs-tertiary-color-rgb));
+  color: var(--bs-tertiary-color);
 }
 
 
@@ -16,17 +16,24 @@ body {
 .carousel {
   margin-bottom: 4rem;
 }
-/* Since positioning the image, we need to help out the caption */
-.carousel-caption {
-  bottom: 3rem;
-  z-index: 10;
-}
 
 /* Declare heights because of positioning of img element */
 .carousel-item {
+  position: relative;
   height: 32rem;
 }
 
+/* Custom hero content overlaid on each slide (replaces the old .carousel-caption) */
+.carousel-hero {
+  position: absolute;
+  inset-inline: 15%;
+  bottom: 3rem;
+  z-index: 10;
+  padding-top: 1.25rem;
+  padding-bottom: 1.25rem;
+  color: var(--bs-white);
+}
+
 
 /* MARKETING CONTENT
 -------------------------------------------------- */
@@ -58,7 +65,7 @@ body {
 
 @media (min-width: 40em) {
   /* Bump up size of carousel content */
-  .carousel-caption p {
+  .carousel-hero p {
     margin-bottom: 1.25rem;
     font-size: 1.25rem;
     line-height: 1.4;
index a98848b2896749576f71951679dd8fbcd9b5b03e..c6cd68574f3e8e3b2846c32605a8b9cf282974b3 100644 (file)
@@ -34,52 +34,54 @@ import Placeholder from "@shortcodes/Placeholder.astro"
 
 <main>
 
-  <div id="myCarousel" class="carousel slide mb-6" data-bs-ride="carousel">
-    <div class="carousel-indicators">
-      <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
-      <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="1" aria-label="Slide 2"></button>
-      <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="2" aria-label="Slide 3"></button>
-    </div>
+  <div id="myCarousel" class="carousel slide mb-6" data-bs-autoplay="true">
     <div class="carousel-inner">
       <div class="carousel-item active">
         <Placeholder width="100%" height="100%" background="var(--bs-secondary-color)" text={false} title={false} />
-        <div class="container">
-          <div class="carousel-caption text-start">
-            <h1>Example headline.</h1>
-            <p class="opacity-75">Some representative placeholder content for the first slide of the carousel.</p>
-            <p><a class="btn-solid theme-primary btn-lg" href="#">Sign up today</a></p>
-          </div>
+        <div class="carousel-hero text-start">
+          <h1>Example headline.</h1>
+          <p class="opacity-75">Some representative placeholder content for the first slide of the carousel.</p>
+          <p><a class="btn-solid theme-primary btn-lg" href="#">Sign up today</a></p>
         </div>
       </div>
       <div class="carousel-item">
         <Placeholder width="100%" height="100%" background="var(--bs-secondary-color)" text={false} title={false} />
-        <div class="container">
-          <div class="carousel-caption">
-            <h1>Another example headline.</h1>
-            <p>Some representative placeholder content for the second slide of the carousel.</p>
-            <p><a class="btn-solid theme-primary btn-lg" href="#">Learn more</a></p>
-          </div>
+        <div class="carousel-hero text-center">
+          <h1>Another example headline.</h1>
+          <p>Some representative placeholder content for the second slide of the carousel.</p>
+          <p><a class="btn-solid theme-primary btn-lg" href="#">Learn more</a></p>
         </div>
       </div>
       <div class="carousel-item">
         <Placeholder width="100%" height="100%" background="var(--bs-secondary-color)" text={false} title={false} />
-        <div class="container">
-          <div class="carousel-caption text-end">
-            <h1>One more for good measure.</h1>
-            <p>Some representative placeholder content for the third slide of this carousel.</p>
-            <p><a class="btn-solid theme-primary btn-lg" href="#">Browse gallery</a></p>
-          </div>
+        <div class="carousel-hero text-end">
+          <h1>One more for good measure.</h1>
+          <p>Some representative placeholder content for the third slide of this carousel.</p>
+          <p><a class="btn-solid theme-primary btn-lg" href="#">Browse gallery</a></p>
         </div>
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#myCarousel" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#myCarousel" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
+    <div class="d-flex justify-content-between align-items-center mt-3">
+      <div class="carousel-indicators">
+        <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+        <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="1" aria-label="Slide 2"></button>
+        <button type="button" data-bs-target="#myCarousel" data-bs-slide-to="2" aria-label="Slide 3"></button>
+      </div>
+      <div>
+        <button class="btn-icon btn-sm carousel-control-play-pause" type="button" data-bs-target="#myCarousel" aria-label="Pause" data-bs-pause-label="Pause" data-bs-play-label="Play">
+          <span class="carousel-icon-pause" aria-hidden="true"></span>
+          <span class="carousel-icon-play" aria-hidden="true"></span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#myCarousel" data-bs-slide="prev">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#myCarousel" data-bs-slide="next">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+    </div>
   </div>
 
 
index 332794f6d62690a8b69aa0069a3f9a84372cd5b3..3135fab285cdaebb58be4f0b56b22dbc80297dc2 100644 (file)
@@ -848,43 +848,35 @@ export const body_class = 'bg-body-tertiary'
 
       <div>
         <Example showMarkup={false} code={`
-        <div id="carouselExampleCaptions" class="carousel slide" data-bs-ride="carousel">
-          <div class="carousel-indicators">
-            <button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
-            <button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="1" aria-label="Slide 2"></button>
-            <button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="2" aria-label="Slide 3"></button>
-          </div>
+        <div id="carouselExample" class="carousel slide">
           <div class="carousel-inner">
             <div class="carousel-item active">
-              <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
-              <div class="carousel-caption d-none md:d-block">
-                <h5>First slide label</h5>
-                <p>Some representative placeholder content for the first slide.</p>
-              </div>
+              <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#555" background="#777" text="First slide" />
             </div>
             <div class="carousel-item">
-              <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#444" background="#666" text="Second slide" />
-              <div class="carousel-caption d-none md:d-block">
-                <h5>Second slide label</h5>
-                <p>Some representative placeholder content for the second slide.</p>
-              </div>
+              <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#444" background="#666" text="Second slide" />
             </div>
             <div class="carousel-item">
-              <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
-              <div class="carousel-caption d-none md:d-block">
-                <h5>Third slide label</h5>
-                <p>Some representative placeholder content for the third slide.</p>
-              </div>
+              <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#333" background="#555" text="Third slide" />
+            </div>
+          </div>
+          <div class="d-flex justify-content-between align-items-center mt-3">
+            <div class="carousel-indicators">
+              <button type="button" data-bs-target="#carouselExample" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+              <button type="button" data-bs-target="#carouselExample" data-bs-slide-to="1" aria-label="Slide 2"></button>
+              <button type="button" data-bs-target="#carouselExample" data-bs-slide-to="2" aria-label="Slide 3"></button>
+            </div>
+            <div>
+              <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExample" data-bs-slide="prev">
+                <span class="carousel-icon-prev" aria-hidden="true"></span>
+                <span class="visually-hidden">Previous</span>
+              </button>
+              <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExample" data-bs-slide="next">
+                <span class="carousel-icon-next" aria-hidden="true"></span>
+                <span class="visually-hidden">Next</span>
+              </button>
             </div>
           </div>
-          <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleCaptions"  data-bs-slide="prev">
-            <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-            <span class="visually-hidden">Previous</span>
-          </button>
-          <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleCaptions"  data-bs-slide="next">
-            <span class="carousel-control-next-icon" aria-hidden="true"></span>
-            <span class="visually-hidden">Next</span>
-          </button>
         </div>
         `} />
       </div>
index 60e7664907ad750c4bea4737b7a7d44f697f483b..922c3767c3555404b6cc35b06b45765c72831c9a 100644 (file)
@@ -120,7 +120,7 @@ export default () => {
   // Carousels
   // --------
   // Instantiate all non-autoplaying carousels in docs or StackBlitz
-  document.querySelectorAll('.carousel:not([data-bs-ride="carousel"])')
+  document.querySelectorAll('.carousel:not([data-bs-autoplay="true"])')
     .forEach(carousel => {
       Carousel.getOrCreateInstance(carousel)
     })
index 97738cab2757fb6e6ed94feef0c8b4130e359655..5b4f05750a0ff577189c56a0709c5a3f41ca652f 100644 (file)
@@ -1,6 +1,6 @@
 ---
 title: Carousel
-description: A slideshow component for cycling through elements—images or slides of text—like a carousel.
+description: A slider or slideshow component for cycling through series of content, ranging from products to images and more, one at a time.
 toc: true
 css_layer: components
 js: required
@@ -8,22 +8,41 @@ js: required
 
 ## How it works
 
-- The carousel is a slideshow for cycling through a series of content, built with CSS 3D transforms and a bit of JavaScript. It works with a series of images, text, or custom markup. It also includes support for previous/next controls and indicators.
+- Carousel is built on CSS’s [scroll-snap](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll_snap), so sliding, touch dragging, momentum, and keyboard scrolling all come from the browser. JavaScript manages autoplay, the previous/next controls, the indicators, and active-slide syncing.
 
-- For performance reasons, **carousels must be manually initialized** using the [carousel constructor method](#methods). Without initialization, some of the event listeners (specifically, the events needed touch/swipe support) will not be registered until a user has explicitly activated a control or indicator.
+- Because the slides live in a real scroll container, the carousel can show [more than one slide at a time](#items), reveal a “peek” of the neighboring slides, and mix variable-width slides—all controlled with CSS.
 
-  The only exception are [autoplaying carousels](#autoplaying-carousels) with the `data-bs-ride="carousel"` attribute as these are initialized automatically on page load. If you’re using autoplaying carousels with the data attribute, **don’t explicitly initialize the same carousels with the constructor method.**
+- Carousels can have any number of slides, with just about any content within. Our examples show images and text, but you can put anything you like inside each `.carousel-item`. We don't support nested carousels.
 
-- Nested carousels are not supported. You should also be aware that carousels in general can often cause usability and accessibility challenges.
+- Carousel items or slides do not normalize their dimensions. As such, you may need to use additional utilities or custom styles to appropriately size content. While carousels support previous/next controls and indicators, they’re not explicitly required. Add and customize as you see fit.
+
+- Some behaviors that need a manually initialized instance before any interaction are autoplay on page load and active-slide syncing as the user scrolls or swipes (the indicators only start tracking the visible slide once an instance exists). [Autoplaying carousels](#autoplay) with the `data-bs-autoplay="true"` attribute are initialized automatically on page load; otherwise, initialize with the [constructor method](#methods). If you’re using autoplaying carousels with the data attribute, **don’t explicitly initialize the same carousels with the constructor method.**
+
+- **Mark one slide `.active` to set the starting slide.** It seeds the active state that drives indicator syncing and—in `.carousel-fade`—which slide is shown; if no slide is `.active`, the carousel starts on the first one. Also be sure to set a unique `id` on the `.carousel` for optional controls, especially if you’re using multiple carousels on a single page. Control and indicator elements must have a `data-bs-target` attribute (or `href` for links) that matches the `id` of the `.carousel` element.
 
 <Callout name="info-prefersreducedmotion" />
 
-## Basic examples
+## Examples
 
-Here is a basic example of a carousel with three slides. Note the previous/next controls. We recommend using `<button>` elements, but you can also use `<a>` elements with `role="button"`.
+### Top controls
 
-<Example code={`<div id="carouselExample" class="carousel slide">
-    <div class="carousel-inner">
+The default carousel has a simple design that you can customize. Controls can be placed anywhere, including outside the inner carousel container. They can sit at the top or bottom of the carousel, with any order of controls and any additional content you may need.
+
+<Example code={`<div id="carouselStacked" class="carousel slide">
+    <div class="d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Top controls</h5>
+      <div>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselStacked" data-bs-slide="prev">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselStacked" data-bs-slide="next">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+    </div>
+    <div class="carousel-inner rounded-5">
       <div class="carousel-item active">
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
       </div>
@@ -34,31 +53,14 @@ Here is a basic example of a carousel with three slides. Note the previous/next
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExample" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExample" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
   </div>`} />
 
-Carousels don’t automatically normalize slide dimensions. As such, you may need to use additional utilities or custom styles to appropriately size content. While carousels support previous/next controls and indicators, they’re not explicitly required. Add and customize as you see fit.
-
-**You must add the `.active` class to one of the slides**, otherwise the carousel will not be visible. Also be sure to set a unique `id` on the `.carousel` for optional controls, especially if you’re using multiple carousels on a single page. Control and indicator elements must have a `data-bs-target` attribute (or `href` for links) that matches the `id` of the `.carousel` element.
-
-### Indicators
+### Bottom controls
 
-You can add indicators to the carousel, alongside the previous/next controls. The indicators let users jump directly to a particular slide.
+Similar to the controls on top, but with the controls at the bottom of the carousel and different content.
 
-<Example code={`<div id="carouselExampleIndicators" class="carousel slide">
-    <div class="carousel-indicators">
-      <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
-      <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="1" aria-label="Slide 2"></button>
-      <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="2" aria-label="Slide 3"></button>
-    </div>
-    <div class="carousel-inner">
+<Example code={`<div id="carouselStackedBottom" class="carousel slide">
+    <div class="carousel-inner rounded-5">
       <div class="carousel-item active">
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
       </div>
@@ -69,97 +71,103 @@ You can add indicators to the carousel, alongside the previous/next controls. Th
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
+    <div>
+      <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselStackedBottom" data-bs-slide="prev">
+        <span class="carousel-icon-prev" aria-hidden="true"></span>
+        <span class="visually-hidden">Previous</span>
+      </button>
+      <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselStackedBottom" data-bs-slide="next">
+        <span class="carousel-icon-next" aria-hidden="true"></span>
+        <span class="visually-hidden">Next</span>
+      </button>
+    </div>
   </div>`} />
 
-### Captions
+### Indicators
 
-You can add captions to your slides with the `.carousel-caption` element within any `.carousel-item`. They can be easily hidden on smaller viewports, as shown below, with optional [display utilities]([[docsref:/utilities/display]]). We hide them initially with `.d-none` and bring them back on medium-sized devices with `.md:d-block`.
+Add indicator buttons wherever you want them to reside. With an [overlay carousel](#overlay-controls), place them inside `.carousel-overlay-controls`, which is positioned over the bottom of the slides—their exact alignment then depends on how you lay out that row (for example, centered between the prev/next controls).
 
-<Example code={`<div id="carouselExampleCaptions" class="carousel slide">
-    <div class="carousel-indicators">
-      <button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
-      <button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="1" aria-label="Slide 2"></button>
-      <button type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide-to="2" aria-label="Slide 3"></button>
-    </div>
-    <div class="carousel-inner">
+<Example code={`<div id="carouselStackedIndicators" class="carousel slide">
+    <div class="carousel-inner rounded-5">
       <div class="carousel-item active">
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
-        <div class="carousel-caption d-none md:d-block">
-          <h5>First slide label</h5>
-          <p>Some representative placeholder content for the first slide.</p>
-        </div>
       </div>
       <div class="carousel-item">
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#444" background="#666" text="Second slide" />
-        <div class="carousel-caption d-none md:d-block">
-          <h5>Second slide label</h5>
-          <p>Some representative placeholder content for the second slide.</p>
-        </div>
       </div>
       <div class="carousel-item">
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
-        <div class="carousel-caption d-none md:d-block">
-          <h5>Third slide label</h5>
-          <p>Some representative placeholder content for the third slide.</p>
-        </div>
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleCaptions" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
+    <div class="d-flex justify-content-between align-items-center">
+      <div>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselStackedIndicators" data-bs-slide="prev">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselStackedIndicators" data-bs-slide="next">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+      <div class="carousel-indicators">
+        <button type="button" data-bs-target="#carouselStackedIndicators" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+        <button type="button" data-bs-target="#carouselStackedIndicators" data-bs-slide-to="1" aria-label="Slide 2"></button>
+        <button type="button" data-bs-target="#carouselStackedIndicators" data-bs-slide-to="2" aria-label="Slide 3"></button>
+      </div>
+    </div>
   </div>`} />
 
-### Crossfade
+### Custom content
 
-Add `.carousel-fade` to your carousel to animate slides with a fade transition instead of a slide. Depending on your carousel content (e.g., text only slides), you may want to add `.bg-body` or some custom CSS to the `.carousel-item`s for proper crossfading.
+You can put any markup you like inside each `.carousel-item`. Add content, then size and style it with utilities or custom CSS as needed.
 
-<Example code={`<div id="carouselExampleFade" class="carousel slide carousel-fade">
-    <div class="carousel-inner">
+<Example code={`<div id="carouselExampleContent" class="carousel slide">
+    <div class="carousel-inner rounded-5">
       <div class="carousel-item active">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
+        <div class="d-flex flex-column justify-content-center bg-primary-subtle p-9 bg-1 rounded-5" style="min-height: 320px;">
+          <h3>Build anything</h3>
+          <p class="text-secondary-emphasis mb-3">Compose slides from your own markup—text, buttons, cards, or media.</p>
+          <div><a class="btn-solid theme-primary" href="#">Get started</a></div>
+        </div>
       </div>
       <div class="carousel-item">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#444" background="#666" text="Second slide" />
+        <div class="d-flex flex-column justify-content-center text-center bg-success-subtle p-9 bg-2 rounded-5" style="min-height: 320px;">
+          <h3>Style it your way</h3>
+          <p class="text-secondary-emphasis mb-3">Use utilities or custom CSS to size and theme each slide however you need.</p>
+          <div><a class="btn-solid theme-success" href="#">Learn more</a></div>
+        </div>
       </div>
       <div class="carousel-item">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
+        <div class="d-flex flex-column justify-content-center text-end bg-warning-subtle p-9 bg-3 rounded-5" style="min-height: 320px;">
+          <h3>Mix and match</h3>
+          <p class="text-secondary-emphasis mb-3">Combine custom content with controls, indicators, and the overlay layout.</p>
+          <div><a class="btn-solid theme-inverse" href="#">Browse examples</a></div>
+        </div>
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleFade" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleFade" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
+    <div class="d-flex justify-content-center gap-2">
+      <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExampleContent" data-bs-slide="prev" style="--bs-carousel-control-icon-width: 1rem;">
+        <span class="carousel-icon-prev" aria-hidden="true"></span>
+        <span class="visually-hidden">Previous</span>
+      </button>
+      <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExampleContent" data-bs-slide="next" style="--bs-carousel-control-icon-width: 1rem;">
+        <span class="carousel-icon-next" aria-hidden="true"></span>
+        <span class="visually-hidden">Next</span>
+      </button>
+    </div>
   </div>`} />
 
-## Autoplaying carousels
+## Modifiers
 
-You can make your carousels autoplay on page load by setting the `ride` option to `carousel`. Autoplaying carousels automatically pause while hovered with the mouse. This behavior can be controlled with the `pause` option. In browsers that support the [Page Visibility API](https://www.w3.org/TR/page-visibility/), the carousel will stop cycling when the webpage is not visible to the user (such as when the browser tab is inactive, or when the browser window is minimized).
+Change the default carousel appearance with alternate control placement and animations.
 
-<Callout>
-For accessibility reasons, we recommend avoiding the use of autoplaying carousels. If your page does include an autoplaying carousel, we recommend providing an additional button or control to explicitly pause/stop the carousel.
+### Overlay controls
 
-See [WCAG 2.2 Success Criterion 2.2.2 Pause, Stop, Hide](https://www.w3.org/TR/WCAG/#pause-stop-hide).
-</Callout>
+Add `.carousel-overlay` to overlay carousel controls on top of the slides. Wrap any controls you want to overlay in `.carousel-overlay-controls`.
 
-<Example code={`<div id="carouselExampleAutoplaying" class="carousel slide" data-bs-ride="carousel">
-    <div class="carousel-inner">
+<Example code={`<div id="carouselExample" class="carousel carousel-overlay slide"><!--[!code highlight]-->
+    <div class="carousel-inner rounded-5">
       <div class="carousel-item active">
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
       </div>
@@ -170,72 +178,289 @@ See [WCAG 2.2 Success Criterion 2.2.2 Pause, Stop, Hide](https://www.w3.org/TR/W
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleAutoplaying" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleAutoplaying" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
+    <div class="carousel-overlay-controls"><!--[!code highlight]-->
+      <div>
+        <button class="btn-icon btn-sm btn-subtle theme-secondary" type="button" data-bs-target="#carouselExample" data-bs-slide="prev">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm btn-subtle theme-secondary" type="button" data-bs-target="#carouselExample" data-bs-slide="next">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+      <div class="carousel-indicators">
+        <button type="button" data-bs-target="#carouselExample" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+        <button type="button" data-bs-target="#carouselExample" data-bs-slide-to="1" aria-label="Slide 2"></button>
+        <button type="button" data-bs-target="#carouselExample" data-bs-slide-to="2" aria-label="Slide 3"></button>
+      </div>
+    </div><!--[!code highlight]-->
   </div>`} />
 
-When the `ride` option is set to `true`, rather than `carousel`, the carousel won’t automatically start to cycle on page load. Instead, it will only start after the first user interaction.
+### Crossfade
+
+Add `.carousel-fade` to your carousel to animate slides with a fade transition instead of a slide. Depending on your carousel content (e.g., text only slides), you may want to add `.bg-body` or some custom CSS to the `.carousel-item`s for proper crossfading. The crossfade is a CSS opacity transition over `--bs-carousel-fade-duration`, and collapses to an instant swap for users who prefer reduced motion.
 
-<Example code={`<div id="carouselExampleRide" class="carousel slide" data-bs-ride="true">
+<Example code={`<div id="carouselExampleFade" class="carousel carousel-fade"><!--[!code highlight]-->
     <div class="carousel-inner">
       <div class="carousel-item active">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#555" background="#777" text="First slide" />
       </div>
       <div class="carousel-item">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#444" background="#666" text="Second slide" />
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#444" background="#666" text="Second slide" />
       </div>
       <div class="carousel-item">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#333" background="#555" text="Third slide" />
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleRide" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleRide" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
+    <div>
+      <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExampleFade" data-bs-slide="prev">
+        <span class="carousel-icon-prev" aria-hidden="true"></span>
+        <span class="visually-hidden">Previous</span>
+      </button>
+      <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExampleFade" data-bs-slide="next">
+        <span class="carousel-icon-next" aria-hidden="true"></span>
+        <span class="visually-hidden">Next</span>
+      </button>
+    </div>
   </div>`} />
 
-### Individual `.carousel-item` interval
+## Items
 
-Add `data-bs-interval=""` to a `.carousel-item` to change the amount of time to delay between automatically cycling to the next item.
+Because the carousel is a real scroll container, you can change how many slides are visible and how they’re sized with a few CSS custom properties on the `.carousel` without writing any JavaScript.
 
-<Example code={`<div id="carouselExampleInterval" class="carousel slide" data-bs-ride="carousel">
+- `--bs-carousel-items` sets the number of whole slides visible per view (default `1`).
+- `--bs-carousel-items-gap` sets the space between slides (default `0`).
+- `--bs-carousel-items-peek` sets how much of the neighboring slides to reveal (default `0`).
+
+### Multiple items
+
+Set `--bs-carousel-items` to show several slides at once. Combine it with `--bs-carousel-items-gap` for spacing.
+
+<Example code={`<div id="carouselMultiple" class="carousel slide" data-bs-ends="stop" style="--bs-carousel-items: 3; --bs-carousel-items-gap: 1rem;"><!--[!code highlight]-->
+    <div class="d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Three at a time</h5>
+      <div>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselMultiple" data-bs-slide="prev" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselMultiple" data-bs-slide="next" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+    </div>
     <div class="carousel-inner">
-      <div class="carousel-item active" data-bs-interval="10000">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
+      <div class="carousel-item active"><Placeholder width="260" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#777" text="1" /></div>
+      <div class="carousel-item"><Placeholder width="260" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#6c6c6c" text="2" /></div>
+      <div class="carousel-item"><Placeholder width="260" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#666" text="3" /></div>
+      <div class="carousel-item"><Placeholder width="260" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#5c5c5c" text="4" /></div>
+      <div class="carousel-item"><Placeholder width="260" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#555" text="5" /></div>
+      <div class="carousel-item"><Placeholder width="260" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#4c4c4c" text="6" /></div>
+      <div class="carousel-item"><Placeholder width="260" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#444" text="7" /></div>
+    </div>
+  </div>`} />
+
+Use the responsive `.carousel-items-*` utilities to vary the count per breakpoint, e.g. `class="carousel carousel-items-1 md:carousel-items-3"`.
+
+### Peeking
+
+Set `--bs-carousel-items-peek` to reveal a sliver of the previous and next slides, hinting that there’s more to scroll.
+
+<Example code={`<div id="carouselPeek" class="carousel slide" data-bs-ends="stop" style="--bs-carousel-items-peek: 3rem; --bs-carousel-items-gap: 1rem;"><!--[!code highlight]-->
+    <div class="d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Peek the neighbors</h5>
+      <div>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselPeek" data-bs-slide="prev" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselPeek" data-bs-slide="next" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
       </div>
-      <div class="carousel-item" data-bs-interval="2000">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#444" background="#666" text="Second slide" />
+    </div>
+    <div class="carousel-inner">
+      <div class="carousel-item active"><Placeholder width="800" height="300" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#fff" background="#777" text="First slide" /></div>
+      <div class="carousel-item"><Placeholder width="800" height="300" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#fff" background="#666" text="Second slide" /></div>
+      <div class="carousel-item"><Placeholder width="800" height="300" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#fff" background="#555" text="Third slide" /></div>
+      <div class="carousel-item"><Placeholder width="800" height="300" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#fff" background="#444" text="Fourth slide" /></div>
+      <div class="carousel-item"><Placeholder width="800" height="300" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#fff" background="#333" text="Fifth slide" /></div>
+    </div>
+  </div>`} />
+
+### Variable width
+
+Add `.carousel-auto` and size each `.carousel-item` yourself (utilities or custom CSS). Snap points still land on every slide.
+
+<Example code={`<div id="carouselVariable" class="carousel carousel-auto slide" data-bs-ends="stop" style="--bs-carousel-items-gap: 1rem;"><!--[!code highlight]-->
+    <div class="d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Mixed widths</h5>
+      <div>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselVariable" data-bs-slide="prev" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselVariable" data-bs-slide="next" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
       </div>
-      <div class="carousel-item">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
+    </div>
+    <div class="carousel-inner">
+      <div class="carousel-item active" style="width: 160px;"><!--[!code highlight]-->
+        <Placeholder width="160" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#777" text="A" />
+      </div>
+      <div class="carousel-item" style="width: 320px;"><!--[!code highlight]-->
+        <Placeholder width="320" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#666" text="B" />
+      </div>
+      <div class="carousel-item" style="width: 220px;"><!--[!code highlight]-->
+        <Placeholder width="220" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#555" text="C" />
+      </div>
+      <div class="carousel-item" style="width: 380px;"><!--[!code highlight]-->
+        <Placeholder width="380" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#4c4c4c" text="D" />
+      </div>
+      <div class="carousel-item" style="width: 200px;"><!--[!code highlight]-->
+        <Placeholder width="200" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#444" text="E" />
+      </div>
+      <div class="carousel-item" style="width: 300px;"><!--[!code highlight]-->
+        <Placeholder width="300" height="200" class="bd-placeholder-img d-block w-100 rounded-5" color="#fff" background="#333" text="F" />
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleInterval" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleInterval" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
   </div>`} />
 
-### Autoplaying carousels without controls
+Add `.carousel-center` to snap the active slide to the center of the viewport instead of its start—pairs nicely with `--bs-carousel-items-peek`.
 
-Here’s a carousel with slides only. Note the presence of the `.d-block` and `.w-100` on carousel images to prevent browser default image alignment.
+## Autoplay
+
+Set the `autoplay` option to `true` (or add `data-bs-autoplay="true"`) to cycle a carousel automatically on page load. Autoplay pauses on hover—configurable with the `pause` option—and, where the [Page Visibility API](https://www.w3.org/TR/page-visibility/) is supported, while the page isn’t visible. As soon as a visitor takes control—clicking a control or indicator, using the keyboard, or swiping—autoplay stops for good, so content isn’t moved out from under them.
+
+<Callout>
+For accessibility reasons, we recommend avoiding the use of autoplaying carousels. If your page does include one, provide a discoverable control to pause and resume it: add a `.carousel-control-play-pause` button (shown below). This satisfies [WCAG 2.2 Success Criterion 2.2.2 Pause, Stop, Hide](https://www.w3.org/TR/WCAG/#pause-stop-hide), which a hover- or focus-only pause does not.
+</Callout>
 
-<Example code={`<div id="carouselExampleSlidesOnly" class="carousel slide" data-bs-ride="carousel">
+The play/pause button toggles autoplay and reflects the current state automatically—a pause icon while playing, a play icon while stopped. Give each state an accessible name with the `data-bs-play-label` and `data-bs-pause-label` attributes. While playing, the active indicator fills like a progress bar over the current slide’s [interval](#individual-intervals) as a visual countdown; it pauses with autoplay and is disabled under reduced motion. Set its color with the `--bs-carousel-indicator-progress-bg` CSS variable.
+
+<Example code={`<div id="carouselExampleAutoplaying" class="carousel slide" data-bs-autoplay="true">
     <div class="carousel-inner">
+      <div class="carousel-item active">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#555" background="#777" text="First slide" />
+      </div>
+      <div class="carousel-item">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#444" background="#666" text="Second slide" />
+      </div>
+      <div class="carousel-item">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#333" background="#555" text="Third slide" />
+      </div>
+    </div>
+    <div class="d-flex justify-content-between align-items-center">
+      <div class="carousel-indicators">
+        <button type="button" data-bs-target="#carouselExampleAutoplaying" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+        <button type="button" data-bs-target="#carouselExampleAutoplaying" data-bs-slide-to="1" aria-label="Slide 2"></button>
+        <button type="button" data-bs-target="#carouselExampleAutoplaying" data-bs-slide-to="2" aria-label="Slide 3"></button>
+      </div>
+      <div>
+        <button class="btn-icon btn-sm carousel-control-play-pause" type="button" data-bs-target="#carouselExampleAutoplaying" aria-label="Pause" data-bs-pause-label="Pause" data-bs-play-label="Play">
+          <span class="carousel-icon-pause" aria-hidden="true"></span>
+          <span class="carousel-icon-play" aria-hidden="true"></span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExampleAutoplaying" data-bs-slide="prev">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselExampleAutoplaying" data-bs-slide="next">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+    </div>
+  </div>`} />
+
+### Individual intervals
+
+Add `data-bs-interval=""` to a `.carousel-item` to change the amount of time to delay between automatically cycling to the next item. The indicator’s progress fill matches each slide’s own interval, so the countdown speeds up or slows down to match (2s, then 4s, then 6s below).
+
+<Example code={`<div id="carouselExampleInterval" class="carousel slide" data-bs-autoplay="true">
+    <div class="carousel-inner rounded-5">
+      <div class="carousel-item active" data-bs-interval="2000">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First: 2 seconds" />
+      </div>
+      <div class="carousel-item" data-bs-interval="4000">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#444" background="#666" text="Second: 4 seconds" />
+      </div>
+      <div class="carousel-item" data-bs-interval="6000">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third: 6 seconds" />
+      </div>
+    </div>
+    <div class="d-flex justify-content-between align-items-center mt-3">
+      <div class="carousel-indicators">
+        <button type="button" data-bs-target="#carouselExampleInterval" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+        <button type="button" data-bs-target="#carouselExampleInterval" data-bs-slide-to="1" aria-label="Slide 2"></button>
+        <button type="button" data-bs-target="#carouselExampleInterval" data-bs-slide-to="2" aria-label="Slide 3"></button>
+      </div>
+      <button class="btn-icon btn-sm carousel-control-play-pause" type="button" data-bs-target="#carouselExampleInterval" aria-label="Pause" data-bs-pause-label="Pause" data-bs-play-label="Play">
+        <span class="carousel-icon-pause" aria-hidden="true"></span>
+        <span class="carousel-icon-play" aria-hidden="true"></span>
+      </button>
+    </div>
+  </div>`} />
+
+## End behavior
+
+The `ends` option controls what happens at the first and last slide. Set it with `data-bs-ends` (or the `ends` option in JavaScript), choosing from `loop` (the default), `wrap`, or `stop`.
+
+### Loop (default)
+
+With `data-bs-ends="loop"` the carousel scrolls seamlessly past the ends: stepping next from the last slide (or previous from the first) continues in the same direction into the destination slide instead of jumping back, for an endless conveyor effect. This is the default, so it applies with or without the attribute. Seamless looping applies to single-slide carousels driven by the controls, keyboard, or autoplay; multi-item, peek, centered, and variable-width layouts—as well as users who prefer reduced motion—fall back to the `wrap` jump.
+
+<Example code={`<div id="carouselEndsLoop" class="carousel slide" data-bs-ends="loop">
+    <div class="d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Loop</h5>
+      <div>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselEndsLoop" data-bs-slide="prev" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselEndsLoop" data-bs-slide="next" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+    </div>
+    <div class="carousel-inner rounded-5">
+      <div class="carousel-item active">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#555" background="#777" text="First slide" />
+      </div>
+      <div class="carousel-item">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#444" background="#666" text="Second slide" />
+      </div>
+      <div class="carousel-item">
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#333" background="#555" text="Third slide" />
+      </div>
+    </div>
+  </div>`} />
+
+### Wrap
+
+With `data-bs-ends="wrap"` the carousel jumps from the last slide back to the first, and vice versa, so the controls never reach a dead end.
+
+<Example code={`<div id="carouselEndsWrap" class="carousel slide" data-bs-ends="wrap">
+    <div class="d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Wrap around</h5>
+      <div>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselEndsWrap" data-bs-slide="prev" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselEndsWrap" data-bs-slide="next" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+    </div>
+    <div class="carousel-inner rounded-5">
       <div class="carousel-item active">
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
       </div>
@@ -248,12 +473,25 @@ Here’s a carousel with slides only. Note the presence of the `.d-block` and `.
     </div>
   </div>`} />
 
-## Disable touch swiping
-
-Carousels support swiping left/right on touchscreen devices to move between slides. This can be disabled by setting the `touch` option to `false`.
-
-<Example code={`<div id="carouselExampleControlsNoTouching" class="carousel slide" data-bs-touch="false">
-    <div class="carousel-inner">
+### Stop
+
+With `data-bs-ends="stop"` the carousel hard-stops at the first and last slide. The previous control is automatically disabled on the first slide and the next control on the last (via the native `disabled` attribute on `<button>` controls); when a focused control becomes disabled, focus shifts to the opposite control so it isn’t lost.
+
+<Example code={`<div id="carouselEndsStop" class="carousel slide" data-bs-ends="stop">
+    <div class="d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Stop at the ends</h5>
+      <div>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselEndsStop" data-bs-slide="prev" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-prev" aria-hidden="true"></span>
+          <span class="visually-hidden">Previous</span>
+        </button>
+        <button class="btn-icon btn-sm" type="button" data-bs-target="#carouselEndsStop" data-bs-slide="next" style="--bs-carousel-control-icon-width: 1rem;">
+          <span class="carousel-icon-next" aria-hidden="true"></span>
+          <span class="visually-hidden">Next</span>
+        </button>
+      </div>
+    </div>
+    <div class="carousel-inner rounded-5">
       <div class="carousel-item active">
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#555" background="#777" text="First slide" />
       </div>
@@ -264,63 +502,41 @@ Carousels support swiping left/right on touchscreen devices to move between slid
         <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#333" background="#555" text="Third slide" />
       </div>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleControlsNoTouching" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleControlsNoTouching" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
   </div>`} />
 
 ## Dark carousel
 
-Add `data-bs-theme="dark"` to the `.carousel` for darker controls, indicators, and captions. Controls are inverted compared to their default white fill with the `filter` CSS property. Captions and controls have additional Sass variables that customize the `color` and `background-color`.
+Add `data-bs-theme="dark"` to the `.carousel` for darker controls and indicators. This is typically most useful with overlay carousels when you need the reverse contrast against the background of the slides.
 
-<Example code={`<div id="carouselExampleDark" class="carousel slide" data-bs-theme="dark">
-    <div class="carousel-indicators">
-      <button type="button" data-bs-target="#carouselExampleDark" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
-      <button type="button" data-bs-target="#carouselExampleDark" data-bs-slide-to="1" aria-label="Slide 2"></button>
-      <button type="button" data-bs-target="#carouselExampleDark" data-bs-slide-to="2" aria-label="Slide 3"></button>
-    </div>
+<Example code={`<div id="carouselExampleDark" class="carousel carousel-overlay slide" data-bs-theme="dark">
     <div class="carousel-inner">
       <div class="carousel-item active" data-bs-interval="10000">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#aaa" background="#f5f5f5" text="First slide" />
-        <div class="carousel-caption d-none md:d-block">
-          <h5>First slide label</h5>
-          <p>Some representative placeholder content for the first slide.</p>
-        </div>
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#aaa" background="#f5f5f5" text="First slide" />
       </div>
       <div class="carousel-item" data-bs-interval="2000">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#bbb" background="#eee" text="Second slide" />
-        <div class="carousel-caption d-none md:d-block">
-          <h5>Second slide label</h5>
-          <p>Some representative placeholder content for the second slide.</p>
-        </div>
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#bbb" background="#eee" text="Second slide" />
       </div>
       <div class="carousel-item">
-        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100" color="#999" background="#e5e5e5" text="Third slide" />
-        <div class="carousel-caption d-none md:d-block">
-          <h5>Third slide label</h5>
-          <p>Some representative placeholder content for the third slide.</p>
-        </div>
+        <Placeholder width="800" height="400" class="bd-placeholder-img-lg d-block w-100 rounded-5" color="#999" background="#e5e5e5" text="Third slide" />
+      </div>
+    </div>
+    <div class="carousel-overlay-controls">
+      <button class="btn-icon btn-sm btn-subtle theme-secondary" type="button" data-bs-target="#carouselExampleDark" data-bs-slide="prev">
+        <span class="carousel-icon-prev" aria-hidden="true"></span>
+        <span class="visually-hidden">Previous</span>
+      </button>
+      <div class="carousel-indicators">
+        <button type="button" data-bs-target="#carouselExampleDark" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
+        <button type="button" data-bs-target="#carouselExampleDark" data-bs-slide-to="1" aria-label="Slide 2"></button>
+        <button type="button" data-bs-target="#carouselExampleDark" data-bs-slide-to="2" aria-label="Slide 3"></button>
       </div>
+      <button class="btn-icon btn-sm btn-subtle theme-secondary" type="button" data-bs-target="#carouselExampleDark" data-bs-slide="next">
+        <span class="carousel-icon-next" aria-hidden="true"></span>
+        <span class="visually-hidden">Next</span>
+      </button>
     </div>
-    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleDark" data-bs-slide="prev">
-      <span class="carousel-control-prev-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Previous</span>
-    </button>
-    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleDark" data-bs-slide="next">
-      <span class="carousel-control-next-icon" aria-hidden="true"></span>
-      <span class="visually-hidden">Next</span>
-    </button>
   </div>`} />
 
-## Custom transition
-
-The transition duration of `.carousel-item` can be changed with the `$carousel-transition-duration` Sass variable before compiling or custom styles if you’re using the compiled CSS. If multiple transitions are applied, make sure the transform transition is defined first (e.g. `transition: transform 2s ease, opacity .5s ease-out`).
-
 ## CSS
 
 ### Variables
@@ -329,10 +545,6 @@ Tokens for all carousels:
 
 <ScssDocs name="carousel-tokens" file="scss/_carousel.scss" />
 
-Tokens for the [dark carousel](#dark-carousel):
-
-<ScssDocs name="carousel-dark-tokens" file="scss/_carousel.scss" />
-
 ## Usage
 
 ### Via data attributes
@@ -342,10 +554,13 @@ Use data attributes to easily control the position of the carousel. `data-bs-sli
 <BsTable>
 | Attribute | Description |
 | --- | --- |
-| `data-bs-ride` | Starts autoplay after the first manual cycle (`true`) or on load (`carousel`). |
+| `data-bs-autoplay` | Set to `true` to autoplay the carousel on load. |
 | `data-bs-slide` | `prev` or `next` to move the carousel relative to the current slide. |
 | `data-bs-slide-to` | Zero-based index of the slide to show. |
 | `data-bs-interval` | Autoplay interval in milliseconds between slides. |
+| `data-bs-ends` | How the carousel behaves at its ends: `stop`, `wrap`, or `loop`. |
+| `data-bs-play-label` | On a `.carousel-control-play-pause` button, the `aria-label` to apply while autoplay is stopped. |
+| `data-bs-pause-label` | On a `.carousel-control-play-pause` button, the `aria-label` to apply while autoplay is playing. |
 </BsTable>
 
 ### Via JavaScript
@@ -363,26 +578,27 @@ const carousel = new bootstrap.Carousel('#myCarousel')
 <BsTable>
 | Name | Type | Default | Description |
 | --- | --- | --- | --- |
+| `autoplay` | boolean | `false` | If set to `true`, autoplays the carousel on load. Otherwise the carousel does not autoplay. |
+| `ends` | string | `"loop"` | How the carousel behaves at its ends. `"stop"` hard-stops at the first/last slide—and disables the previous control on the first slide and the next control on the last (via the native `disabled` attribute on `<button>` controls, so `<a>` controls aren’t affected). `"wrap"` jumps from the last slide back to the first (and vice versa), and `"loop"` scrolls seamlessly past the ends for single-slide carousels (falling back to `"wrap"` for multi-item/peek/centered/variable-width layouts and under reduced motion). |
 | `interval` | number | `5000` | The amount of time to delay between automatically cycling an item. |
 | `keyboard` | boolean | `true` | Whether the carousel should react to keyboard events. |
-| `pause` | string, boolean | `"hover"` | If set to `"hover"`, pauses the cycling of the carousel on `mouseenter` and resumes the cycling of the carousel on `mouseleave`. If set to `false`, hovering over the carousel won’t pause it. On touch-enabled devices, when set to `"hover"`, cycling will pause on `touchend` (once the user finished interacting with the carousel) for two intervals, before automatically resuming. This is in addition to the mouse behavior. |
-| `ride` | string, boolean | `false` | If set to `true`, autoplays the carousel after the user manually cycles the first item. If set to `"carousel"`, autoplays the carousel on load. |
-| `touch` | boolean | `true` | Whether the carousel should support left/right swipe interactions on touchscreen devices. |
-| `wrap` | boolean | `true` | Whether the carousel should cycle continuously or have hard stops. |
+| `pause` | string, boolean | `"hover"` | If set to `"hover"`, pauses the cycling of the carousel on `mouseenter` and resumes it on `mouseleave`. If set to `false`, hovering over the carousel won’t pause it. |
 </BsTable>
 
+The number of visible slides, the gap between them, and the peek of adjacent slides are controlled with CSS, not these options—see [Items](#items).
+
 ### Methods
 
 <Callout name="danger-async-methods" type="danger" />
 
-You can create a carousel instance with the carousel constructor, and pass on any additional options. For example, to manually initialize an autoplaying carousel (assuming you’re not using the `data-bs-ride="carousel"` attribute in the markup itself) with a specific interval and with touch support disabled, you can use:
+You can create a carousel instance with the carousel constructor, and pass on any additional options. For example, to manually initialize an autoplaying carousel (assuming you’re not using the `data-bs-autoplay="true"` attribute in the markup itself) with a specific interval, you can use:
 
 ```js
 const myCarouselElement = document.querySelector('#myCarousel')
 
 const carousel = new bootstrap.Carousel(myCarouselElement, {
-  interval: 2000,
-  touch: false
+  autoplay: true,
+  interval: 2000
 })
 ```
 
@@ -414,7 +630,7 @@ All carousel events are fired at the carousel itself (i.e. at the `<div class="c
 <BsTable>
 | Event type | Description |
 | --- | --- |
-| `slid.bs.carousel` | Fired when the carousel has completed its slide transition. |
+| `slid.bs.carousel` | Fired when the new slide has scrolled into place and settled (in `.carousel-fade`, once the crossfade is applied). A multi-slide `to()` jump may emit intermediate `slid` events as it scrolls past slides. |
 | `slide.bs.carousel` | Fires immediately when the `slide` instance method is invoked. |
 </BsTable>
 
index 875e1cce5aeed018b742fffa1b78415eec48a8c4..084ec6eeb5e6076a050cddf0aedeb11394efc1b3 100644 (file)
@@ -89,7 +89,7 @@ Bootstrap does not yet ship with a built-in color mode picker, but you can use t
 
 ### Building with Sass
 
-Our new dark mode option is available to use for all users of Bootstrap, but it’s controlled via data attributes instead of media queries and does not automatically toggle your project’s color mode. You can disable our dark mode entirely via Sass by changing `$enable-dark-mode` to `false`.
+Our new dark mode option is available to use for all users of Bootstrap, but it’s controlled via data attributes instead of media queries and does not automatically toggle your project’s color mode.
 
 We use a custom Sass mixin, `color-mode()`, to help you control _how_ color modes are applied. By default, we use a `data` attribute approach, allowing you to create more user-friendly experiences where your visitors can choose to have an automatic dark mode or control their preference (like in our own docs here). This is also an easy and scalable way to add different themes and more custom color modes beyond light and dark.
 
index 450dec7c31e7fe33ba7c1941ca4806e3c3562885..39e007e0297f2d4d8a53c481fdf34f66b53a5979 100644 (file)
@@ -11,7 +11,6 @@ You can find and customize these variables for key global options in Bootstrap
 | Variable                       | Values                             | Description                                                                            |
 | ------------------------------ | ---------------------------------- | -------------------------------------------------------------------------------------- |
 | `$spacer`                      | `1rem` (default), or any value > 0 | Specifies the default spacer value to programmatically generate our [margin]([[docsref:/utilities/margin]]), [padding]([[docsref:/utilities/padding]]), and [gap]([[docsref:/utilities/gap]]) utilities. |
-| `$enable-dark-mode`            | `true` (default) or `false`        | Enables built-in [dark mode support]([[docsref:/customize/color-modes#dark-mode]]) across the project and its components. |
 | `$enable-rounded`              | `true` (default) or `false`        | Enables predefined `border-radius` styles on various components. |
 | `$enable-shadows`              | `true` or `false` (default)        | Enables predefined decorative `box-shadow` styles on various components. Does not affect `box-shadow`s used for focus states. |
 | `$enable-gradients`            | `true` or `false` (default)        | Enables predefined gradients via `background-image` styles on various components. |
index 6bb2725323ddb9bb4786cfe89f99cd373a319635..64156e45fab99c1f12e67467d11cbec3f653853a 100644 (file)
@@ -102,10 +102,11 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb
 - **Renamed Sass files for consistency.** `_placeholders.scss` is now `_placeholder.scss` and `_spinners.scss` is now `_spinner.scss`. Update any individual `@use` imports for these files.
 - **Standardized focus styles with `focus-ring` mixin.** All component-specific `*-focus-box-shadow` Sass variables (e.g., `$btn-focus-box-shadow`, `$input-focus-box-shadow`, `$accordion-button-focus-box-shadow`) have been removed. Focus styles are now handled by a shared `@mixin focus-ring()` using `--focus-ring`, `--focus-ring-width`, `--focus-ring-offset`, and `--focus-ring-color` CSS custom properties. Customize focus styles by overriding these tokens in `_root.scss` instead of individual Sass variables.
 - **Renamed `$grid-breakpoints` to `$breakpoints`.**
+- **Removed `$enable-dark-mode`.** Dark mode support is always compiled; control it at runtime with `data-bs-theme` (or the `color-mode()` mixin) instead of toggling a Sass flag.
 - **Removes all deprecated Sass variables and values:**
   - Removed `$nested-kbd-font-weight`, no replacement.
   - Removed `muted`, `black-50`, and `white-50` from text colors utilities map
-  - Consolidated carousel dark variables, removing `$carousel-dark-indicator-active-bg`, `$carousel-dark-caption-color`, and `$carousel-dark-control-icon-filter` for their reassigned counterparts.
+  - Removed the carousel dark Sass variables (`$carousel-dark-indicator-active-bg`, `$carousel-dark-control-icon-filter`) and the `.carousel-dark` class—they’re not reassigned. Dark carousels now use `data-bs-theme="dark"` (see the carousel changes below). Carousel captions were removed entirely too.
   - Removed `$btn-close-white-filter`. The close button no longer uses a filter—its icon is a CSS mask painted with `currentcolor`, so it adapts to dark backgrounds automatically.
   - Removed `$border-radius-xxl`, use `$border-radius-2xl`.
   - Removed `$text-muted` for secondary color.
@@ -211,6 +212,27 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb
 - **Reworked badge variants.** Badge color variants now use `.badge-subtle` and `.badge-outline` combined with `.theme-*` classes (e.g., `.badge-subtle .theme-primary`), replacing the v5 `.bg-primary` utility pattern on badges.
 - **Updated breadcrumb markup.** Breadcrumbs now use `.breadcrumb-link` as an interactive element with padding, min-height, and hover background, and explicit `.breadcrumb-divider` elements as separators between items. An empty `.breadcrumb-divider` renders a default chevron via a CSS `mask-image` (`--breadcrumb-divider-icon`) tinted with `background-color: currentcolor`; add your own SVG, text, or markup inside it to override. This replaces the v5 `--bs-breadcrumb-divider` content string on the `.breadcrumb-item::before` pseudo-element.
 - **Navbar toggler icon now uses a CSS mask.** `.navbar-toggler-icon` renders via `mask-image` (`--navbar-toggler-icon`) tinted with `background-color: currentcolor` instead of an embedded `background-image` SVG. The markup stays an empty `<span class="navbar-toggler-icon">`, but the icon now inherits the current text color (including dark mode), so the separate light/dark toggler SVGs are no longer needed.
+- **Rebuilt the carousel on CSS scroll snap.** The slide engine no longer uses `float` + `translateX` class juggling or a custom swipe handler—`.carousel-inner` is now a native horizontal scroll-snap container, so sliding, touch dragging, momentum, and keyboard scrolling come from the browser. The markup is unchanged (`.carousel` &rarr; `.carousel-inner` &rarr; `.carousel-item`), and the public JavaScript API (`next`, `prev`, `to`, `cycle`, `pause`) plus the `slide.bs.carousel` / `slid.bs.carousel` events are preserved.
+  - **New capabilities:** show multiple slides at once, reveal a “peek” of adjacent slides, gaps, center mode, and variable-width slides—all via CSS custom properties (`--carousel-items`, `--carousel-items-gap`, `--carousel-items-peek`), the `.carousel-center` / `.carousel-auto` variants, and responsive `.carousel-items-*` utilities.
+  - **Removed transitional classes** `.carousel-item-start`, `.carousel-item-end`, `.carousel-item-next`, and `.carousel-item-prev`, plus the `.carousel.pointer-event` helper—the browser tracks scroll position instead of an `.active` layout class. (The active slide still gets `.active` for styling.)
+  - **`.carousel-fade`** is now a stacked-opacity (grid) crossfade animated with a CSS opacity transition over `--bs-carousel-fade-duration` (it collapses to an instant swap under reduced motion).
+  - **Removed the `touch` option.** Because `.carousel-inner` is a native scroll-snap container, horizontal touch dragging is part of the browser’s native scrolling and is no longer toggled by JavaScript.
+  - Active-slide syncing uses an `IntersectionObserver`; as a result `slid.bs.carousel` fires when the new slide settles into view, and a multi-slide `to()` jump may emit intermediate `slid` events as it scrolls past.
+- **Renamed the carousel `ride` option to `autoplay`, and made it a boolean.** Autoplay is now strictly opt-in: a carousel only autoplays when `autoplay` is `true` (set via `data-bs-autoplay="true"`). The old `ride` option and its string `"carousel"` value have been removed, as has the `ride="true"` behavior that started autoplaying only *after* the first user interaction.
+  - `data-bs-ride="carousel"` &rarr; `data-bs-autoplay="true"`
+  - `data-bs-ride="true"` &rarr; `data-bs-autoplay="true"` (it now autoplays on load like any other autoplaying carousel, instead of waiting for the first interaction)
+  - JavaScript: `new bootstrap.Carousel(el, { ride: 'carousel' })` &rarr; `new bootstrap.Carousel(el, { autoplay: true })`
+  - The auto-initialization-on-load selector changed accordingly from `[data-bs-ride="carousel"]` to `[data-bs-autoplay="true"]`.
+- **Autoplaying carousels now stop when the user interacts with them.** Clicking a control or indicator, navigating with the keyboard, or swiping permanently stops autoplay instead of resuming it, respecting the visitor’s intent ([WCAG 2.2.2](https://www.w3.org/TR/WCAG/#pause-stop-hide)). Previously these interactions kept the carousel cycling.
+- **New `.carousel-control-play-pause` control** provides a discoverable, accessible button to pause and resume an autoplaying carousel—the mechanism WCAG 2.2.2 requires (a hover-only pause does not qualify). It renders pause/play icons via CSS masks (`--carousel-control-pause-icon` / `--carousel-control-play-icon`), swaps its icon and `aria-label` with state, and can also start autoplay on an otherwise static carousel.
+- **Stacked is now the default carousel layout.** `.carousel` is a flex column, so prev/next controls, indicators, and any custom content sit in the flow above or below the slides. The old `.carousel-stacked` class was removed—it’s now the default, so drop it from your markup.
+- **Overlaid controls now require the `.carousel-overlay` modifier.** To overlay the prev/next controls, play/pause button, and indicators on top of the slides (the classic v5 look), add `.carousel-overlay` to the `.carousel` element. Without it, those elements lay out in the flow.
+- **Renamed the control-icon classes** `.carousel-control-prev-icon` &rarr; `.carousel-icon-prev` and `.carousel-control-next-icon` &rarr; `.carousel-icon-next`. The icons are now painted with `background-color: currentcolor`, so they inherit the surrounding text color (white on the overlay controls, the button color inside `.btn-*`). Size them with `--bs-carousel-control-icon-width`.
+- **Removed `.carousel-caption`** and its `--carousel-caption-*` tokens. Compose slide content from your own markup inside `.carousel-item` instead, styling and positioning it with utilities or custom CSS.
+- **Replaced the `wrap` option with `ends`.** v5's `wrap: true | false` is now `ends: "loop" | "wrap" | "stop"` (default `"loop"`). Map `wrap: true` to `ends: "wrap"` (or the new default `"loop"` for the seamless conveyor effect) and `wrap: false` to `ends: "stop"`. Set it via `data-bs-ends` or the `ends` option. Unknown values fall back to `"loop"`.
+- **Removed the `.carousel-control-prev` / `.carousel-control-next` button classes.** The absolute, full-height hover targets are gone. Compose a control from a button (e.g. `.btn-icon`) plus `data-bs-slide="prev"` / `data-bs-slide="next"` and a `.carousel-icon-prev` / `.carousel-icon-next` glyph, placed in the flow or inside `.carousel-overlay-controls`.
+- **Removed the `.carousel-dark` class.** Use `data-bs-theme="dark"` on the `.carousel` (typically alongside `.carousel-overlay`) for reversed contrast.
+- **Removed v5 carousel tokens** that no longer have an equivalent: `--carousel-caption-*`, `--carousel-control-color`, `--carousel-control-opacity` / `--carousel-control-hover-opacity`, `--carousel-control-icon-filter`, and `--carousel-transition`. Indicator, control, and fade styling now derive from the redesigned `--bs-carousel-*` tokens listed under [CSS variables]([[docsref:/components/carousel#variables]]).
 
 ### Reboot
 
@@ -224,7 +246,7 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb
 - **Refactor checks, radios, and switches.**
   - Split apart `_form-check.scss` into separate stylesheets: `_checkbox.scss`, `_radio.scss`, and `_switch.scss`.
   - Also split apart the documentation pages for checks, radios, and switches.
-  - Added new CSS variables on each of these components. _Side note: we could’ve shared variables here, but chose not to for simplicity’s sake._
+  - Added new CSS variables on each of these components. *Side note: we could’ve shared variables here, but chose not to for simplicity’s sake.*
   - Removed several now unused Sass variables.
   - Checkboxes apply the `.check` class directly on the `<input>` (no wrapping element) and draw the checked and indeterminate marks with a CSS `mask-image` on a `::before` pseudo-element tinted with the contrast color—no inline SVG in the DOM. Radios and switches are likewise styled without a wrapping element.
   - Revamped layout for checks, radios, and switches with labels (and descriptions). We now have custom elements for layout that include basic flexbox styling.