blob: 4870548690d142bef1af16c6f00d67acc352a637 [file] [edit]
@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;
}
}
}