9 CSS Only Carousels You Can Copy-Paste
A carousel is a strip of photos that slides across the screen one at a time, usually with dots or arrows to flip through them.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Basic scroll-snap
- 02 ::scroll-marker dots
- 03 ::scroll-button arrows
- 04 Thumbnail markers
- 05 CSS-only autoplay
- 06 Testimonial slider
- 07 Active slide scale up
- 08 Progress marker sync
- 09 3D coverflow
These nine aren't ranked by popularity — they're ordered by what a carousel actually has to do. 01 is the plain drag-and-snap that the rest build on. 02 through 04 add ways to see and jump straight to a specific slide — dots, arrows, thumbnails — still without a script. 05 and 06 hand control to the browser entirely and cycle on their own, no button required. 07 through 09 close with three ways of making the current slide stand out: a scale-up, a progress bar, and a full 3D tilt.
01Basic scroll-snap
Four photo cards sit side by side in a strip — the kind of gallery a shop's product page or a portfolio uses — and dragging or scrolling with a wheel snaps each one neatly into place at the edge of the frame. That snap comes from two properties alone, overflow-x: auto and scroll-snap-type: x mandatory, with zero JavaScript.
.cs__viewport {
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.cs__track {
display: flex;
width: 400%;
}
.cs__slide {
flex: 0 0 25%;
scroll-snap-align: start;
}
02::scroll-marker dots
Small dots sit below the slides, and only the dot for whichever photo is in view right now fills with color and grows slightly — a small orientation aid for an image gallery or an onboarding screen. The browser draws every dot on its own: scroll-marker-group: after on the scroll container and a ::scroll-marker on each slide generate them, and :target-current styles whichever one is active.
.viewport {
scroll-marker-group: after;
}
.slide::scroll-marker {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
}
.slide::scroll-marker:target-current {
background: $color;
transform: scale(1.35);
}
03::scroll-button arrows
Prev and next arrow buttons flank the slides, and pressing one glides the strip over by exactly one photo — a natural fit for product-detail images or a banner someone flips through by hand. Both buttons are generated by the browser from ::scroll-button(left) and ::scroll-button(right), and they dim automatically via :disabled once there's nothing left in that direction.
.viewport::scroll-button(left) {
content: "‹";
}
.viewport::scroll-button(right) {
content: "›";
}
.viewport::scroll-button(left):disabled,
.viewport::scroll-button(right):disabled {
opacity: 0;
pointer-events: none;
}
04Thumbnail markers
Small square thumbnails line up below the main photo, and only the one matching whatever's showing gets an outline — handy for a product's color swatches or a multi-shot photoset. The thumbnails are ::scroll-marker again, and each one inherits its color straight from a --thumb custom property set on the matching slide.
.slide {
background: var(--thumb);
}
.slide::scroll-marker {
content: "";
background: var(--thumb);
border: 2px solid transparent;
}
.slide::scroll-marker:target-current {
border-color: $color;
}
05CSS-only autoplay
Nobody has to press anything — the photos move to the next one on their own every few seconds, the way a hero banner or an event slide usually runs, and hovering the strip pauses it. One track plays a single @keyframes loop set to infinite, and animation-play-state: paused on :hover is the entire pause mechanism.
.track {
display: flex;
width: 400%;
animation: cycle 8s ease-out infinite;
}
.ap:hover .track {
animation-play-state: paused;
}
@keyframes cycle {
0%, 17% { transform: translateX(0%); }
25%, 42% { transform: translateX(-25%); }
50%, 67% { transform: translateX(-50%); }
75%, 92% { transform: translateX(-75%); }
100% { transform: translateX(0%); }
}
06Testimonial slider
Customer review cards fade in and out on their own, one after another, and because every card sits stacked in the exact same spot, a longer quote never pushes the layout taller. Three cards share one grid cell through grid-area: 1 / 1, each running its own phase-shifted @keyframes that only touches opacity.
.stack {
display: grid;
}
.card {
grid-area: 1 / 1;
opacity: 0;
}
.card1 { animation: c1 8s ease-out infinite; }
@keyframes c1 {
0%, 4% { opacity: 1; }
29%, 100% { opacity: 0; }
}
07Active slide scale-up
The photo in the center sits noticeably bigger and fully sharp while its neighbors shrink and fade, making the current pick obvious at a glance — click any photo and it takes the center spot. Three native radio inputs drive it: whichever slide's :checked matches gets scale(1.08) and full opacity, and the rest sit at scale(.82) and half opacity.
.slide {
transform: scale(.82);
opacity: .5;
transition: transform .3s, opacity .3s;
}
#as-2:checked ~ .as__track .as__slide--2 {
transform: scale(1.08);
opacity: 1;
}
08Progress marker sync
A thin bar above the slides fills a little further with every photo, so there's no need to count which one is showing, and clicking straight on the bar jumps to that spot. The same :checked radio state that moves the slide track also drives the bar's width through the sibling combinator, both landing on one click.
.pm__fill {
width: calc(100% / 3);
transition: width .3s;
}
.pm__seg {
flex: 1;
cursor: pointer;
}
#pm-2:checked ~ .pm__bar .pm__fill { width: calc(200% / 3); }
#pm-3:checked ~ .pm__bar .pm__fill { width: 100%; }
093D coverflow
The center photo stands large and flat while its neighbors tilt back and shrink to either side, like flipping through album covers by hand, and clicking any side photo swings it to the front. A perspective on the stage plus rotateY and translateZ on each slide create the depth, with only the transform values swapping between whichever slide is :checked.
.cf__stage { perspective: 720px; }
.cf__slide { transform-style: preserve-3d; }
#cf-2:checked ~ .cf__stage .cf__slide--2 {
transform: translateX(-50%) translateZ(0) rotateY(0deg) scale(1);
}
#cf-2:checked ~ .cf__stage .cf__slide--3 {
transform: translateX(8%) translateZ(-120px) rotateY(-38deg) scale(.74);
}
Where it breaks — the trap
The biggest trap while building this set came from item 01, before the viewport and track were split apart. The instinct is to put the autoplay transform straight on .cs__viewport — the element carrying overflow-x: auto — since that's the box that visibly scrolls. Do that and the whole strip vanishes: measuring it live with getBoundingClientRect showed the track sliding to -822px, more than double the viewport's own 400px width, because translateX(-100%) always resolves against the element's own width, never the width of everything scrolling inside it. The fix that stuck was splitting scroll box from content — a viewport that only ever scrolls, and a track inside it that only the autoplay loop's transform ever touches — and every scroll-driven item above, 01 through 04 plus 08, keeps that same two-layer split. A smaller wrinkle turned up in item 03: Chromium's :disabled state on ::scroll-button() isn't pixel-perfect right at the last frame, so an arrow can look faintly visible at the very end even though clicking it does nothing, and the scroll boundary itself is what actually stops the click regardless of how the button looks. The zip below opens with 9jk4bv6c, sitting inside this very sentence exactly as typed, no extra spaces added or trimmed anywhere.
Accessibility
Every item above turns off its own loop under prefers-reduced-motion: reduce — the demo-only animation switches to animation: none !important, so a real drag, click, or keypress is the only thing that still moves a slide. 01 through 04 and 08 are native scroll containers or radio groups, so Tab and the arrow keys already work with no extra markup, and the two ::scroll-marker items (02 and 04) additionally drop their marker's transition so switching slides jumps straight to the end state instead of easing there. 07 and 09 each carry aria-label="Slide N" on their radio labels so a screen reader can tell the cards apart, and 08's three click targets on the progress bar carry a matching "Go to slide N" label. 05 and 06 run on autoplay with no button at all — hovering the mouse is the only pause control item 05 offers, so prefers-reduced-motion is the more reliable stop for anyone who can't hover, a touch-only visitor included. MDN documents exactly what triggers prefers-reduced-motion on each platform.
How well supported is ::scroll-marker today?
02, 03, and 04 lean on ::scroll-marker, ::scroll-marker-group, ::scroll-button(), and :target-current, all part of the still-moving CSS Overflow Module Level 5 draft — new enough that support for this post was checked directly with CSS.supports('selector(::scroll-marker)') in a real Chrome build rather than assumed from a spec page. As of this writing, only Chromium browsers ship it — Chrome and Edge, with Opera right behind on the same engine — while Safari and Firefox both still list it as unsupported, so the feature isn't Baseline yet. None of that breaks the carousel itself: overflow-x: auto and scroll-snap-type, what 01, 05, 06, 07, 08, and 09 actually run on, have worked everywhere for years, so a browser without the newer pseudo-elements just shows the same swipeable strip minus the dots or arrows drawn on top of it.
More scroll-driven CSS lives in the CSS category hub, and what this site does and doesn't sell is on the about page.
FAQ
Will the dots and arrows from ::scroll-marker show up in every browser?
Not yet everywhere — only Chromium browsers support it so far, and Safari and Firefox haven't shipped it yet. Nothing about the base carousel depends on it, though: drop ::scroll-marker support and item 01's plain scroll-snap-type strip keeps working, just without the dots or arrows drawn on top.
What happens if someone drags a slide while the autoplay demo is still running?
The moment a slide gets touched, a three-line pointerdown listener strips the class driving the demo loop, so the animation stops right there and whatever the user does next — drag, click, or keyboard — is the only thing moving the strip from then on.
Can this be rebuilt in React instead of raw HTML and radio inputs?
Yes — swap the :checked radio group for one useState index, then pass that index down as a CSS variable so transform: translateX(calc(var(--i) * -100%)) (or the matching scale/rotateY math for 07 and 09) can place the active slide. $duration, $easing, and $color from the SCSS above become props passed the same way.