A row of slides that scrolls and snaps, built from CSS alone.
On this page
A carousel is a row of slides that scrolls and snaps. It is built from CSS alone: the track is a scroll container with scroll snapping, so touch, a trackpad, the keys and assistive technology all work as scrolling does. Where the browser supports them, the stylesheet adds previous and next buttons and a dot for each slide, which the browser generates as real, focusable controls.
Carousel
<divclass="carousel"role="region"aria-label="Featured projects"tabindex="0"><articleclass="carousel-slide card card-flat"><headerclass="card-header"><h3class="card-title">Atlas</h3><pclass="card-description">A map of every deployment, live.</p></header><divclass="card-body">1.28M requests in the last 24 hours.</div></article><articleclass="carousel-slide card card-flat"><headerclass="card-header"><h3class="card-title">Borealis</h3><pclass="card-description">Edge functions close to the reader.</p></header><divclass="card-body">Served from 14 regions.</div></article><articleclass="carousel-slide card card-flat"><headerclass="card-header"><h3class="card-title">Cirrus</h3><pclass="card-description">Storage that follows the project.</p></header><divclass="card-body">7.4 of 10 GB in use.</div></article></div>
Nothing in the markup stands for the buttons or the dots. They are pseudo-elements:
::scroll-button(inline-start) and ::scroll-button(inline-end) scroll the track by a page. The browser disables one at each end, and the stylesheet lets it step back.
::scroll-marker on each slide is a dot; ::scroll-marker-group gathers them under the track. The dot of the slide in view matches :target-current, lengthens and takes ink.
They are placed with anchor positioning against their own carousel, whose anchor name is scoped, so several carousels on a page do not share controls. In a browser without these pseudo-elements, the carousel is a swipeable, scrollable row and loses nothing else.
carousel-2 and carousel-3 show two or three slides at once. A slide's width is a share of the track less the gaps, so the last one in view ends on the track's edge. Set --carousel-slide to any width for a slide that peeks.
/* * Carousel: a row of slides that scrolls and snaps, built from CSS alone. * The track is a scroll container with scroll snapping, so touch, trackpad, keys and assistive technology * all work as scrolling does. Where the browser supports them, ::scroll-button() adds previous and next * buttons and ::scroll-marker adds a dot per slide that follows the scroll position; both are real, * focusable controls the browser generates. Elsewhere the carousel is a swipeable, scrollable row. */@layer components {.carousel{--carousel-gap:1rem;--carousel-slide:100%;--carousel-control:2rem;/* A neutral mid-grey chevron reads on moonstone and obsidian alike. */--carousel-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23777' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6.5 4.5 3.5 3.5-3.5 3.5'/%3E%3C/svg%3E");position:relative;display:flex;gap:var(--carousel-gap);min-width:0;margin:0;padding:0;overflow:autohidden;overscroll-behavior-x:contain;list-style:none;scroll-snap-type:xmandatory;scroll-behavior:smooth;scrollbar-width:none;&:focus-visible{@apply--focus-ring;}/* The previous and next buttons are touch targets on a touch screen. */@media(pointer:coarse){--carousel-control:2.75rem;}@media(prefers-reduced-motion:reduce){scroll-behavior:auto;}@supportsselector(::scroll-marker){/* Room under the track for the markers, which sit on the carousel's own edge. */margin-bottom:1.75rem;/* The controls anchor to their own carousel: the name is scoped, so several on a page do not share it. */anchor-name: --carousel;anchor-scope: --carousel;scroll-marker-group: after;/* The dots: a row centered under the track. */&::scroll-marker-group{position:fixed;position-anchor: --carousel;top:calc(anchor(bottom)+.75rem);left:anchor(left);right:anchor(right);display:flex;justify-content:center;gap:.375rem;}/* Previous and next: small raised squares on the track's edges, at its middle. */&::scroll-button(inline-start),&::scroll-button(inline-end){position:fixed;position-anchor: --carousel;top:calc(anchor(center)-var(--carousel-control)/2);display:grid;place-items:center;width:var(--carousel-control);height:var(--carousel-control);padding:0;border:0;border-radius:999px;background:var(--carousel-chevron)center/.875remno-repeat,var(--color-surface-floating);box-shadow:var(--shadow-raised);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;transition: box-shadow var(--duration-quick)var(--easing-glide), opacity var(--duration-quick)var(--easing-glide);}&::scroll-button(inline-start){content:''/'Previous slide';left:calc(anchor(left)+.5rem);scale:-11;}&::scroll-button(inline-end){content:''/'Next slide';right:calc(anchor(right)+.5rem);}/* Where slides run from the right, previous is on the right and both chevrons turn round. */&:dir(rtl)::scroll-button(inline-start){right:calc(anchor(right)+.5rem);left:auto;scale:none;}&:dir(rtl)::scroll-button(inline-end){right:auto;left:calc(anchor(left)+.5rem);scale:-11;}@media(hover:hover){&::scroll-button(inline-start):hover,&::scroll-button(inline-end):hover{box-shadow:var(--shadow-raised-hover);}}&::scroll-button(inline-start):focus-visible,&::scroll-button(inline-end):focus-visible{outline:2pxsolidvar(--color-focus);outline-offset:2px;}/* At either end there is nowhere to go: the button steps back. */&::scroll-button(inline-start):disabled,&::scroll-button(inline-end):disabled{opacity:0;cursor:default;}}@media(forced-colors:active){&::scroll-button(inline-start),&::scroll-button(inline-end){border:1pxsolid ButtonText;}}}/* A slide: it snaps to the start of the track and takes --carousel-slide of its width. */.carousel-slide{flex:00var(--carousel-slide);min-width:0;scroll-snap-align:start;@supportsselector(::scroll-marker){&::scroll-marker{content:'';width:.375rem;height:.375rem;border-radius:999px;background:var(--color-line-mark);transition: width var(--duration-base)var(--easing-glide), background-color var(--duration-quick)var(--easing-glide);}/* The slide in view: the dot lengthens and takes ink. */&::scroll-marker:target-current{width:1rem;background:var(--color-text-strong);}&::scroll-marker:focus-visible{outline:2pxsolidvar(--color-focus);outline-offset:2px;}@media(forced-colors:active){&::scroll-marker{background: GrayText;}&::scroll-marker:target-current{background: Highlight;}}}}/* Several slides in view: each takes a share of the track, less the gaps between them. */.carousel-2{--carousel-slide:calc((100%-var(--carousel-gap))/2);}.carousel-3{--carousel-slide:calc((100%-var(--carousel-gap)*2)/3);}}