Smooth snap
The track locks onto each card thanks to scroll-snap-type and scroll-snap-align, without a single line of JavaScript.
A native carousel built with horizontal scrolling, scroll-snap, and progressive CSS controls. Slides snap into place naturally, while supported browsers can add scroll buttons, markers, and active-slide styling without JavaScript.
0 KB JavaScript
~70 CSS lines
scroll native snap
controls progressive
The track locks onto each card thanks to scroll-snap-type and scroll-snap-align, without a single line of JavaScript.
The horizontal scroll relies on overflow-x. The browser handles inertia, mouse wheel, keyboard and touch.
The navigation arrows are generated by ::scroll-button(left) and ::scroll-button(right), driven without script.
The scroll-marker-group property and the ::scroll-marker pseudo-element generate a dot per card. The active card receives :target-current.
The scrollbar uses scrollbar-width and scrollbar-color to integrate with the design without a plugin.
The tabindex="0" attribute makes the track focusable. Keyboard arrows move the scroll predictably.
The snapped card is styled via @container scroll-state(snapped: x). No JavaScript or IntersectionObserver needed.
scroll-initial-target: nearest sets the starting scroll position to any card on first render, without JavaScript.
The entire component is written in modern HTML and CSS. No listener, no framework, no dependency.
HTML + CSS<div class="carousel" tabindex="0" aria-label="Carousel">
<div class="slide" data-label="Slide 1">
<div class="slide-inner">...</div>
</div>
<div class="slide origin" data-label="Slide 2">
<div class="slide-inner">...</div>
</div>
<div class="slide" data-label="Slide 3">
<div class="slide-inner">...</div>
</div>
...
</div>/* ----------------------------------------------------------------------------
* Carousel Containers
* ---------------------------------------------------------------------------- */
/**
* Native horizontal carousel container.
*
* The carousel remains visually neutral and uses native scrolling
* as its core mechanism. Users can scroll with touch, trackpad,
* mouse, keyboard, or browser-generated controls when supported.
*/
.carousel {
--carousel-slide-size: min(380px, 80vw);
--carousel-scrollbar: currentColor;
display: flex;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scrollbar-color: var(--carousel-scrollbar) transparent;
scrollbar-width: auto;
/* add your styles here */
}
/**
* Smooth scrolling enhancement.
*
* Only enabled when the user has not requested reduced motion.
*/
@media (prefers-reduced-motion: no-preference) {
.carousel {
scroll-behavior: smooth;
}
}
/* ----------------------------------------------------------------------------
* Carousel Slide
* ---------------------------------------------------------------------------- */
/**
* Individual carousel snap target.
*
* The slide size is controlled with `--carousel-slide-size`, so it
* can be customized without changing the carousel mechanics.
*/
.slide {
flex: 0 0 var(--carousel-slide-size);
scroll-snap-align: center;
scroll-snap-stop: always;
}
/**
* Visible slide content wrapper.
*
* Use this element for custom spacing, surface, border, shadow,
* media styling, or any other presentation styles.
*/
.slide-inner {
/* add your styles here */
}
/* ----------------------------------------------------------------------------
* Carousel Initial position
* ---------------------------------------------------------------------------- */
/**
* Initial scroll target.
*
* When supported, the `.origin` slide is used as the initial snap
* target when the carousel is first rendered.
*/
.carousel .origin {
scroll-initial-target: nearest;
}
/* ----------------------------------------------------------------------------
* Carousel Buttons
* ---------------------------------------------------------------------------- */
/**
* Native carousel buttons.
*
* `::scroll-button()` generates browser-managed previous and next
* controls. Without support, the carousel remains fully scrollable.
*/
@supports selector(.carousel::scroll-button(right)) {
.carousel {
position: relative;
}
/**
* Shared button mechanics.
*
* Keep visual styles here: size, color, border, background,
* placement offsets, etc.
*/
.carousel::scroll-button(left),
.carousel::scroll-button(right) {
cursor: pointer;
position: absolute;
z-index: 1;
/* add your styles here */
}
/**
* Button hover state.
*/
.carousel::scroll-button(left):hover,
.carousel::scroll-button(right):hover {
/* add your styles here */
}
/**
* Disabled button state.
*
* The browser disables the previous or next button automatically
* when the scroll container reaches either edge.
*/
.carousel::scroll-button(left):disabled,
.carousel::scroll-button(right):disabled {
opacity: 0;
pointer-events: none;
}
/**
* Previous slide button.
*/
.carousel::scroll-button(left) {
content: "‹" / "Previous slide";
left: 0;
}
/**
* Next slide button.
*/
.carousel::scroll-button(right) {
content: "›" / "Next slide";
right: 0;
}
}
/* ----------------------------------------------------------------------------
* Carousel Markers
* ---------------------------------------------------------------------------- */
/**
* Native carousel markers.
*
* `::scroll-marker` creates browser-managed navigation markers for
* each slide. The marker group is placed after the carousel content.
*/
@supports selector(.slide::scroll-marker) {
.carousel {
scroll-marker-group: after;
}
/**
* Marker group container.
*
* Use this pseudo-element to control marker layout, spacing, and
* placement.
*/
.carousel::scroll-marker-group {
display: flex;
/* add your styles here */
}
/**
* Individual slide marker.
*
* Each marker gets its accessible label from the slide's
* `data-label` attribute.
*/
.carousel > .slide::scroll-marker {
content: attr(data-label);
/* add your styles here */
}
/**
* Current slide marker.
*
* Matches the marker associated with the currently snapped slide.
*/
.carousel > .slide::scroll-marker:target-current {
/* add your styles here */
}
}
/* ----------------------------------------------------------------------------
* Carousel Active slide
* ---------------------------------------------------------------------------- */
/**
* Scroll-state container setup.
*
* Enables container queries based on whether an individual slide is
* currently snapped inside the carousel.
*/
@supports (container-type: scroll-state) {
.slide {
container-type: scroll-state;
}
/**
* Current snapped slide.
*
* Use this block to style `.slide-inner` only when its parent slide
* is the active snapped item.
*/
@container scroll-state(snapped: x) {
.slide-inner {
/* add your styles here */
}
}
}The browser generates sibling DOM elements with the appropriate ARIA roles, in the correct tab order, and maintains their state.
Pure CSS carousels improve performance by eliminating JavaScript parsing and event listeners.
Snap scrolling works everywhere, markers add a richer UX where supported.
::scroll-button(), ::scroll-marker, @container scroll-state() and scroll-initial-target are available in Chrome and Edge. Firefox and Safari have implementations in progress.
One of the main limitations is the creation of a cyclical carousel, allowing the user to go directly from the last slide to the first.
To add features like autoplay or start/stop controls, JavaScript is probably still necessary.
Because inactive slides are not hidden via display: none, screen readers can announce all slides, even those not visible.