mrkeyoor.com_
Thu 06 Aug 02:46 UTC
npmWeb Frontendupdated 06 Aug 2026

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.

Verdict

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.

API stability5/5The v8 API has been stable since 2023 and 8.6.0 added features without breaking calls; option and method names on the returned API object have not shifted across minor releases
Docs5/5embla-carousel.com documents every option, method, and event with its default, plus generated code sandboxes per framework and a large set of copy-ready examples covering thumbnails, parallax, and infinite scroll
Maintenance4/5Pushed August 2026 with only 5 open issues (16 counting PRs), so the queue is genuinely small; 8.6.0 dates from April 2025, and development is effectively one maintainer plus sponsors
Ecosystem4/536M weekly downloads and 8.4k stars, first-party wrappers for React, Vue, Svelte, and Solid, official plugins for autoplay, fade, auto height, and class names, plus community plugins like wheel gestures; smaller than Swiper's plugin catalog

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
Skip it if

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

PackageRegistryPick it when
swipernpmYou want a finished carousel with navigation, pagination, lazy loading, and effects included, and can afford the larger bundle
keen-slidernpmYou want a similarly small headless slider with a plugin hook API and built-in vertical and multi-track modes
@splidejs/splidenpmYou want accessibility defaults (ARIA roles, keyboard handling) shipped with the slider rather than added by you