embla-carousel
Embla is a carousel engine, not a carousel component. You write the markup (a viewport div wrapping a flex container wrapping your slides) and the CSS that decides how wide a slide is, then call EmblaCarousel(viewportNode, options) and get back an API object with scrollNext, scrollTo, selectedScrollSnap, event subscriptions, and nothing else. There is no built-in arrow, dot, caption, or lightbox markup, which is the point: styling stays entirely yours. It has no dependencies, weighs about 7 KB gzipped, and ships official wrappers for React, Vue, Svelte, and Solid plus separate plugin packages for autoplay, fade, class names, and auto height. It is the engine behind the carousel component in several popular UI kits.
The best choice when the carousel has to match a specific design and you accept writing the markup, controls, and CSS yourself. If you want something that looks complete after ten minutes of configuration, reach for Swiper instead.
Use it if
- You have a design that a themed carousel library cannot match, and you would rather write the slide markup and CSS yourself than fight someone else's stylesheet
- You need precise drag and swipe behavior on touch devices, including free-drag momentum, loop, and per-breakpoint option overrides
- You are in React, Vue, Svelte, or Solid and want a hook or wrapper maintained in the same repo as the core, not a community port
- Bundle size is a constraint: roughly 7 KB gzipped with zero dependencies, against tens of kilobytes for full-featured carousel suites
- You want a carousel that looks finished out of the box: Embla ships no arrows, dots, thumbnails, captions, or default theme, and a real component takes 60 to 150 lines of your own markup and CSS
- A CSS scroll-snap container would do: for a simple horizontal scroller with native momentum, overflow-x auto plus scroll-snap-type costs zero bytes and no JavaScript
- You need built-in lazy loading, video handling, zoom, or coverflow effects: Swiper bundles all of that, and rebuilding it on Embla means writing each piece
- You dislike exact-pinned peer dependencies: every official plugin declares a peer dependency on the exact core version (8.6.0 requires embla-carousel 8.6.0), so upgrades have to move core and plugins together or npm install errors
- Accessibility must come for free: Embla handles focus scrolling and pointer events, but the roles, labels, and live-region announcements on your controls are entirely your job
Setup reality
npm install embla-carousel (or embla-carousel-react and friends) and the JavaScript side is one function call. The CSS is where people lose an afternoon. The library assumes three levels of markup and it does not inject any styles: the viewport needs overflow hidden, the container needs display flex, and each slide needs a flex basis plus min-width 0 or Safari and Chrome will refuse to shrink them and every slide will look full width. Slide count per view is set in CSS, not in options, which surprises people coming from Swiper. Plugins are separate packages with exact-version peer dependencies on the core, so a mismatched patch version fails to install. In React, plugin instances and the options object should be stable references, otherwise the carousel re-initializes on every render and drag feels broken.
Patterns
Initialize a carousel with plain JavaScriptvanilla-setup
import EmblaCarousel from 'embla-carousel';
const viewport = document.querySelector('.embla');
const embla = EmblaCarousel(viewport, { loop: false, align: 'start' });
embla.scrollNext();
console.log(embla.selectedScrollSnap());Pass the viewport element (the one with overflow hidden), not the container or a slide. The first child of the viewport is treated as the slide container.
Write the markup and CSS the engine expectsrequired-css
<div class="embla">
<div class="embla__container">
<div class="embla__slide">1</div>
<div class="embla__slide">2</div>
</div>
</div>
<style>
.embla { overflow: hidden; }
.embla__container { display: flex; }
.embla__slide { flex: 0 0 100%; min-width: 0; }
</style>min-width: 0 is not optional. Without it flex items refuse to shrink below their content width and every slide renders at full size.
Use the React wrapperreact-hook
import useEmblaCarousel from 'embla-carousel-react';
export function Carousel({ slides }) {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
return (
<div className="embla" ref={emblaRef}>
<div className="embla__container">
{slides.map((s) => (
<div className="embla__slide" key={s.id}>{s.title}</div>
))}
</div>
</div>
);
}emblaApi is undefined on the first render, before the ref attaches, so guard every call. Put the ref on the viewport element.
Wire previous and next buttons with disabled statesprev-next-buttons
const prevBtn = document.querySelector('.prev');
const nextBtn = document.querySelector('.next');
prevBtn.addEventListener('click', () => embla.scrollPrev());
nextBtn.addEventListener('click', () => embla.scrollNext());
function updateButtons() {
prevBtn.disabled = !embla.canScrollPrev();
nextBtn.disabled = !embla.canScrollNext();
}
embla.on('select', updateButtons).on('reInit', updateButtons);
updateButtons();Listen to reInit as well as select, otherwise the disabled state goes stale after a resize or a slide list change.
Build dot navigation from the snap listdot-navigation
const dots = embla.scrollSnapList().map((_, index) => {
const dot = document.createElement('button');
dot.addEventListener('click', () => embla.scrollTo(index));
return dot;
});
function markSelected() {
const selected = embla.selectedScrollSnap();
dots.forEach((d, i) => d.classList.toggle('is-selected', i === selected));
}
embla.on('select', markSelected).on('reInit', markSelected);scrollSnapList() length is the number of scroll positions, which is not the number of slides once slidesToScroll or partial slides are in play.
Show several slides at onceslides-per-view
/* three per view with a gap, set in CSS not in options */
.embla__container { display: flex; margin-left: -1rem; }
.embla__slide { flex: 0 0 33.333%; min-width: 0; padding-left: 1rem; }Embla has no slidesPerView option; slide width is CSS. Use the options slidesToScroll to control how many move per click, and containScroll: 'trimSnaps' to drop empty snaps at the ends.
Change options per breakpointresponsive-breakpoints
const embla = EmblaCarousel(viewport, {
align: 'center',
slidesToScroll: 1,
breakpoints: {
'(min-width: 768px)': { slidesToScroll: 2, align: 'start' },
'(min-width: 1200px)': { slidesToScroll: 3 },
},
});Keys are raw media query strings evaluated with matchMedia. Later matching queries win, and breakpoints cannot themselves contain breakpoints.
Add autoplay and control itautoplay-plugin
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
const embla = EmblaCarousel(viewport, { loop: true }, [
Autoplay({ delay: 4000, stopOnMouseEnter: true, stopOnInteraction: false }),
]);
const autoplay = embla.plugins().autoplay;
autoplay.stop();
autoplay.play();
console.log(autoplay.isPlaying());With stopOnInteraction left at its default of true, autoplay stops for good after the first drag. Install embla-carousel-autoplay at the exact same version as the core.
Keep plugin and option references stable in Reactreact-stable-plugins
import { useRef } from 'react';
import Autoplay from 'embla-carousel-autoplay';
function Carousel() {
const autoplay = useRef(Autoplay({ delay: 5000 }));
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [autoplay.current]);
// ...
}Creating Autoplay() inline in the render body makes a new plugin array on every render, which re-initializes the carousel and resets the timer. A ref or module-level constant avoids that.
Track the selected index in React statereact-event-subscription
const [selected, setSelected] = useState(0);
useEffect(() => {
if (!emblaApi) return;
const onSelect = () => setSelected(emblaApi.selectedScrollSnap());
onSelect();
emblaApi.on('select', onSelect).on('reInit', onSelect);
return () => {
emblaApi.off('select', onSelect).off('reInit', onSelect);
};
}, [emblaApi]);Always unsubscribe with off() in the cleanup; the API instance survives re-renders and duplicate listeners pile up otherwise.
Re-initialize after slides changedynamic-slides
slidesContainer.append(newSlideElement);
embla.reInit();
// or change options at the same time:
embla.reInit({ loop: true, align: 'start' });Embla watches the container for added or removed nodes by default (watchSlides), so an explicit reInit is mostly needed after CSS-driven size changes or option swaps.
Scroll vertically and tear down properlyvertical-and-cleanup
const embla = EmblaCarousel(viewport, { axis: 'y' });
// CSS: .embla__container { flex-direction: column; height: 400px; }
// on unmount / page teardown
embla.destroy();The vertical axis needs a fixed height on the viewport, otherwise every slide collapses. destroy() removes listeners; forgetting it in a single-page app leaks resize observers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| swiper | npm | You want a finished carousel with navigation, pagination, lazy loading, and effects included, and can afford the larger bundle |
| keen-slider | npm | You want a similarly small headless slider with a plugin hook API and built-in vertical and multi-track modes |
| @splidejs/splide | npm | You want accessibility defaults (ARIA roles, keyboard handling) shipped with the slider rather than added by you |