Snap Carousel

A lightweight CSS scroll snap carousel. A zero-dependency custom element built on scroll-snap and scroll-padding, configured entirely through HTML attributes.

Snap Carousel on GitHub · npm

Getting Started

Quick setup guide to get you started with Snap Carousel
<!-- 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.

Basic Usage

Core features with smooth scrolling and autoplay
<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>

Examples

Real-world examples and use cases

Product Showcase

Responsive product grid with loop
<snap-carousel
  displayed="3"
  per-page="2"
  gap="1rem"
  padding="2rem"
  controls
  nav
  loop
>
...
</snap-carousel>
  • Premium Headphones

    Premium Headphones

    $299.99

  • Wireless Earbuds

    Wireless Earbuds

    $159.99

  • Smart Watch

    Smart Watch

    $399.99

  • Fitness Tracker

    Fitness Tracker

    $199.99

  • Fitness Tracker

    Fitness Tracker

    $199.99

  • Fitness Tracker

    Fitness Tracker

    $199.99

Testimonials

Auto-playing testimonials with pause on hover
<snap-carousel
  displayed="1"
  gap="2rem"
  controls
  nav
  autoplay="5000"
  use-pause
>
...
</snap-carousel>
  • "Amazing product! Exactly what I needed."
    John Doe
    John Doe CEO, TechCorp
  • "The best solution we've found in the market."
    Jane Smith
    Jane Smith Product Manager, DesignCo
  • "Outstanding support and regular updates."
    Mike Johnson
    Mike Johnson Developer, DevTeam

Image Gallery

Full-width gallery with captions
<snap-carousel
  displayed="1"
  nav
>
...
</snap-carousel>

Real Estate Listings

Property showcase with detailed information cards
<snap-carousel
  displayed="2"
  per-page="1"
  gap="2rem"
  controls
  nav
  class="property-listings"
>
...
</snap-carousel>
  • Modern City Apartment

    $850,000

    3 beds 2 baths 1,500 sq ft

    📍 Downtown, City

  • Luxury Beachfront Villa

    $2,500,000

    5 beds 4 baths 3,200 sq ft

    📍 Coastal Area

  • Spacious Family Home

    $675,000

    4 beds 3 baths 2,400 sq ft

    📍 Suburban Area

Product Comparison

Multi-row feature comparison with sticky headers
<snap-carousel
  displayed="3"
  per-page="1"
  gap="2rem"
  controls
  nav
  class="comparison-table"
>
...
</snap-carousel>
  • Basic Plan

    Basic Plan

    $9.99/month

    Cloud Storage 50GB
    Users 1
    Support Email
    Custom Domain ✗
  • Enterprise Plan

    Enterprise Plan

    $49.99/month

    Cloud Storage Unlimited
    Users Unlimited
    Support Priority
    Custom Domain ✓
  • Custom Plan

    Custom Plan

    Contact Us

    Cloud Storage Custom
    Users Custom
    Support Dedicated
    Custom Domain ✓

Film Showcase

Netflix-style movie carousel with hover details
<snap-carousel
    displayed="3"
    per-page="3"
    gap="1rem"
    padding="0 2rem"
    controls
    nav
    class="film-carousel"
>
...
</snap-carousel>
  • The Last Frontier
    12+ ★ 8.5

    The Last Frontier

    2024 2h 15m 4K

    A thrilling space exploration journey to the edges of our solar system reveals unexpected discoveries.

    Sci-Fi Adventure
  • Urban Legends
    15+ ★ 7.9

    Urban Legends

    2024 1h 55m HD

    Modern myths come to life in this psychological thriller that blends reality with urban folklore.

    Thriller Mystery
  • The Grand Heist
    16+ ★ 8.2

    The Grand Heist

    2024 2h 30m 4K

    A masterful team of thieves attempts the most daring museum heist in history.

    Action Crime
  • Echoes of Time
    12+ ★ 8.7

    Echoes of Time

    2024 2h 05m 4K

    A mysterious artifact allows a historian to communicate with people from different time periods.

    Drama Fantasy
  • Wild Hearts
    PG ★ 7.8

    Wild Hearts

    2024 1h 45m HD

    An unlikely friendship forms between a young girl and a wild horse in the American wilderness.

    Family Adventure
  • The Last Frontier
    12+ ★ 8.5

    The Last Frontier

    2024 2h 15m 4K

    A thrilling space exploration journey to the edges of our solar system reveals unexpected discoveries.

    Sci-Fi Adventure
  • Urban Legends
    15+ ★ 7.9

    Urban Legends

    2024 1h 55m HD

    Modern myths come to life in this psychological thriller that blends reality with urban folklore.

    Thriller Mystery
  • The Grand Heist
    16+ ★ 8.2

    The Grand Heist

    2024 2h 30m 4K

    A masterful team of thieves attempts the most daring museum heist in history.

    Action Crime
  • Echoes of Time
    12+ ★ 8.7

    Echoes of Time

    2024 2h 05m 4K

    A mysterious artifact allows a historian to communicate with people from different time periods.

    Drama Fantasy
  • Wild Hearts
    PG ★ 7.8

    Wild Hearts

    2024 1h 45m HD

    An unlikely friendship forms between a young girl and a wild horse in the American wilderness.

    Family Adventure

