@use 'sprucecss/scss/spruce' as *;
.carousel {
display: grid;
gap: spacer('s');
&__controls {
display: flex;
gap: spacer('xs');
justify-content: flex-end;
}
&__track {
@include clear-list;
@include layout-reel(85%, 's');
border-radius: radius('lg');
scrollbar-width: none;
@include motion-safe {
scroll-behavior: smooth;
}
&:focus-visible {
outline: 2px solid color('focus-ring');
outline-offset: 2px;
}
> * + * {
margin-block-start: 0;
}
> li {
margin-block: 0;
padding-block: 0;
}
}
&__slide {
figure {
margin: 0;
position: relative;
}
img {
aspect-ratio: 3 / 2;
border-radius: radius('lg');
inline-size: 100%;
object-fit: cover;
}
figcaption {
background-color: color-alpha('background', 0.85);
border-radius: radius('sm');
color: color('heading');
font-weight: 700;
inset-block-end: spacer('s');
inset-inline-start: spacer('s');
margin: 0;
padding: spacer('xxs') spacer('xs');
position: absolute;
}
}
&__dots {
display: flex;
gap: spacer('xs');
justify-content: center;
}
&__dot {
background-color: color('border');
block-size: 0.625rem;
border-radius: radius('pill');
inline-size: 0.625rem;
@include transition($property: 'background-color, inline-size');
&[aria-current='true'] {
background-color: color('primary');
inline-size: 1.5rem;
}
}
}
<section class="carousel" aria-roledescription="carousel" aria-label="Landscapes">
<div class="carousel__controls">
<button class="btn btn--outline-primary btn--icon btn--sm" type="button" data-carousel-prev aria-label="Previous slide">
<svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m15 18-6-6 6-6"/></svg>
</button>
<button class="btn btn--outline-primary btn--icon btn--sm" type="button" data-carousel-next aria-label="Next slide">
<svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
<ul class="carousel__track" role="list" tabindex="0" aria-label="Slides">
<li class="carousel__slide" id="carousel-slide-1" aria-roledescription="slide" aria-label="1 of 4">
<figure>
<img src="https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=900&q=70" alt="Snowy peaks at sunrise." width="900" height="600" loading="lazy">
<figcaption>Mountains</figcaption>
</figure>
</li>
<li class="carousel__slide" id="carousel-slide-2" aria-roledescription="slide" aria-label="2 of 4">
<figure>
<img src="https://images.unsplash.com/photo-1448375240586-882707db888b?auto=format&fit=crop&w=900&q=70" alt="Sunlight through tall trees." width="900" height="600" loading="lazy">
<figcaption>Forest</figcaption>
</figure>
</li>
<li class="carousel__slide" id="carousel-slide-3" aria-roledescription="slide" aria-label="3 of 4">
<figure>
<img src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=900&q=70" alt="Turquoise water on a quiet beach." width="900" height="600" loading="lazy">
<figcaption>Coast</figcaption>
</figure>
</li>
<li class="carousel__slide" id="carousel-slide-4" aria-roledescription="slide" aria-label="4 of 4">
<figure>
<img src="https://images.unsplash.com/photo-1509316785289-025f5b846b35?auto=format&fit=crop&w=900&q=70" alt="Dunes under a clear sky." width="900" height="600" loading="lazy">
<figcaption>Desert</figcaption>
</figure>
</li>
</ul>
<nav class="carousel__dots" aria-label="Choose a slide">
<a class="carousel__dot" href="#carousel-slide-1" aria-label="Go to slide 1"></a>
<a class="carousel__dot" href="#carousel-slide-2" aria-label="Go to slide 2"></a>
<a class="carousel__dot" href="#carousel-slide-3" aria-label="Go to slide 3"></a>
<a class="carousel__dot" href="#carousel-slide-4" aria-label="Go to slide 4"></a>
</nav>
</section>
(() => {
document.querySelectorAll('.carousel').forEach((carousel) => {
const track = carousel.querySelector('.carousel__track');
const slides = [...carousel.querySelectorAll('.carousel__slide')];
const dots = [...carousel.querySelectorAll('.carousel__dot')];
let current = 0;
const goTo = (index) => {
const slide = slides[Math.max(0, Math.min(index, slides.length - 1))];
track.scrollTo({ left: slide.offsetLeft - track.offsetLeft });
};
carousel.querySelector('[data-carousel-prev]')?.addEventListener('click', () => goTo(current - 1));
carousel.querySelector('[data-carousel-next]')?.addEventListener('click', () => goTo(current + 1));
dots.forEach((dot, index) => {
dot.addEventListener('click', (event) => {
event.preventDefault();
goTo(index);
});
});
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
current = slides.indexOf(entry.target);
dots.forEach((dot, index) => dot.setAttribute('aria-current', String(index === current)));
}
});
}, { root: track, threshold: 0.6 });
slides.forEach((slide) => observer.observe(slide));
});
})();