A lightweight CSS scroll snap carousel. A zero-dependency custom element built on scroll-snap and scroll-padding, configured entirely through HTML attributes.
<!-- Load the module once, it registers <snap-carousel> -->
<script type="module" src="https://unpkg.com/snap-carousel.js@2"></script>
<!-- Controls and nav are downloaded only because this carousel uses them -->
<snap-carousel displayed="1" controls nav>
<div slot="scroller">
<div>Slide 1</div>
<div>Slide 2</div>
<div>Slide 3</div>
</div>
</snap-carousel>
Buttons and dots come without visual styles. See Styling to make them match your design.
<snap-carousel
displayed="1"
per-page="1"
nav
controls
gap="16"
padding="16"
behavior="smooth"
loop
pager
autoplay="3000"
use-pause
responsive='[{"breakpoint": "1025", "settings": {"displayed": "2"}}]'
>
</snap-carousel>
<snap-carousel
displayed="3"
per-page="2"
gap="1rem"
padding="2rem"
controls
nav
loop
>
...
</snap-carousel>
$299.99
$159.99
$399.99
$199.99
$199.99
$199.99
<snap-carousel
displayed="1"
gap="2rem"
controls
nav
autoplay="5000"
use-pause
>
...
</snap-carousel>
<snap-carousel
displayed="1"
nav
>
...
</snap-carousel>
<snap-carousel
displayed="2"
per-page="1"
gap="2rem"
controls
nav
class="property-listings"
>
...
</snap-carousel>
$850,000
📍 Downtown, City
$2,500,000
📍 Coastal Area
$675,000
📍 Suburban Area
<snap-carousel
displayed="3"
per-page="1"
gap="2rem"
controls
nav
class="comparison-table"
>
...
</snap-carousel>
$9.99/month
$19.99/month
$49.99/month
Contact Us
<snap-carousel
displayed="3"
per-page="3"
gap="1rem"
padding="0 2rem"
controls
nav
class="film-carousel"
>
...
</snap-carousel>
A thrilling space exploration journey to the edges of our solar system reveals unexpected discoveries.
Modern myths come to life in this psychological thriller that blends reality with urban folklore.
A masterful team of thieves attempts the most daring museum heist in history.
A mysterious artifact allows a historian to communicate with people from different time periods.
An unlikely friendship forms between a young girl and a wild horse in the American wilderness.
A thrilling space exploration journey to the edges of our solar system reveals unexpected discoveries.
Modern myths come to life in this psychological thriller that blends reality with urban folklore.
A masterful team of thieves attempts the most daring museum heist in history.
A mysterious artifact allows a historian to communicate with people from different time periods.
An unlikely friendship forms between a young girl and a wild horse in the American wilderness.
<snap-carousel
displayed="1"
controls
nav
pager
pager-separator=" OF "
scrollbar
vertical
stop
class="custom-nav-carousel"
style="height: 400px;"
>
<style>
.custom-pagination {
gap: .7rem;
display: flex;
position: absolute;
bottom: 1rem;
width: 100%;
justify-content: center;
}
.custom-pagination button {
font-size: 0;
border: none;
padding: 20px 0;
background: transparent;
cursor: pointer;
}
.custom-pagination button::after {
content: '';
display: block;
width: 3rem;
height: 0.3rem;
padding: 0;
border-radius: 0;
background: var(--border);
transition: all 0.3s ease;
font-size: 0px;
transform: translateY(2px);
}
.custom-pagination [aria-current="true"]::after {
background: var(--primary);
}
.custom-pagination button:hover::after {
background: var(--primary-dark);
transform: none;
}
.custom-nav-carousel::part(next-button) {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
}
.custom-nav-button {
border-radius: 30px;
pointer-events: auto;
background: var(--secondary);
box-shadow: var(--shadow);
color: #333;
padding: 8px;
position: absolute;
top: 50%;
transform: translateY(-50%);
border: none;
left: 1rem;
}
.custom-nav-button[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.custom-nav-button:hover {
scale: 1.1;
}
.custom-nav-button[modifier="2"] {
transform: translateY(50px);
}
.custom-nav-carousel::part(pager) {
position: absolute;
top: 1rem;
right: 1rem;
background: var(--bg);
padding: 0.5rem 1rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
margin: 0;
}
.custom-nav-carousel::part(current) {
font-size: 2rem;
color: var(--text);
}
.custom-nav-carousel::part(buttons) {
margin: 0;
}
/* Only show the icon of the next button, it keeps "Next" as accessible name */
.custom-nav-carousel::part(next-label) {
display: none;
}
</style>
<ul slot="scroller">
<li style="position: sticky;left: 0;top: 0;">
<div style="background: var(--primary); color: #FFF; padding: 1rem; border-radius: var(--radius-md);position: absolute;width: 200px;top:2rem;left:2rem;">
This item is not counted as a slide
</div>
</li>
<li>
<div class="nav-demo-slide">
<h3>Custom Navigation Demo</h3>
<p>Showcasing different navigation styles</p>
</div>
</li>
</ul>
<div slot="pagination" class="custom-pagination">
<!-- buttons will be added here automatically -->
</div>
<!-- Change the default prev button by a custom one -->
<button slot="prev-buttons" class="custom-nav-button" direction="prev">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M220-240v-480h80v480h-80Zm520 0L380-480l360-240v480Zm-80-240Zm0 90v-180l-136 90 136 90Z"/></svg>
</button>
<!-- Add a completely custom prev button which goes back 2 slides -->
<button slot="prev-buttons" class="custom-nav-button" direction="prev" modifier="2">Prev (x2)</button>
<!-- Change the next button icon -->
<span slot="next-icon">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M660-240v-480h80v480h-80Zm-440 0v-480l360 240-360 240Zm80-240Zm0 90 136-90-136-90v180Z"/></svg>
</span>
</snap-carousel>
<snap-carousel
controls
nav
prev-label="Préc."
next-label="Suiv."
responsive='[{"breakpoint": 768, "settings": {"prevLabel": "Précédent", "nextLabel": "Suivant"}}]'
>
...
</snap-carousel>
The labels are the buttons' visible text and their accessible name, in both the native and the JS version.
Inside responsive settings, options use their camelCase names.
In Chrome and Edge, the default prev/next buttons and nav dots are drawn by the browser with the CSS
carousel pseudo-elements ::scroll-button() and ::scroll-marker, so no JavaScript
is downloaded for them. Other browsers download the JS version, made of regular buttons in the carousel's
shadow DOM.
Both versions have the same content, placement and behaviour, but they are different elements: the
JS version is styled with ::part(), the native version with the pseudo-elements. To
get the same look everywhere, write your styles for both.
Your browser is using: …
Supporting browsers still use the JS version when a carousel needs something the native one can't do:
prev-buttons, next-buttons, before-prev,
after-next, prev-icon, next-icon or pagination slots
loop, since native buttons stop at the endsper-page lower than the number of whole slides displayed, since native buttons move by a
whole view::part()States work as usual on parts: ::part(prev-button):disabled,
::part(nav-button):hover, ::part(nav-button):focus-visible.
::scroll-button() and ::scroll-markersnpc-s attribute
Only the first slide of each page gets a dot, so > *::scroll-marker is enough to target
them all.
/* 1. JS version */
.styled-carousel::part(prev-button),
.styled-carousel::part(next-button) {
padding: .5rem 1.25rem;
border: 0;
border-radius: 99rem;
background: #1f2937;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.styled-carousel::part(prev-button):disabled,
.styled-carousel::part(next-button):disabled {
opacity: .3;
cursor: default;
}
.styled-carousel::part(nav) {
display: flex;
justify-content: center;
gap: .375rem;
margin-top: 1rem;
}
.styled-carousel::part(nav-button) {
width: 1.5rem;
height: .5rem;
padding: 0;
border: 0;
border-radius: 99rem;
background: #e2e8f0;
font-size: 0;
cursor: pointer;
}
.styled-carousel::part(active) {
width: 2.5rem;
background: #6366f1;
}
/* 2. Native version */
.styled-carousel [snpc-s]::scroll-button(*) {
padding: .5rem 1.25rem;
border: 0;
border-radius: 99rem;
background: #1f2937;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.styled-carousel [snpc-s]::scroll-button(*):disabled {
opacity: .3;
cursor: default;
}
.styled-carousel [snpc-s]::scroll-marker-group {
display: flex;
justify-content: center;
gap: .375rem;
margin-top: 1rem;
height: .5rem; /* the group doesn't size itself */
}
.styled-carousel [snpc-s] > *::scroll-marker {
box-sizing: border-box; /* markers are links */
width: 1.5rem;
height: .5rem;
border-radius: 99rem;
background: #e2e8f0;
font-size: 0;
text-decoration: none;
cursor: pointer;
}
.styled-carousel [snpc-s] > *::scroll-marker:target-current {
width: 2.5rem;
background: #6366f1;
}
Keep the two versions in separate rules. A browser drops a whole rule when it doesn't know
one of its selectors, so .a::part(prev-button), .a [snpc-s]::scroll-button(*) { … } would be
ignored by Safari and Firefox, the very browsers that use the ::part() version.
With a preprocessor, a mixin per look avoids writing each declaration twice:
@mixin carousel-button {
padding: .5rem 1.25rem;
border-radius: 99rem;
/* ... */
}
.styled-carousel {
// Separate rules: one unknown selector would drop the whole rule
&::part(prev-button),
&::part(next-button) {
@include carousel-button;
}
[snpc-s]::scroll-button(*) {
@include carousel-button;
}
}
For complete control over the markup, slot your own buttons and pagination, as in the customization example. The carousel then uses the JS version in every browser and only your styles apply.
<!-- Registers <snap-carousel>, controls and nav are fetched when used -->
<script type="module" src="https://unpkg.com/snap-carousel.js@2"></script>
<snap-carousel displayed="3" gap="20" controls nav>
<div slot="scroller">
<div>Slide 1</div>
<div>Slide 2</div>
<div>Slide 3</div>
</div>
</snap-carousel>
// Registers <snap-carousel>, features are loaded on demand
import 'snap-carousel.js';
// The class is exported too, e.g. for instanceof checks
import { SnapCarousel } from 'snap-carousel.js';
// The base entry has no side effects and registers nothing
import { createCarousel } from 'snap-carousel.js/base';
import { NavFeature } from 'snap-carousel.js/features/nav';
import { PagerFeature } from 'snap-carousel.js/features/pager';
// Create custom carousel with only navigation and pager
const CustomCarousel = createCarousel(NavFeature, PagerFeature);
customElements.define('custom-carousel', CustomCarousel);
// Use in HTML
<custom-carousel displayed="3" nav pager>
<div slot="scroller">...</div>
</custom-carousel>
// Import specific features
import { createCarousel } from 'snap-carousel.js/base';
import { ControlsFeature } from 'snap-carousel.js/features/controls';
import { NavFeature } from 'snap-carousel.js/features/nav';
import { PagerFeature } from 'snap-carousel.js/features/pager';
// Create carousel with only the features you need
const MinimalCarousel = createCarousel(ControlsFeature);
const FullCarousel = createCarousel(ControlsFeature, NavFeature, PagerFeature);
// Register custom elements
customElements.define('minimal-carousel', MinimalCarousel);
customElements.define('full-carousel', FullCarousel);
// main.js: features imported this way are bundled with your code
import { createCarousel } from 'snap-carousel.js/base';
import { ControlsFeature } from 'snap-carousel.js/features/controls';
const MyCarousel = createCarousel(ControlsFeature);
customElements.define('my-carousel', MyCarousel);
// Or keep on-demand loading: bundlers split the features into chunks
import 'snap-carousel.js';
// Main entry, registers <snap-carousel>
import { SnapCarousel, BaseCarousel, createCarousel } from 'snap-carousel.js';
// Base entry, no side effects
import { BaseCarousel, createCarousel } from 'snap-carousel.js/base';
// Features: a mixin for createCarousel() and a plugin for carousel.use()
import { ControlsFeature, controls } from 'snap-carousel.js/features/controls';
import { NavFeature, nav } from 'snap-carousel.js/features/nav';
import { PagerFeature, pager } from 'snap-carousel.js/features/pager';