Custom Navigation & addictionnal options

Showcase of customized navigation styles and additional options
<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>
  • This item is not counted as a slide

Localized Labels

Button text set from attributes, and changed at a breakpoint
<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.

Styling

Buttons and dots ship without visual styles, here is how to style them

Two versions of the same buttons

The browser draws them when it can, JavaScript does otherwise

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:

  • custom elements in the prev-buttons, next-buttons, before-prev, after-next, prev-icon, next-icon or pagination slots
  • loop, since native buttons stop at the ends
  • per-page lower than the number of whole slides displayed, since native buttons move by a whole view

1. JS version: ::part()

Used by Safari, Firefox, and Chrome/Edge carousels that need the JS version

::part(button)

Every default button: prev, next and dots.

::part(prev-button)
::part(next-button)

The prev and next buttons. Also available together as ::part(control-button).

::part(prev-label)
::part(next-label)

The text inside the prev and next buttons. Hide it to show only a slotted icon: the button keeps its label as accessible name.

::part(buttons)

The row holding prev and next, a flex row with one button at each end.

::part(nav)

The container of the dots.

::part(nav-button)

Each dot, a button showing its page number.

::part(active)

The dot of the current page.

::part(pager)

The pager, with ::part(current), ::part(page-sep) and ::part(total). The pager is always JS.

States work as usual on parts: ::part(prev-button):disabled, ::part(nav-button):hover, ::part(nav-button):focus-visible.

2. Native version: ::scroll-button() and ::scroll-marker

Used by Chrome and Edge 135+. The pseudo-elements belong to the scroller, marked with the snpc-s attribute

[snpc-s]::scroll-button(*)

Both prev and next. Target one with (inline-start) and (inline-end), or (block-start) and (block-end) on vertical carousels.

[snpc-s]::scroll-button(*):disabled

A button at the start or end of the carousel.

[snpc-s]::scroll-marker-group

The container of the dots. Set its height: the browser gives it size containment, so it doesn't grow with its dots. It defaults to 1.25rem.

[snpc-s] > *::scroll-marker

Each dot. Dots are links: add box-sizing: border-box and text-decoration: none to match a button.

[snpc-s] > *::scroll-marker:target-current

The dot of the current page.

Only the first slide of each page gets a dot, so > *::scroll-marker is enough to target them all.

Styling both

The same look written for each version
/* 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.

Module Structure

Different ways to import and use the carousel features

1. Basic Usage (features loaded on demand)

<!-- 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>

2. ES Modules Usage

// 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';

3. Custom Build with Selected Features

// 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>

4. Individual Features

// 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);

5. With Module Bundlers

// 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';

6. Available Imports

// 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';
  • Modular Design

    Import only what you need

  • Tree Shaking

    Optimize bundle size

  • Custom Features

    Mix and match carousel features

Options Reference

Complete list of available carousel options and their descriptions

displayed

Type: number
Default: 1

Number of items visible in the viewport at once.

per-page

Type: number
Default: 1

Number of items to scroll per navigation action.

gap

Type: string
Default: "0"

Space between carousel items (CSS units, e.g., "1rem", "16px").

padding

Type: string
Default: "0"

Inline Padding around the carousel viewport (CSS units, you can specify both left and right padding e.g., "0px 4rem").

controls

Type: boolean
Default: false

Show previous/next navigation buttons.

nav

Type: boolean
Default: false

Show navigation dots for direct slide access.

pager

Type: boolean
Default: false

Show current/total slides counter (e.g., "1 / 5").

prev-label

Type: string
Default: "Previous"

Text and accessible name of the default previous button. Example.

next-label

Type: string
Default: "Next"

Text and accessible name of the default next button.

pager-separator

Type: string
Default: " / "

Text between the current and total page numbers of the pager, e.g. " of ".

loop

Type: boolean
Default: false

Wrap from the last page to the first and back. Without it, navigation stops at the ends (autoplay still rewinds).

autoplay

Type: number
Default: 0

Autoplay interval in milliseconds (0 to disable).

use-pause

Type: boolean
Default: true

Pause autoplay on hover and while the carousel has focus.

behavior

Type: string
Default: "smooth"

Scroll behavior ("smooth" or "auto").

stop

Type: boolean
Default: false

Force stopping at each step. (scroll-snap-stop: always)

vertical

Type: boolean
Default: false

Enable vertical scrolling mode instead of horizontal.

responsive

Type: array
Default: []

Settings applied above a viewport width (e.g., responsive='[{"breakpoint": "1024", "settings": {"displayed": "2"}}]'). Option names are camelCase inside settings, e.g. perPage, prevLabel.

scrollbar

Type: boolean
Default: false

Show scrollbar.

sync

Type: string
Default: null

CSS selector for the carousel to sync with.