Carousel with Scroll Controls
Build a native carousel using scroll-snap, anchor-positioned controls, and marker dots.
Key Implementation Points
Markup Structure
Lay out li slides inside ul.carousel; each slide is a figure pairing image and caption.
<ul class="carousel">
<li>
<figure>
<img src="/images/jeremy-hynes-zLrqHNms8eE-unsplash.jpg" alt="">
<figcaption>
Photo by <a target="_parent" href="https://unsplash.com/@hynesight?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Jeremy Hynes</a> on <a target="_parent" href="https://unsplash.com/photos/brown-owl-on-tree-branch-covered-with-snow-zLrqHNms8eE?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure>
</li>
<li>
<figure>
<img src="/images/richard-jacobs-8oenpCXktqQ-unsplash.jpg" alt="">
<figcaption>
Photo by <a target="_parent" href="https://unsplash.com/@rj2747?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Richard Jacobs</a> on <a target="_parent" href="https://unsplash.com/photos/grayscale-photo-of-elephants-drinking-water-8oenpCXktqQ?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure>
</li>
<li>
<figure>
<img src="/images/rod-long-gUYYvPrnuHY-unsplash.jpg" alt="">
<figcaption>
Photo by <a target="_parent" href="https://unsplash.com/@rodlong?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Rod Long</a> on <a target="_parent" href="https://unsplash.com/photos/black-whale-in-water-during-daytime-gUYYvPrnuHY?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure>
</li>
</ul>
Scroll-Snap and Anchor Setup
Enable horizontal scrolling with overflow-x: scroll and scroll-snap-type; add anchor-name so buttons can position relative to the carousel center. container: inline-size enables use of cqw units.
.carousel {
container: inline-size;
display: flex;
gap: 20px;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
list-style: none;
position: relative;
anchor-name: --carousel;
scroll-marker-group: after;
scrollbar-width: none;
}
Scroll-State Container Query
Set each li as container-type: scroll-state and use @container scroll-state(snapped: x) to toggle figcaption transition only when snapped. Guard with @supports and prefers-reduced-motion.
.carousel li {
flex: 0 0 100%;
scroll-snap-align: center;
container-type: scroll-state;
@supports (container-type: scroll-state) {
@media (prefers-reduced-motion: no-preference) {
figcaption {
transition: transform 0.3s ease-in-out;
transform: translateY(100%);
@container scroll-state(snapped: x) {
transform: translateY(0);
}
}
}
}
}
Scroll Buttons (::scroll-button)
Use ::scroll-button(left/right) with icon content; position using position-anchor and position-area relative to the carousel, then compute offset via --carousel-item-half-width and translate.
.carousel::scroll-button(*) {
font-family: "Material Icons";
position: absolute;
position-anchor: --carousel;
/* make them round and easy to press */
inline-size: 48px;
aspect-ratio: 1;
border-radius: 9999px;
background-color: #fff;
font-size: 20px;
border: 1px solid #999;
transition: all 0.1s ease-in-out;
}
.carousel::scroll-button(*):focus-visible {
outline-offset: 4px;
}
.carousel::scroll-button(*):disabled {
opacity: 0.3;
}
.carousel::scroll-button(*):not(:disabled):is(:hover, :active) {
background-color: rgba(255, 255, 255, 0.8);
}
.carousel::scroll-button(*):not(:disabled):active {
scale: 95%;
transform-origin: center;
}
.carousel::scroll-button(left) {
content: 'chevron_left' / 'Previous'; /* Using Google Icons */
position-area: center;
translate: calc(var(--carousel-item-half-width) * -1 + 36px);
}
.carousel::scroll-button(right) {
content: 'chevron_right' / 'Next'; /* Using Google Icons */
position-area: center;
translate: calc(var(--carousel-item-half-width) - 36px);
}
Marker Group
Render ::scroll-marker-group as a small grid. When overflowing, apply its own scroll-snap with smooth scrolling and contained overscroll.
.carousel::scroll-marker-group {
grid-area: markers; /* place markers in parent grid area */
/* 15px by 15px horizontal grid - size of dots */
display: grid;
place-content: safe center;
grid: 12px / auto-flow 12px;
gap: 20px;
padding: 15px;
scroll-padding: 15px;
/* handle overflow */
overflow: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
scroll-snap-type: x mandatory;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
}
Slide Markers
Draw li::scroll-marker dots and reflect the active slide with :target-current. Add hover/focus-visible affordances.
.carousel li::scroll-marker {
content: ''/ attr(data-name);
width: 10px;
height: 10px;
border-radius: 9999px;
background-color: white;
border: 1px solid black;
/* snap if group is overflowing */
scroll-snap-align: center;
}
.carousel li::scroll-marker:is(:hover, :focus-visible) {
border-color: gray;
}
.carousel li::scroll-marker:target-current {
background: black;
border-color: black;
} <ul class="carousel">
<li>
<figure>
<img src="/images/jeremy-hynes-zLrqHNms8eE-unsplash.jpg" alt="">
<figcaption>
Photo by <a target="_parent" href="https://unsplash.com/@hynesight?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Jeremy Hynes</a> on <a target="_parent" href="https://unsplash.com/photos/brown-owl-on-tree-branch-covered-with-snow-zLrqHNms8eE?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure>
</li>
<li>
<figure>
<img src="/images/richard-jacobs-8oenpCXktqQ-unsplash.jpg" alt="">
<figcaption>
Photo by <a target="_parent" href="https://unsplash.com/@rj2747?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Richard Jacobs</a> on <a target="_parent" href="https://unsplash.com/photos/grayscale-photo-of-elephants-drinking-water-8oenpCXktqQ?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure>
</li>
<li>
<figure>
<img src="/images/rod-long-gUYYvPrnuHY-unsplash.jpg" alt="">
<figcaption>
Photo by <a target="_parent" href="https://unsplash.com/@rodlong?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Rod Long</a> on <a target="_parent" href="https://unsplash.com/photos/black-whale-in-water-during-daytime-gUYYvPrnuHY?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure>
</li>
</ul>
<style>
.carousel {
--carousel-item-max-width: 600px;
--carousel-item-width: min(var(--carousel-item-max-width), 100cqw);
--carousel-item-half-width: calc(var(--carousel-item-width) / 2);
--carousel-inline-padding: calc((100cqw - 600px) / 2);
}
body .carousel {
padding: 0 var(--carousel-inline-padding);
}
.carousel {
container: inline-size;
display: flex;
gap: 20px;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
list-style: none;
position: relative;
anchor-name: --carousel;
scroll-marker-group: after;
scrollbar-width: none;
}
.carousel li {
flex: 0 0 100%;
scroll-snap-align: center;
container-type: scroll-state;
@supports (container-type: scroll-state) {
@media (prefers-reduced-motion: no-preference) {
figcaption {
transition: transform 0.3s ease-in-out;
transform: translateY(100%);
@container scroll-state(snapped: x) {
transform: translateY(0);
}
}
}
}
}
figure {
aspect-ratio: 16 / 9;
display: grid;
place-items: center;
font-size: 3rem;
font-weight: bold;
border-radius: 8px;
overflow: hidden;
position: relative;
}
figure figcaption {
position: absolute;
bottom: 0;
width: 100%;
background-image: linear-gradient(to top, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0));
color: white;
padding: 40px 10px 10px;
font-size: 0.8rem;
text-align: left;
font-weight: lighter;
}
figcaption a {
color: white;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.carousel::scroll-button(*) {
font-family: "Material Icons";
position: absolute;
position-anchor: --carousel;
/* make them round and easy to press */
inline-size: 48px;
aspect-ratio: 1;
border-radius: 9999px;
background-color: #fff;
font-size: 20px;
border: 1px solid #999;
transition: all 0.1s ease-in-out;
}
.carousel::scroll-button(*):focus-visible {
outline-offset: 4px;
}
.carousel::scroll-button(*):disabled {
opacity: 0.3;
}
.carousel::scroll-button(*):not(:disabled):is(:hover, :active) {
background-color: rgba(255, 255, 255, 0.8);
}
.carousel::scroll-button(*):not(:disabled):active {
scale: 95%;
transform-origin: center;
}
.carousel::scroll-button(left) {
content: 'chevron_left' / 'Previous'; /* Using Google Icons */
position-area: center;
translate: calc(var(--carousel-item-half-width) * -1 + 36px);
}
.carousel::scroll-button(right) {
content: 'chevron_right' / 'Next'; /* Using Google Icons */
position-area: center;
translate: calc(var(--carousel-item-half-width) - 36px);
}
.carousel::scroll-marker-group {
grid-area: markers; /* place markers in parent grid area */
/* 15px by 15px horizontal grid - size of dots */
display: grid;
place-content: safe center;
grid: 12px / auto-flow 12px;
gap: 20px;
padding: 15px;
scroll-padding: 15px;
/* handle overflow */
overflow: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
scroll-snap-type: x mandatory;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
}
.carousel li::scroll-marker {
content: ''/ attr(data-name);
width: 10px;
height: 10px;
border-radius: 9999px;
background-color: white;
border: 1px solid black;
/* snap if group is overflowing */
scroll-snap-align: center;
}
.carousel li::scroll-marker:is(:hover, :focus-visible) {
border-color: gray;
}
.carousel li::scroll-marker:target-current {
background: black;
border-color: black;
}
</style>