If you’re looking for a carousel setup that is professional, fully customizable, accessible, and performant, this Embla Carousel init setup is a good option worth reaching for. Instead of writing fresh Embla setup code for every logo carousel, testimonial slider, or image gallery, you get one reusable function that every carousel component in your project can import and configure with a simple options object.
In this tutorial, we’ll walk through hosting the js file, setting up the expected HTML structure, initializing a basic carousel, and then layering on navigation, styling, autoplay, fade transitions, responsive sizing, and mobile only behavior.
Step 1: Load the JS File
The js file is served from jsDelivr, so there’s nothing to download or host. Import it at the top of any inline snippet that needs a carousel, from a type="module" script:
import { initEmblaCarousel } from 'https://cdn.jsdelivr.net/gh/zackpyle/embla-carousel-init@1.2.0/embla-carousel-init.js';
The version in the URL controls when you get updates. @1.2.0 pins this exact release, so the file never changes under you, which is what you want in production. Swap it for @1 to always get the newest 1.x release and pick up fixes automatically.
If you’d rather host it yourself, download the latest release:
Then upload it to your theme or uploads folder (or your own CDN) and swap the jsDelivr URL in the import for yours. This file needs to live somewhere reachable by URL. A couple of options:
- Upload it as a static asset from a theme or uploads folder
- Host it on a CDN and reference the public URL
Step 2: Build the Expected HTML Structure
Embla expects a specific nesting structure. The outer element is the viewport (this is what you’ll target with your selector), and it needs a container div wrapping individual slide divs.
<div class="carousel" role="region" aria-label="Featured products">
<div class="embla__container">
<div class="embla__slide">Slide 1</div>
<div class="embla__slide">Slide 2</div>
<div class="embla__slide">Slide 3</div>
</div>
</div>
The role="region" and aria-label on the viewport aren’t required by the js itself, but you’ll want them on every carousel you build. Step 10 covers why.
Note: .embla__container and .embla__slide are the default selectors the js looks for. If your markup uses different class names, override them with containerSelector and slideSelector which are covered in some examples below. Just make sure your HTML matches whatever selectors you pass in. Since BEM naming is the norm in Etch, you’ll likely be setting these on most components rather than renaming your markup to match Embla’s defaults.
Step 3: Initialize a Basic Carousel
With the structure in place, initializing is a single function call. Pass in a CSS selector (or a DOM element directly) and a config object.
initEmblaCarousel('.carousel', {
loop: true,
enableButtons: true,
enableDots: true
});
initEmblaCarousel accepts either a CSS selector string or a DOM element as its first argument. Passing a selector string only initializes the first matching element, the same way document.querySelector works. That’s fine for a one-off carousel, but if a component repeats on the page, a testimonial carousel inside a loop, or several product carousels on a category archive, you’ll end up with only the first one actually working.
For those cases, grab all the matches yourself with querySelectorAll and call initEmblaCarousel once per element, passing the element directly instead of a selector:
document.querySelectorAll('.carousel').forEach((el) => {
initEmblaCarousel(el, {
loop: true,
enableButtons: true,
enableDots: true
});
});
This also gives you control over initialization order if it matters, and lets you pass different config per instance if, say, one carousel in the loop needs different settings than the others.
If you forget and pass a selector that matches more than one element, the module will log a console warning telling you exactly how many elements matched and reminding you to loop, rather than failing silently.
Step 4: Add Navigation
The js gives you two ways to handle prev/next buttons and dots.
Generated navigation
If you don’t already have button or dot html markup, just enable them and leave the selector options unset. The js will build and append .embla__buttons and .embla__dots inside the viewport for you.
initEmblaCarousel('.carousel', {
enableButtons: true,
enableDots: true
});
Custom navigation markup
If you’ve already designed your own buttons or dots in Etch, point the js at them instead. It’ll wire up click handlers and keep disabled/aria-selected state in sync without generating anything extra.
initEmblaCarousel('.carousel', {
enableButtons: true,
prevButtonSelector: '.my-prev-btn',
nextButtonSelector: '.my-next-btn',
enableDots: true,
dotsContainerSelector: '.my-dots-container'
});
For custom dots, your container just needs to hold <button> elements, one per slide (or per scroll snap, depending on your config).
prevButtonSelector, nextButtonSelector, and dotsContainerSelector also accept a DOM element directly instead of a selector string.
Scoping custom navigation to one carousel
By default, custom nav selectors are looked up across the whole document. That’s fine for a one-off, but as soon as a component with custom nav repeats on the page, every instance finds the first .my-prev-btn on the page, and they all end up driving the wrong carousel.
The scope option fixes this by limiting where those selectors are looked up. Pass it an element, or a selector for an ancestor of the viewport (it’s resolved with closest()):
document.querySelectorAll('.slider').forEach((el) => {
initEmblaCarousel(el.querySelector('.slider__viewport'), {
scope: el, // or scope: '.slider'
enableButtons: true,
prevButtonSelector: '.slider__prev',
nextButtonSelector: '.slider__next',
enableDots: true,
dotsContainerSelector: '.slider__thumbs'
});
});
This also means your nav can live outside the viewport element entirely, say in a header row above the slides, and still be tied to the right carousel. If a `scope` selector doesn’t match any ancestor of the viewport, the module logs a console warning and falls back to the whole document.
Step 5: Auto-Hide Navigation When There’s Nothing to Scroll
A small issue with carousels is showing nav buttons / dots with dynamic content is even when every slide already fits in the visible area, they still show up. This js handles that automatically by checking how many scroll positions Embla actually has, and toggling a class on the viewport when there’s only one.
The class name is embla--no-nav, so every carousel using this js shares the same CSS contract. It’s on by default, but you can opt a specific carousel out entirely with enableNoNavClass: false:
initEmblaCarousel('.carousel', {
enableButtons: true,
enableDots: true,
enableNoNavClass: false
});
Then in your CSS, hide the nav elements whenever that class is present:
.embla--no-nav .embla__buttons,
.embla--no-nav .embla__dots{
display: none;
}
This re-evaluates on every reInit, so it stays accurate through resizes, breakpoint changes, and prop updates inside the Etch editor. If you don’t want this behavior at all for a specific carousel, set enableNoNavClass to false.
Step 6: Styling the Carousel
A few pieces of CSS are required regardless of how you configure the carousel, plus a starting point for the generated buttons and dots if you’re using them.
Required container styles
The module only applies display: flex to the container inline when JS-driven sizing is active (slideWidth or slidesVisible set). If you’re using CSS-driven sizing, or just don’t want a flash of unstyled layout before JS runs, set it yourself. touch-action and cursor are never set by the script at all, those are on you for correct drag behavior:
.embla__container {
display: flex;
touch-action: pan-y pinch-zoom;
cursor: grab;
&:active {
cursor: grabbing;
}
}
touch-action: pan-y pinch-zoom tells the browser to let Embla handle horizontal drags itself while still allowing normal vertical scrolling and pinch-zoom, without it, dragging a carousel on mobile can fight with the page’s own scroll. The cursor rules are just a visual affordance for mouse users, grab when idle, grabbing while actively dragging.
It’s also worth clipping horizontal overflow on whatever section wraps the carousel, so dragged or auto-scrolling slides don’t spill out visually. Matching on embla rather than something like slider is more reliable here, since every carousel this module touches gets embla__container, embla__slide, and an embla__live-region regardless of what you name the component itself:
section:has([class*="embla"]){
overflow-x: clip;
}
Starter styles for buttons and dots
Here’s a solid starting point for styling the carousel, buttons, dots the js creates. This uses zero specificity selectors (:where()) so it’s easy to override per component.
/* Embla Carousel - Default Styles (Zero Specificity) */
/* Buttons */
:where(.embla__buttons) {
display: flex;
gap: 1rem;
justify-content: start;
margin-top: 1.5rem;
}
:where(.embla__button) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
padding: 0;
background-color: transparent;
color: var(--primary);
border: 1px solid var(--primary);
border-radius: 50%;
cursor: pointer;
transition: var(--transition);
}
:where(.embla__button:hover) {
background-color: var(--primary);
color: white;
.bg-pattern &,
.bg-pattern-2 &,
.bg--dark &{
color: var(--primary);
background-color: var(--white);
}
}
:where(.embla__button:disabled) {
opacity: 0.3;
cursor: not-allowed;
}
:where(.embla__button-icon) {
display: block;
width: 1.25rem;
height: 1.25rem;
background-color: currentColor;
mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4gPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMi4zOSAxNy42NCI+PHBhdGggZmlsbD0iY3VycmVudENvbG9yIiBkPSJNMTUuMDcuMzJhMSAxIDAgMCAwLS4yNi0uMjMuNzMuNzMgMCAwIDAtLjY0LS4wM2MtLjEuMDQtLjIuMTEtLjI4LjJzLS4xNC4xOS0uMTguMzEtLjA2LjI0LS4wNS4zNmMwIC4xMi4wMy4yNC4wOC4zNXMuMTIuMjEuMi4yOWw1LjcyIDYuMzlILjc2Yy0uMi4wMS0uNC4xMS0uNTQuMjhTMCA4LjYyIDAgOC44NXMuMDguNDUuMjIuNjFjLjE0LjE3LjMzLjI2LjU0LjI4aDE4LjkybC01LjczIDYuMzhjLS4xNC4xNy0uMjIuMzktLjIyLjYzcy4wOC40Ni4yMi42M2EuNy43IDAgMCAwIC4yNi4xOWMuMS4wNC4yLjA3LjMuMDdzLjIxLS4wMi4zLS4wN2MuMS0uMDQuMTgtLjExLjI2LS4xOWw3LjA5LTcuOTFjLjA4LS4wOC4xNC0uMTguMTgtLjI5YS45Ny45NyAwIDAgMCAwLS42OC45LjkgMCAwIDAtLjE4LS4yOXoiPjwvcGF0aD48L3N2Zz4g');
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4gPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMi4zOSAxNy42NCI+PHBhdGggZmlsbD0iY3VycmVudENvbG9yIiBkPSJNMTUuMDcuMzJhMSAxIDAgMCAwLS4yNi0uMjMuNzMuNzMgMCAwIDAtLjY0LS4wM2MtLjEuMDQtLjIuMTEtLjI4LjJzLS4xNC4xOS0uMTguMzEtLjA2LjI0LS4wNS4zNmMwIC4xMi4wMy4yNC4wOC4zNXMuMTIuMjEuMi4yOWw1LjcyIDYuMzlILjc2Yy0uMi4wMS0uNC4xMS0uNTQuMjhTMCA4LjYyIDAgOC44NXMuMDguNDUuMjIuNjFjLjE0LjE3LjMzLjI2LjU0LjI4aDE4LjkybC01LjczIDYuMzhjLS4xNC4xNy0uMjIuMzktLjIyLjYzcy4wOC40Ni4yMi42M2EuNy43IDAgMCAwIC4yNi4xOWMuMS4wNC4yLjA3LjMuMDdzLjIxLS4wMi4zLS4wN2MuMS0uMDQuMTgtLjExLjI2LS4xOWw3LjA5LTcuOTFjLjA4LS4wOC4xNC0uMTguMTgtLjI5YS45Ny45NyAwIDAgMCAwLS42OC45LjkgMCAwIDAtLjE4LS4yOXoiPjwvcGF0aD48L3N2Zz4g');
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
:where(.embla__button-icon--prev) {
transform: rotate(180deg);
}
/* Dots */
:where(.embla__dots) {
display: flex;
gap: 0.5rem;
justify-content: center;
align-items: center;
margin-top: 1.5rem;
}
:where(.embla__dot) {
width: 0.75rem;
height: 0.75rem;
padding: 0;
background-color: var(--neutral-ultra-light);
border: none;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s ease, transform 0.2s ease;
}
:where(.embla__dot:hover) {
background-color: var(--secondary);
}
:where(.embla__dot--active) {
background-color: var(--primary);
}
.embla__buttons{
transition: opacity .25s ease .5s;
}
.embla__buttons:has(.embla__button--prev[disabled]):has(.embla__button--next[disabled]){
opacity: 0;
}
/* Auto-hiding nav when there's nothing to scroll to */
.embla--no-nav .embla__buttons,
.embla--no-nav .embla__dots{
display: none;
}
One thing worth pointing out here. The .embla__buttons:has(...) rule adds a nice touch, hiding the buttons if both prev and next are ever disabled at the same time (non-loop mode, single scroll position).
The icon is embedded as a base64 data URI right in the CSS, so there’s no extra file request or dependency on a media library upload path. If you ever want to swap the arrow for a different icon, you don’t have to encode anything, just replace the whole data:image/svg+xml;base64,... value with a direct link to your SVG file instead, in both the mask-image and -webkit-mask-image properties. The currentColor background keeps it in sync with the button’s text color automatically.
Step 7: Choose a Sizing Mode
You’ve got two approaches for controlling how wide each slide is. JS-driven sizing is the better default for most carousels, since it handles showing a reliable number of slides per view without you writing your own breakpoint math. CSS-driven sizing is the better call when slides shouldn’t be forced into an exact per-view count, a logo ticker is the clearest example, where each logo just needs to be its natural width and the row scrolls continuously regardless of how many happen to fit.
JS-driven sizing
Let the js calculate widths for you by setting slidesVisible:
initEmblaCarousel('.carousel', {
slidesVisible: 3,
slideGap: 16
});
Or fix every slide to a specific pixel width instead:
initEmblaCarousel('.carousel', {
slideWidth: 300,
slideGap: 16
});
One thing worth knowing: when loop is enabled, spacing between slides is applied with margin-right instead of gap, to avoid issues at the clone boundaries. In non-loop mode, gap is used instead. You don’t need to do anything differently, just know the module is handling that switch for you under the hood.
CSS-driven sizing
If you don’t set slideWidth or slidesVisible, the module isn’t calculating anything for you, so visible-item count becomes entirely your responsibility in CSS. Style .embla__slide widths yourself, and account for gaps in the same way you would in any flex layout. For a percentage-based, N-per-view approach:
.embla__slide {
flex: 0 0 calc((100% - (2 * 1rem)) / 3);
}
That example shows 3 slides at a time, with the 2 * 1rem accounting for the two gaps between them. Keep in mind that ratio will drift as the viewport changes unless you write your own breakpoints to adjust it, so this mode works best for cases like a logo ticker where a strict per-view count doesn’t actually matter, rather than a testimonial or product carousel where you want a predictable number visible at every screen size.
For a fixed-width card instead, where every slide is the same set width regardless of viewport, and the number visible just falls out naturally from however many fit:
.transactions-slider-section__item {
flex: 0 0 auto; /* don't grow or shrink to fill space */
min-width: 0; /* allow shrinking below content's natural size */
width: 375px; /* set desired card width */
max-width: 100%; /* prevent overflow on narrow viewports */
margin-right: var(--grid-gap);
}
Swap margin-right for gap on the container instead if you’re not in loop mode, same tradeoff covered above for JS-driven sizing.
Step 8: Optional Autoplay or Auto-Scroll
There are two separate timed-motion options, and they behave differently.
Autoplay (timed slide advances)
initEmblaCarousel('.carousel', {
loop: true,
autoplay: true,
autoplayDelay: 4000,
autoplayStopOnMouseEnter: true,
autoplayStopOnInteraction: false
});
Autoplay also stops when keyboard focus moves into a slide, so a keyboard user isn’t pulled away from whatever they’re reading. That’s on by default via autoplayStopOnFocusIn. If you want autoplay set up but not running yet, set autoplayPlayOnInit: false and start it yourself later through the API with api.plugins().autoplay.play().
Slide changes made by autoplay aren’t announced to screen readers, only the ones the visitor makes, which follows the WAI-ARIA carousel pattern.
Autoplay timer bar
As of Embla 8.6.0, the Autoplay plugin exposes timeUntilNext() and fires autoplay:timerset and autoplay:timerstopped events. That’s everything you need to drive a progress bar off the plugin’s own clock, so the bar stays in sync through hover pauses, focus stops, and manual navigation without you tracking any timers yourself:
const api = await initEmblaCarousel(el, {
loop: true,
autoplay: true,
autoplayDelay: 6000
});
const autoplay = api.plugins().autoplay;
const bar = el.querySelector('.slider__progress');
const startBar = () => {
const ms = autoplay.timeUntilNext();
if (ms === null) return;
// Reset to empty, then animate to full over the remaining time
bar.style.transition = 'none';
bar.style.transform = 'scaleX(0)';
bar.offsetWidth; // force reflow so the reset applies before the transition
bar.style.transition = `transform ${ms}ms linear`;
bar.style.transform = 'scaleX(1)';
};
const stopBar = () => {
bar.style.transition = 'none';
bar.style.transform = 'scaleX(0)';
};
api.on('autoplay:timerset', startBar);
api.on('autoplay:timerstopped', stopBar);
// The first timer may already be running by the time listeners are attached
startBar();
.slider__progress {
height: 3px;
background-color: var(--primary);
transform: scaleX(0);
transform-origin: left;
}
Auto-scroll (continuous ticker style motion)
initEmblaCarousel('.carousel', {
loop: true,
autoScroll: true,
autoScrollSpeed: 1,
autoScrollStopOnInteraction: true
});
Both autoplay/auto-scroll Embla plugins are loaded on demand from jsDelivr’s ESM builds, so there’s no added weight unless you actually turn one of these on. Also worth noting, the screen-reader announcement region is automatically skipped while autoScroll is active, since it would otherwise fire constantly.
Fade (cross-fade instead of sliding)
Instead of sliding, slides stack in place and cross-fade between each other. This pairs well with autoplay for things like a hero slideshow:
initEmblaCarousel('.carousel', {
loop: true,
fade: true,
autoplay: true,
autoplayDelay: 5000
});
Because the slides are stacked, each one needs to fill the full viewport width:
.embla__slide {
flex: 0 0 100%;
}
Don’t combine fade with autoScroll, since a continuous ticker has nothing to fade between. The inert handling from the Accessibility section also adjusts for fade mode automatically. Stacked slides all technically count as “in view”, so the module uses the selected slide instead, and only that one stays reachable by keyboard and screen readers.
The autoplay, auto-scroll, and fade Embla plugins are all loaded on demand from jsDelivr’s ESM builds, so there’s no added weight unless you actually turn one of them on.
Step 9: Carousel On Mobile Only
Sometimes you only want the carousel behavior on smaller screens, and a plain CSS grid on desktop. The mobileOnly option handles this entirely for you, checking the breakpoint on load and on every resize, then creating or tearing down the Embla instance as the viewport crosses it.
initEmblaCarousel('.carousel', {
mobileOnly: true,
mobileBreakpoint: '(width <= 767px)',
enableButtons: true,
enableDots: true
});
When the breakpoint isn’t matched, your markup just renders as plain HTML, no Embla instance attached at all. This is a nice way to keep desktop layouts simple without maintaining two separate components.
Accessibility
The js handles quite a bit of this automatically, but there’s one piece it deliberately leaves to you.
What’s built in
- A visually-hidden
aria-live="polite"region announces the current slide using theannouncementtemplate (default isSlide {current} of {total}). This fires on Embla’ssettleevent rather thanselect, so a drag that passes through several slides only announces the one it actually lands on, not every slide it passed through along the way. Advances made by autoplay aren’t announced, only changes the visitor makes - Autoplay stops when keyboard focus moves into a slide (
autoplayStopOnFocusIn), and pauses on hover by default (autoplayStopOnMouseEnter) - Dots follow the ARIA tabs pattern (
role="tablist"/role="tab",aria-selected) with a rovingtabindex, meaning only the active dot sits in the tab order, matching what a keyboard user expects from a tab group. Left/Right arrow keys move between dots (wrapping only whenloop: true), and Home/End jump to the first and last dot. This works whether you’re using generated dots or your own markup viadotsContainerSelector - Generated buttons include
aria-labels ofPrevious slideandNext slide - Prev/next buttons are properly
disabled, not just visually hidden, when there’s nowhere left to scroll (unlessloopis on) - Slides that are scrolled out of view are set
inert(feature-detected, so older browsers simply don’t get this protection), so keyboard and screen reader users can’t tab into links or buttons sitting inside a slide that isn’t currently visible. This updates on everyselect,settle, andreInit, and clears automatically when a carousel is destroyed, for example when amobileOnlycarousel switches off
If you want a custom announcement message, override the template:
initEmblaCarousel('.carousel', {
announcement: 'Showing slide {current} of {total}'
});
What you need to add yourself
The js does not label the carousel region, since only you know what a given carousel actually is. Add role="region" and an aria-label (or aria-labelledby pointing at a visible heading) on the viewport element in your own markup:
<div class="carousel" role="region" aria-label="Featured products">
<div class="embla__container">…</div>
</div>
Without this, a screen reader user will hear the slide-change announcements from the live region, but has no way to know which carousel they’re actually in if there’s more than one on the page.
Full Config Reference
| Option | Type | Default | Description |
|---|---|---|---|
mobileOnly |
boolean | false |
Only initialize within mobileBreakpoint, destroying/reinitializing automatically as the viewport crosses it |
mobileBreakpoint |
string | (width <= 767px) |
Media query used when mobileOnly is true |
containerSelector |
string | .embla__container |
Selector for the slides container, relative to the viewport |
slideSelector |
string | .embla__slide |
Selector for individual slides, relative to the container |
slidesVisible |
number | null |
If set, JS calculates slide width so this many are visible at once |
slideWidth |
number | null |
If set, applies a fixed pixel width to every slide |
slideGap |
number | null |
Gap in pixels between slides, only used with JS sizing (defaults to 16 when active) |
loop |
boolean | false |
Enable infinite looping |
align |
string | start |
Slide alignment: start, center, or end |
slidesToScroll |
number | 1 |
Number of slides advanced per navigation action |
skipSnaps |
boolean | false |
Allow the carousel to skip scroll snaps during a drag |
dragFree |
boolean | false |
Enable free-form dragging instead of snapping |
containScroll |
string | trimSnaps |
Embla’s scroll containment strategy |
breakpoints |
object | {} |
Responsive overrides keyed by media query, following Embla’s breakpoints API |
enableButtons |
boolean | false |
Enable prev/next navigation buttons |
prevButtonSelector |
string | null |
Use an existing element as the previous button instead of generating one |
nextButtonSelector |
string | null |
Use an existing element as the next button instead of generating one |
enableDots |
boolean | false |
Enable dot navigation |
dotsContainerSelector |
string | null |
Use an existing container of buttons as dots instead of generating them |
autoScroll |
boolean | false |
Enable continuous auto-scroll via the AutoScroll plugin |
autoScrollSpeed |
number | 1 |
Auto-scroll speed multiplier |
autoScrollStopOnInteraction |
boolean | true |
Stop auto-scroll permanently after user interaction |
autoScrollStopOnMouseEnter |
boolean | false |
Pause auto-scroll while hovering |
autoplay |
boolean | false |
Enable timed slide advances via the Autoplay plugin |
autoplayDelay |
number | 4000 |
Milliseconds between autoplay advances |
autoplayStopOnInteraction |
boolean | false |
Stop autoplay permanently after the user drags |
autoplayStopOnMouseEnter |
boolean | true |
Pause autoplay while hovering |
announcement |
string | Slide {current} of {total} |
Template for the screen-reader announcement, with {current} and {total} replaced automatically |
enableNoNavClass |
boolean | true |
Whether to toggle the fixed embla--no-nav class on the viewport when there’s only one scroll position, set to false to disable for a specific carousel |
Example Scenarios
Here are a few complete configs for common carousel types, so you can see how the options come together in practice. Since BEM naming is easy to keep consistent in Etch, these examples assume you’re using containerSelector and slideSelector to match your own component’s class names rather than renaming everything to Embla’s defaults.
Testimonial carousel
Looping, JS-driven sizing, generated navigation, and autoplay that pauses on hover.
import { initEmblaCarousel } from 'https://your-cdn-or-host/embla-carousel-init.js';
initEmblaCarousel('.testimonial-carousel', {
containerSelector: '.testimonial-carousel__list',
slideSelector: '.testimonial-carousel__item',
loop: true,
align: 'center',
slidesVisible: 3,
slideGap: 24,
enableButtons: true,
enableDots: true,
autoplay: true,
autoplayDelay: 5000,
autoplayStopOnMouseEnter: true,
breakpoints: {
'(width <= 767px)': {
slidesVisible: 1
}
}
});
Hero slideshow with fade and timer bar
One full-width slide at a time, cross-fading on a timer, with custom dots and a progress bar that fills up until the next slide. Autoplay pauses on hover and stops when keyboard focus moves into a slide, both by default, so there’s nothing extra to configure for that.
const api = await initEmblaCarousel('.hero-slider__viewport', {
containerSelector: '.hero-slider__list',
slideSelector: '.hero-slider__item',
loop: true,
fade: true,
autoplay: true,
autoplayDelay: 6000,
enableDots: true,
dotsContainerSelector: '.hero-slider__dots'
});
if (api) {
const autoplay = api.plugins().autoplay;
const bar = document.querySelector('.hero-slider__progress');
const startBar = () => {
const ms = autoplay.timeUntilNext();
if (ms === null) return;
// Reset to empty, then animate to full over the remaining time
bar.style.transition = 'none';
bar.style.transform = 'scaleX(0)';
bar.offsetWidth; // force reflow so the reset applies before the transition
bar.style.transition = `transform ${ms}ms linear`;
bar.style.transform = 'scaleX(1)';
};
const stopBar = () => {
bar.style.transition = 'none';
bar.style.transform = 'scaleX(0)';
};
api.on('autoplay:timerset', startBar);
api.on('autoplay:timerstopped', stopBar);
// The first timer may already be running by the time listeners are attached
startBar();
}
.hero-slider__item {
flex: 0 0 100%;
}
.hero-slider__progress {
height: 3px;
background-color: var(--primary);
transform: scaleX(0);
transform-origin: left;
}
Logo carousel, continuous ticker
No buttons or dots needed here, just a slow, non-stop scroll with no pause on interaction, the kind of thing you’d use for a row of client or partner logos. Sizing is left to CSS here rather than slidesVisible, since each logo should just be its natural width, not stretched or squeezed to fit an exact per-view count.
initEmblaCarousel('.logo-carousel', {
containerSelector: '.logo-carousel__list',
slideSelector: '.logo-carousel__item',
loop: true,
align: 'start',
autoScroll: true,
autoScrollSpeed: 0.5,
autoScrollStopOnInteraction: false
});
.logo-carousel__item {
flex: 0 0 auto;
width: auto;
margin-right: 2rem;
}
Image gallery, mobile only
Renders as a plain CSS grid on desktop, only becomes a draggable carousel below the breakpoint.
initEmblaCarousel('.image-gallery', {
containerSelector: '.image-gallery__list',
slideSelector: '.image-gallery__item',
mobileOnly: true,
mobileBreakpoint: '(width <= 767px)', // Carousel below 768px
loop: false,
dragFree: true,
enableDots: true
});
Services carousels with custom nav markup
Uses buttons and dots already built into the component instead of letting the js generate them. scope keeps each instance wired to its own nav if the component shows up more than once on a page.
document.querySelectorAll('.services-carousel').forEach((el) => {
initEmblaCarousel(el.querySelector('.services-carousel__viewport'), {
scope: el,
containerSelector: '.services-carousel__list',
slideSelector: '.services-carousel__item',
slidesToScroll: 1,
enableButtons: true,
prevButtonSelector: '.services-carousel__nav-prev',
nextButtonSelector: '.services-carousel__nav-next',
enableDots: true,
dotsContainerSelector: '.services-carousel__nav-dots'
});
});
Team carousel with multiple responsive breakpoints
This one’s a good example of stacking the breakpoints option on top of mobileOnly to control both whether the carousel runs at all and how many people are visible at each screen size. It’s a mobile/tablet carousel that becomes a plain grid at 1200px and up, with slidesVisible stepping up from roughly 1 person to 2 to 3 as the viewport grows, using fractional values so the next person peeks in at the edge as a scroll affordance.
initEmblaCarousel('.team-slider-container', {
containerSelector: '.team-slider',
slideSelector: '.team-slider__person',
mobileOnly: true,
mobileBreakpoint: '(width <= 1199px)', // Carousel below 1200px
loop: false,
enableDots: true,
slidesVisible: 1.25, // Mobile: 1 person visible
slideGap: 24,
slidesToScroll: 1,
breakpoints: {
'(width >= 450px)': {
slidesVisible: 2.25 // 450px+: 2 people visible
},
'(width >= 900px)': {
slidesVisible: 3.25 // 900px+: 3 people visible
}
}
});
Since mobileBreakpoint and the entries in breakpoints are independent settings, it’s worth double checking they line up the way you expect. Here, the carousel is active anywhere from 0 up to 1199px, and within that range, slidesVisible steps from 1.25 at the smallest widths, to 2.25 at 450px and up, to 3.25 at 900px and up, still safely under the 1200px cutoff where the carousel turns off entirely.
Pair any of these with your own BEM-structured HTML and the CSS from Step 6, and you’ve got a fully accessible, responsive carousel that only shows navigation when it’s actually needed.
Troubleshooting
The carousel isn’t initializing at all
initEmblaCarousel returns null if it can’t find the viewport, container, or slides, so check that your selector actually matches an element on the page and that your HTML follows the structure from Step 2. If you’re using containerSelector or slideSelector overrides, double check those match your markup exactly. Also worth confirming the import URL is reachable, a 404 on the module file will silently fail the import.
Only the first carousel works when the component repeats
This means you’re passing a selector string to a component that appears more than once on the page, a CMS loop being the usual cause. As covered in Step 3, a string selector only ever initializes the first match. Check your browser console, the module will warn you with the exact match count if this is happening, and remind you to loop with querySelectorAll and initialize each element individually instead.
mobileOnly carousel returns null on desktop
This is expected behavior, not a bug. When mobileOnly is true and the breakpoint doesn’t match, there’s no Embla instance to return, so null just means you’re on desktop and the plain HTML fallback is rendering instead.
Slides are the wrong width or overlapping
If you’re using CSS-driven sizing, make sure you’re not also setting slideWidth or slidesVisible, since JS sizing will override your CSS widths. If you are using JS sizing and slides still look off, check slideGap is set to something reasonable, it defaults to 16 only when JS sizing is active, otherwise it’s ignored entirely.
Buttons or dots show up duplicated in the Etch editor
The module already cleans up generated buttons, dots, and the live region on every reInit/destroyEmbla, so this shouldn’t happen with generated navigation. If you’re seeing duplicates, check whether the snippet itself is being initialized more than once, for example if it’s both running on page load and being re-triggered by another script or an Etch interaction.
Nav buttons or dots never disappear on a single slide carousel
Confirm enableNoNavClass hasn’t been set to false, and that your CSS actually targets embla--no-nav, the fixed class name this module always uses. Also check that the class is being applied to the viewport element, the one you passed as the selector, not the container or slides.
Autoplay or auto-scroll isn’t working
Both plugins load on demand from jsDelivr, so a slow or blocked connection to that CDN will prevent them from working. Check your browser’s network tab for a failed request. Also double check you’re setting autoplay: true or autoScroll: true, not just the delay or speed options, those alone won’t turn the feature on.
Screen reader announcements aren’t firing
This is expected if autoScroll is enabled, the live region is intentionally skipped in that mode to avoid constant announcements. If you’re not using auto-scroll and still not hearing announcements, verify the aria-live region wasn’t accidentally hidden or removed by other CSS on the page.
Links or buttons inside a slide can’t be clicked or tabbed to
This is expected if the slide is currently scrolled out of view, the module sets those slides inert so they’re skipped by keyboard and screen reader navigation, which is the intended behavior. If interactive content inside the currently visible slide isn’t working, check that the browser actually supports inert feature detection correctly, or that nothing else in your CSS is applying pointer-events: none to active slides.
Wrapping Up
Because this js returns the Embla API instance (or null if the viewport, container, or slides couldn’t be found, or if mobileOnly is active and the breakpoint doesn’t currently match), you can grab it and call Embla methods directly if a component needs more control, things like scrollTo() or listening for events with .on().
That covers the core setup. From here, every new carousel component in your project is really just a different HTML structure and config object pointed at the same shared js.
Changelog
1.2.0 — Scoped controls and autoplay timer events
Released Oct 7, 2026
- Embla and plugins updated from
v8.1.4tov8.6.0. - Autoplay gains
timeUntilNext()and theautoplay:timerset/autoplay:timerstoppedevents - New
scopeoption for looking up custom controls inside one component prevButtonSelector,nextButtonSelector, anddotsContainerSelectoraccept elements as well as selectors- New
autoplayStopOnFocusInandautoplayPlayOnInitoptions - Autoplay advances are no longer announced to screen readers
1.1.0 — Fade
Released Sept 17, 2026
New fade option (using Embla Fade plugin). Inert handling uses the selected slide in fade mode
1.0.0 — Initial Release
Released July 1, 2026
Initial release on Embla 8.1.4: generated or custom navigation with auto-hide, JS or CSS slide sizing, autoplay and auto-scroll, mobile-only mode, and built-in accessibility (live region, ARIA tabs dots, `inert` offscreen slides)