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:

JavaScript

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:

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.

HTML

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.

JavaScript

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:

JavaScript

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.

JavaScript

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.

JavaScript

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()):

JavaScript

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:

JavaScript

Then in your CSS, hide the nav elements whenever that class is present:

CSS

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:

CSS

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:

CSS

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.

CSS

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:

JavaScript

Or fix every slide to a specific pixel width instead:

JavaScript

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:

CSS

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:

CSS

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)

JavaScript

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:

JavaScript
CSS

Auto-scroll (continuous ticker style motion)

JavaScript

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:

JavaScript

Because the slides are stacked, each one needs to fill the full viewport width:

CSS

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.

JavaScript

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

If you want a custom announcement message, override the template:

JavaScript

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:

HTML

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.

JavaScript

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.

JavaScript
CSS

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.

JavaScript
CSS

Image gallery, mobile only

Renders as a plain CSS grid on desktop, only becomes a draggable carousel below the breakpoint.

JavaScript

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.

JavaScript

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.

JavaScript

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


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)