| @use "config" as *; |
| @use "functions" as *; |
| @use "mixins/border-radius" as *; |
| @use "mixins/transition" 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-gap: .75rem, |
| --carousel-indicator-bg: var(--fg-3), |
| --carousel-indicator-width: .75rem, |
| --carousel-indicator-height: .75rem, |
| --carousel-indicator-spacer: .25rem, |
| --carousel-indicator-opacity-duration: .6s, |
| --carousel-indicator-opacity-timing: ease, |
| --carousel-indicator-width-duration: .3s, |
| --carousel-indicator-width-timing: 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-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-fade-timing: ease, |
| ), |
| $carousel-tokens |
| ); |
| // scss-docs-end carousel-tokens |
| // stylelint-enable custom-property-no-missing-var-function |
| |
| @layer components { |
| .carousel { |
| @include tokens($carousel-tokens); |
| |
| position: relative; |
| display: flex; |
| flex-direction: column; |
| gap: var(--carousel-gap); |
| } |
| |
| // The scroll viewport |
| .carousel-inner { |
| display: flex; |
| gap: var(--carousel-items-gap); |
| width: 100%; |
| 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 { |
| // `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; |
| } |
| |
| // |
| // Layout variants |
| // |
| |
| // Center the active slide in the viewport (pairs well with `--carousel-items-peek`) |
| .carousel-center { |
| .carousel-item { |
| scroll-snap-align: center; |
| } |
| } |
| |
| // Let each slide size itself; snap points still land on every item |
| .carousel-auto { |
| .carousel-item { |
| flex-basis: auto; |
| } |
| } |
| |
| // |
| // 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-inner { |
| display: grid; |
| overflow: hidden; |
| scroll-snap-type: none; |
| } |
| |
| .carousel-item { |
| grid-area: 1 / 1; |
| width: 100%; |
| visibility: hidden; |
| opacity: 0; |
| @include transition(opacity var(--carousel-fade-duration) var(--carousel-fade-timing), visibility 0s linear var(--carousel-fade-duration)); |
| } |
| |
| .carousel-item.active { |
| visibility: visible; |
| opacity: 1; |
| @include transition(opacity var(--carousel-fade-duration) var(--carousel-fade-timing)); |
| } |
| } |
| |
| // 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: currentcolor; |
| @include mask-icon($size: 100% 100%, $position: 50%); |
| } |
| |
| .carousel-icon-prev { |
| mask-image: var(--carousel-control-prev-icon); |
| } |
| |
| .carousel-icon-next { |
| mask-image: var(--carousel-control-next-icon); |
| } |
| |
| [dir="rtl"] .carousel-icon-prev, |
| [dir="rtl"] .carousel-icon-next { |
| transform: scaleX(-1); |
| } |
| |
| .carousel-icon-pause { |
| mask-image: var(--carousel-control-pause-icon); |
| } |
| |
| .carousel-icon-play { |
| mask-image: var(--carousel-control-play-icon); |
| } |
| |
| // Optional play/pause control |
| // |
| // 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 { |
| display: flex; |
| gap: var(--carousel-indicator-spacer); |
| justify-content: center; |
| |
| [data-bs-target] { |
| flex: 0 1 auto; |
| width: var(--carousel-indicator-width); |
| height: var(--carousel-indicator-height); |
| padding: 0; |
| cursor: pointer; |
| background-color: transparent; |
| border: 1px solid var(--carousel-indicator-bg); |
| @include border-radius(var(--carousel-indicator-width)); |
| @include transition(opacity var(--carousel-indicator-opacity-duration) var(--carousel-indicator-opacity-timing), width var(--carousel-indicator-width-duration) var(--carousel-indicator-width-timing)); |
| } |
| |
| .active { |
| width: calc(var(--carousel-indicator-width) * 2.5); |
| background-color: var(--carousel-indicator-bg); |
| border-color: var(--carousel-indicator-bg); |
| } |
| } |
| |
| // 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-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; |
| } |
| } |
| } |
| } |
| |
| // 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-overlay { |
| --carousel-indicator-bg: light-dark(var(--white), var(--black)); |
| |
| .carousel-overlay-controls { |
| position: absolute; |
| inset-block-end: 1rem; |
| inset-inline: 1rem; |
| z-index: 1; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| } |
| } |