Scroll snapping alone gets you a working carousel with no slider script, and the browser can generate the buttons and position markers too. Product image galleries, card rows and hero sliders are where people tend to use it.
Those generated controls come from the ::scroll-button() and ::scroll-marker pseudo-elements, which Chrome 135 shipped in April 2025 (Chrome for Developers). As of October 2026, only Chromium-based browsers render them. Firefox and Safari fall back to a plain scroll-snap row (caniuse), which means that row is the base layer for every visitor, whatever else you add.
What is a CSS carousel?
See the Pen
c(ss)arousel – pure CSS carousels 🎪🐎🎪🐎🎪🐎 by Jhey (@jh3y)
on CodePen.
The paging, the buttons and the position markers all come from CSS here, with no slider script involved. The browser handles the scrolling, the focus order and the button state.
People use the term for two different builds. One is the scroll-snap row, made from flexbox slides, overflow-x and scroll-snap-type, with navigation coming from hand-built buttons or a few lines of script. The other is the native carousel. It takes that same row and adds the ::scroll-button() and ::scroll-marker pseudo-elements, so the browser generates and maintains the controls.
The native version comes from the CSS Overflow Module Level 5, a specification of the W3C CSS Working Group. Chrome shipped it in version 135 (Chrome for Developers, April 2025).
On the design side it’s the familiar carousel UI pattern. Items sit in a row with one page in view, and controls move you between pages.
What changes is who keeps the controls in sync. MDN calls carousels built with JavaScript brittle, since scripts must match each marker to its item and keep every button current.
It isn’t a full slider engine, though. Looping sits outside the shipped features, and Chrome’s March 2025 post lists cyclical scrolling as planned work.
Chrome’s own Carousel Gallery runs without JavaScript, which shows how far the native features reach.
How to build a CSS carousel with scroll snap
The base layer is a flex row that scrolls and snaps. Scroll snap is Baseline widely available (MDN), so this part runs in every current browser.
- Write the slides as one
ulwith oneliper slide. - Make the list a row with
display: flexand agap. - Give each item
flex: 0 0 100%for one slide per page (the pattern in MDN’s single page demo). - Add
overflow-x: scrollso the row scrolls instead of the whole page. - Set
scroll-snap-type: x mandatoryon the list. - Set
scroll-snap-align: centeron each item.
.carousel {
display: flex;
gap: 4vw;
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
.carousel li {
flex: 0 0 100%;
scroll-snap-align: center;
}Skip the snap rules and you end up with a plain scrolling strip. MDN warns that buttons and markers won’t land cleanly between pages without snapping.
Flexbox or grid for the slide track
Flexbox is the default track. The flex: 0 0 part stops slides from shrinking, and that’s what forces the overflow. Slides keep the width you set, either fixed or as a percentage basis.
Grid works too. grid-auto-flow: column with grid-auto-columns gives equal columns from a single width rule, which is tidy when every slide is the same size.
Multi-column is the odd one out. The columns property plus the ::column pseudo-element lets one marker stand for a page of several items (MDN, last revised January 2026), though the naming cost of that route shows up in the controls section below.
I’d go with flexbox unless every slide has the same width and one grid rule reads cleaner. Multi-column suits paged groups of items.
Scroll snap values that matter in a carousel
| Property | Carousel setting | Effect |
|---|---|---|
| scroll-snap-type | x mandatory | Lands on a slide after every scroll |
| scroll-snap-align | center or start | Sets where the slide stops |
| scroll-snap-stop | always | Blocks skipping slides on fast swipes |
| scroll-padding | a length value | Offsets where slides snap, measured from the container edge |
Writing the axis alone, as in scroll-snap-type: x, defaults to proximity (MDN). A carousel that must always land on a slide needs the mandatory keyword spelled out. Easy to forget.
How do ::scroll-button() and ::scroll-marker() work?
Previous and next buttons beside a scroll container come from ::scroll-button(). Position markers come from ::scroll-marker, which generates one navigation link per item and collects them in a ::scroll-marker-group. The browser places all of it as siblings of the scroller, with the right roles and tab order, and it keeps their state current (Chrome for Developers, March 2025).
Scroll buttons
Each press moves the scroller 85% of its visible area (Chrome for Developers, March 2025), where a built-in scrollbar arrow only nudges.
With one full-width slide in view, that feels like one slide per press. With several slides in view, it moves almost a full page. Chrome’s fix for exact one-item steps is mandatory snapping plus scroll-snap-stop: always on each slide.
A button only exists once content is set to something other than none (MDN). The direction goes in the argument, either left, right, up or down, or the logical block and inline values. You can have up to 4 buttons per scroll container, and each one disables itself at the scroll boundary, so :disabled styles the end of the track.
Scroll markers and the marker group
See the Pen
Pure CSS Carousel with Thumbnails by Ronny Siikaluoma (@siiron)
on CodePen.
Markers are browser-made links, and a marker can stand for any item, not only a dot. Chrome’s example uses two markers labelled Season 1 and Season 2 for a ten-episode TV carousel.
scroll-marker-group takes before or after, which sets where the group sits in tab order and box order (MDN).
For the active state, :target-current styles the marker for the slide in view. :target-before and :target-after cover the markers on either side.
Keyboard use is straightforward. Tab reaches the group, then arrow keys move between pages, and the container is exposed with tablist and tab semantics (MDN).
Positioning and naming the controls
The browser generates the controls, but you still have to place them and give them an accessible name.
Anchor positioning handles placement. Set anchor-name on the scroller, give each control position: absolute and a matching position-anchor, then offset it with anchor() inside calc() (MDN).
The name comes from alt text in content, written as an icon string, a slash, then the text alternative, as in content: ">" / "Next".
Getting this wrong is easy. Using content: "" on a marker produces a blank accessible name, a WCAG 4.1.2 Name, Role, Value violation (MDN). Markers generated on ::column fragments have no element to draw a name from, so that pattern creates the same violation (MDN). MDN also warns that alt text in content can still fall short of WCAG.
MDN’s workaround pulls the name from a data attribute on each list item with attr(). Automated translation services do not pick that value up.
Which browsers support the CSS carousel pseudo-elements?
Only Chromium-based browsers ship the pseudo-elements as of October 2026. Firefox and Safari don’t, though both still run the scroll-snap base.
The caniuse table for October 2026 lists Chrome and Edge as supported from 135, Opera from 120 and Samsung Internet from 29. Firefox is unsupported through 161 and Safari through 27.2. Global usage covered by ::scroll-marker support sits at 71.14% (StatCounter usage data, September 2026).
What Firefox and Safari users get
The row still swipes and snaps, just without buttons or markers. That makes the scroll-snap base the part to get right.
Chrome’s own demo caption describes a bare scroller as showing no clue except cut-off content. Add a visible scrollbar or a partly visible next slide so people can tell the row scrolls. Treat the controls as a layer on top, and plan your cross-browser compatibility checks around that base.
Keep ::scroll-button() rules in their own blocks. A selector list that contains a pseudo-element the browser does not recognize is dropped whole, so a grouped rule loses its supported selectors too. Wrap the control styles in an @supports selector(::scroll-button(*)) block, then test it in Firefox and Safari.
Safari support: where the sources disagree
SitePoint (May 2026) reports full Safari support from version 19 and flagged-only Firefox support. Safari has no version 19, because its numbering jumped from 18 to 26, so that claim does not hold up. The caniuse table tracks Safari through 27.2 and marks every release unsupported, with only Technology Preview listed as unknown. Chrome’s March 2025 post also lists both browsers as unsupported.
The compatibility table is the primary source here, so this article follows it. Recheck it before shipping, because Safari Technology Preview status is still unknown.
Can a CSS carousel autoplay?
See the Pen
[CSS] Infinite autoplay carousel by Jack Oliver (@studiojvla)
on CodePen.
Yes, with @keyframes, but autoplay fails WCAG 2.2.2 unless visitors get a real pause control. The native scroll buttons and markers add navigation, not timers.
The usual build animates the track with CSS keyframes and toggles scroll-snap-align during the move, so mandatory snapping doesn’t fight the animation (ITNext tutorial).
WCAG 2.2.2 Pause, Stop, Hide (Level A) covers moving, scrolling and auto-updating content that starts on its own and shares the page with other content. Some summaries apply a five-second limit to carousels. W3C’s Understanding document (updated August 2026) says auto-updating content has no five-second exception and lists auto-advancing presentations in that category.
Two setups fail. Autoplay with no control at all is the obvious one (W3C failure F16 covers scrolling content without a pause and restart mechanism). A pause that works only while keyboard focus rests on the carousel fails as well, since W3C says that isn’t a pause mechanism.
The passing setups are simpler. A visible control that stops the motion and resumes it does the job (W3C technique G186). A carousel that starts paused and moves only after a button press also passes, since 2.2.2 applies to content that starts automatically. And a labelled checkbox that sets animation-play-state to paused keeps the pause control inside CSS.
Wrap the animation in a prefers-reduced-motion: no-preference media query. Visitors who ask their system for less motion then get a manual-scroll carousel.
How to make a CSS carousel accessible
Label the carousel region and keep every control reachable by keyboard. Then decide what off-screen slides do to the tab order. The native pseudo-elements supply working controls, but the region markup and the focus decisions stay with you.
Naming the region and the slides
W3C’s ARIA Authoring Practices Guide puts the carousel in a container with role region or group and sets aria-roledescription to carousel. The label comes from aria-labelledby when a visible title exists, and from aria-label otherwise.
Because the role description already says carousel, the label shouldn’t repeat the word. Slides in the same pattern are groups described as slide, named like 3 of 10 when no unique title exists.
That workaround exists because ARIA group elements carry no set size or position. A ul and its li items already expose both, so a list-based scroll-snap carousel keeps the region label and skips the per-slide roles.
Keyboard focus on the scroller
See the Pen
CSS Carousel with keyboard controls by David Lewis (@dp_lewis)
on CodePen.
From Chrome 130, a scroller with no focusable children is keyboard focusable by default (Chrome for Developers).
With text and image slides, the scroller is the only stop. Arrow keys scroll it once it has focus, so give it the region role and a label that says what received focus.
Slides with links or buttons work differently. The children become the stops and the scroller drops out of the tab order. Add tabindex="0" if the scroller itself should stay reachable, which is Chrome’s own recommendation.
Off-screen slides and the tab order
Slides outside the visible page stay in the tab order unless something removes them.
Leave them in and focusing a link on a hidden slide scrolls that slide into view. Make them inert and hidden slides leave the tab order, which means keyboard users depend on the buttons and markers to reach them.
The carousel explainer treats content on later pages as reachable only through the carousel’s own navigation, and Chrome’s Carousel Configurator exposes that as an inert switch.
Test with the keyboard alone first. Then work through a web accessibility checklist for the problems a keyboard pass misses.
How to keep a CSS carousel responsive and fast
See the Pen
Responsive Slideshow / Carousel with only HTML5 & CSS3 by Trung Vo (@trungvose)
on CodePen.
Size slides with percentage-based widths and reserve each image’s space before it loads. web.dev sets a good Cumulative Layout Shift score at 0.1 or less, measured at the 75th percentile of page loads.
One slide or several per view
Flex-basis percentages resolve against the width of the flex container, so slide width alone decides how many slides fit.
For two per view, use flex: 0 0 calc((100% - var(--gap)) / 2), with the variable matching the flex gap. Three per view works the same way with flex: 0 0 calc((100% - 2 * var(--gap)) / 3).
Changing the count at a min-width breakpoint is plain responsive design, with wider slides on phones and narrower ones on desktop.
Reserving image space
web.dev lists images with unknown dimensions among the usual causes of layout shift.
Set width and height attributes on each img, or give the slide an aspect ratio in CSS. With aspect-ratio: 16 / 9 auto, the browser uses the stated ratio until the image loads and then switches to the image’s own ratio (MDN).
.carousel img {
width: 100%;
height: auto;
aspect-ratio: 16 / 9 auto;
}Move things with transform, never with left, width or margins. In a manual carousel, shifts that follow a user input are excluded from CLS, but in an autoplay carousel they count and can repeat for as long as the page stays open (web.dev).
Native scrolling is safe. web.dev says a shift is not counted when an element’s start position and the scroll offset change together in the same frame.
Loading slides without extra cost
Chrome 121 gave horizontally scrolling images, such as carousel slides, the same loading thresholds as vertical scrolling. Slides now load before they scroll into view, at the price of more downloads (web.dev).
Slides belong in the HTML. web.dev names script-initiated image loading as probably the biggest carousel performance mistake, since it delays discovery and hurts Largest Contentful Paint, and a CSS carousel avoids it by default.
Keep the first slide eager, because large above-the-fold carousels often hold the page’s LCP element (web.dev). Images in later slides can take loading="lazy", and srcset is worth adding since carousels often carry some of a site’s largest images.
When does a CSS carousel stop working?
The technical failures are narrow. The bigger problem is usually putting content in a carousel that never belonged there.
Mandatory snapping across spaced-out targets
Mandatory snapping hides content when snap targets sit far apart.
The CSS Scroll Snap specification warns that mandatory snap positions on non-adjacent siblings can make the content between them inaccessible when it is longer than the screen. Oversized slides are not the problem, because the browser lets users scroll freely inside a snap area larger than the scrollport.
Put scroll-snap-align on every slide, or on every page boundary, and keep any unsnapped content between two targets shorter than the scrollport.
Changing item counts
A common claim is that CSS carousels can’t cope with a changing number of items. For the native markers, that’s out of date.
MDN’s responsive demo shows the marker count updating as the number of columns changes with the viewport. Add an li and the browser adds a marker.
Active-slide state without native markers
Browsers without ::scroll-marker need script for a custom indicator. The Intersection Observer API, Baseline widely available since March 2019 (MDN), reports when an element crosses a visibility threshold relative to a root element.
- Create an observer with the scroller as
rootand a threshold such as 0.5. - Observe each slide.
- In the callback, move an active attribute to the slide where
isIntersectingis true, then style your own dots from it.
The callback runs on the main thread, so keep it short.
Content that does not belong in a carousel
See the Pen
Responsive Testimonial Carousel using OwlCarousel with Next and Previous Preview by Md Nahidul Islam (@thenahidul)
on CodePen.
Anything visitors must see is the wrong fit.
web.dev’s carousel guidance (last updated January 2021) notes that most visitors will not view every slide, and that promotional or auto-advancing carousels are easily mistaken for ads. It recommends making each slide’s destination reachable by other navigation paths.
Product image galleries fit well, along with other cases where extra vertical space isn’t an option. Promotions and announcements don’t, and neither does any content nobody could prioritize.
CSS carousel or JavaScript library: which one fits?
See the Pen
Carousel (React) by Andy Pagès (@andyNroses)
on CodePen.
Static slides in a swipeable row are a good match for plain CSS. A library earns its weight once the design needs infinite looping, synced carousels or built-in autoplay controls.
| Feature | Scroll-snap row | Native carousel | JavaScript library |
|---|---|---|---|
| Infinite loop | No | No | Yes (Swiper, Embla, Splide, Slick) |
| Autoplay | Keyframes plus a hand-built pause | Keyframes plus a hand-built pause | Built in, or by plugin |
| Navigation controls | Hand-built | Browser-generated, Chromium only | Library-generated |
| Script added | None | None | Splide: 12 kB gzipped. Slick also needs jQuery |
What each library adds
Swiper is tree-shakeable and free of dependencies, with loop, autoplay and lazy loading for images in inactive slides (Swiper documentation). Embla Carousel is dependency-free as well but sits at a lower level, with loop and drag handling plus a method API (Embla documentation).
Splide weighs 12 kB gzipped and has no dependencies, and it adds lazy loading, mouse drag and an autoplay play-pause toggle (Splide README). Slick needs jQuery, loops infinitely by default and can sync several sliders through asNavFor (Slick README).
Sources disagree on Slick’s health. Its README now describes a Version 2.0 for jQuery 4, while accessibility consultancy Allyant describes a library left unmaintained for several years.
Allyant responded by publishing accessible-slick, an accessible fork of Slick. Check the repository’s recent activity before adopting the original.
Bootstrap’s carousel component is a different kind of choice. It is built with CSS 3D transforms and a bit of JavaScript, supports touch swiping, and its documentation recommends avoiding autoplay or adding a pause control when autoplay is used (Bootstrap 5.3 documentation).
Choosing between them
Stay with CSS when the slides are static and nobody needs an infinite loop. A swipeable row has to be acceptable where the controls don’t render, too.
A library makes sense when the carousel must loop or several carousels have to stay in sync, such as a main image with a thumbnail strip. It also makes sense when autoplay needs a built-in play-pause toggle or progress bar.
A hybrid keeps the scroll-snap row as the base and adds a few lines of JavaScript only for the missing piece, such as loop or active-slide state. If the script fails, visitors still get a swipeable row.
CSS carousel examples
Carousel Team Ride
See the Pen
Carousel team by Marco Barría (@fixcl)
on CodePen.
Youplay Carousel Extravaganza
See the Pen
Youplay Carousel by nK (@_nK)
on CodePen.
Playlist Carousel Beats
See the Pen
Playlist Carousel – css only by Aybüke Ceylan (@aybukeceylan)
on CodePen.
Three Dimensional Adventure – Split 3D Carousel
See the Pen
Split 3D Carousel by Paul Noble (@paulnoble)
on CodePen.
Rotate with the SVG Carousel
See the Pen
Rotating SVG Carousel by Ramsay Lanier (@ramsaylanier)
on CodePen.
Sleek Annotated Linear Carousel
See the Pen
Pure CSS, annotated linear carousel by Paul Noble (@paulnoble)
on CodePen.
Fading Wonders: Carousel Delight
See the Pen
Simple Carousel Pure CSS by Dang Van Thanh (@dangvanthanh)
on CodePen.
3D Magic: CSS Variables Carousel
See the Pen
CSS Variables 3D Carousel (No JS) by Chris Neale (@onion2k)
on CodePen.
Snapshot Memories with Polaroid-Like Carousel
See the Pen
Polaroid-like photo carousel by Temani Afif (@t_afif)
on CodePen.
Workout Goals with Apple Watch Carousel
See the Pen
Apple Watch – Workout Carousel by Derek Palladino (@derekjp)
on CodePen.
Radio Waves with Pure CSS Carousel
See the Pen
Pure CSS carousel by Olivier PASCAL (@pascaloliv)
on CodePen.
Insta Vibes with Jake’s Magic
See the Pen
Pure CSS | Jake’s Instagram by Andy (@kotAndy)
on CodePen.
Framing Memories the Ruediger Way
See the Pen
Pure CSS Featured Image Slider by Ruediger Stursberg (@stursberg)
on CodePen.
Sync-Up with Rian’s Carousel
See the Pen
Simple Synchronised Carousel by Rian Ariona (@ariona)
on CodePen.
Colorful Carousel Escapades
See the Pen
CSS 3D transform Colorful Animated Carousel by Edmundo Santos (@edmundojr)
on CodePen.
Swipe Left for Kate’s Material Card Carousel
See the Pen
Material Cards – Carousel (mobile) by Kate Hummer (@katehummer)
on CodePen.
When CSS Does a Mic Drop
See the Pen
Real Simple Slider by Chris Coyier (@chriscoyier)
on CodePen.
CSS Carousel FAQ
How do you hide the scrollbar on a CSS carousel?
Set scrollbar-width: none on the scroller. Older WebKit browsers also need ::-webkit-scrollbar { display: none; }.
Hide it only where generated buttons or markers render. In Firefox and Safari those controls are absent, and the scrollbar is the only cue that the row scrolls.
Does a CSS carousel support mouse drag?
See the Pen
Hover-Carousel by Yair Even Or (@vsync)
on CodePen.
No. MDN lists swiping, dragging the scrollbar and pressing the scroll buttons as the ways to move through one, so desktop users can’t grab and drag the content itself.
Libraries add that behavior. Splide offers mouse drag and Slick enables desktop dragging by default.
Can a CSS carousel be vertical?
See the Pen
React Vertical Carousel by Alex Boffey (@alexboffey)
on CodePen.
Yes. Chrome states that carousels work for vertical and bidirectional scrollers too.
Set scroll-snap-type: y mandatory on a container with a fixed height and overflow-y. The generated buttons then take the up and down directions.
How do you show a peek of the next slide?
See the Pen
CSS-Tricks Card Carousel by William Goldsworthy (@william-goldsworthy)
on CodePen.
Make each slide narrower than the scroller, such as flex: 0 0 85%, and align slides with scroll-snap-align: start. The remaining width reveals the start of the next slide.
For a peek of the previous slide as well, add scroll-padding on the scroller. The CSS Scroll Snap specification uses that offset in its own paging example.
The last slide cannot reach the start edge, so the browser scrolls as far as it can toward that position.
Which CSS Carousel Build to Ship First
Ship the scroll-snap row first, then add native buttons and markers for Chromium visitors. Every browser gets a working CSS carousel, and the generated controls arrive only where they render.
Start with the scroll-snap row and a visible scroll cue, then put the native controls on top inside a feature query. Script comes last, and only for looping or synced carousels. Each step stays useful when the next one fails to render.
You do end up testing two experiences. Chromium visitors see generated controls, everyone else swipes, and the page carries no added script in exchange.
As of October 2026, the advice changes once Firefox and Safari both ship the pseudo-elements. The hand-built fallback can then shrink, so recheck the caniuse tables before the next release.
- What is Backend in Web Development? - September 13, 2026
- What is Frontend Development? - September 11, 2026
- How to Make a Button in Figma: Design Best Practices - September 10, 2026



