Skip to content

Embla — the cross-framework carousel

Embla Carousel is a dependency-free, library-agnostic carousel engine: its core is pure vanilla JS that attaches to a viewport element, reads the consumer's slide DOM, and drives transform: translate3d(...) for buttery drag/scroll. But its framework wrappers are uneven: React, Vue, Svelte and Solid have official wrappers — but they are four divergent APIs (a hook vs a composable vs an action vs a Solid primitive); Angular has only a single-maintainer community package version-pinned to Angular majors; and Lit / web components have nothing at all.

One Carousel.rozie source compiles to six idiomatic packages — so all six frameworks get the same props, events, two-way selectedIndex, and imperative handle. Lit consumers get a category-leading Embla wrapper for free; Angular gets a first-party-quality signals wrapper from the same source as the other five.

The full source for Carousel.rozie lives in the @rozie-ui/embla package. See it running in the live demo, and how it stacks up against the per-framework wrappers in the libraries comparison.

The @rozie-ui/embla packages

PackageFrameworkShips
@rozie-ui/embla-reactReact 18+compiled .tsx + types
@rozie-ui/embla-vueVue 3.4+.vue SFC source
@rozie-ui/embla-svelteSvelte 5+.svelte source
@rozie-ui/embla-angularAngular 19+standalone component source
@rozie-ui/embla-solidSolid 1.8+compiled .tsx + types
@rozie-ui/embla-litLit 3+compiled custom element + types

All six wrap Embla Carousel v8 (embla-carousel@^8.6) plus the Autoplay plugin (embla-carousel-autoplay@^8.6), both declared as peer dependencies. (Embla v9 is RC-only and renames the whole API surface — it is deliberately not targeted yet.)

Install

Install the one package for your framework plus the two Embla peer dependencies — no Rozie toolchain, no build-time compile step:

bash
# React (also: react-dom)
npm i @rozie-ui/embla-react embla-carousel embla-carousel-autoplay
# Vue
npm i @rozie-ui/embla-vue embla-carousel embla-carousel-autoplay
# Svelte / Angular / Solid / Lit — swap the framework package
npm i @rozie-ui/embla-svelte embla-carousel embla-carousel-autoplay

There is no engine CSS to import — Embla's carousel skeleton ships scoped inside the component (see the tip above).

No engine CSS to import

Unlike most engine wrappers, Embla ships no stylesheet you must import. The carousel skeleton styles — an overflow: hidden viewport, a display: flex container, and slide sizing — ship scoped inside the component. Slides are plain light-DOM framework children, so the scoped styles reach them on all six targets (including through Lit's shadow root).

Quick start

There are two slide-source modes from one component:

  • Config array — pass :slides="[...]" and Rozie renders one slide per item (optionally via the scoped slide slot for custom markup).
  • Declarative — drop <div class="rozie-embla__slide">…</div> children into the default slot; Embla's native watchSlides reacts to adds/removes.

The current snap is two-way bound through the single selectedIndex model prop. Dragging or scrolling writes the new index back through the model path (echo-guarded so a programmatic scrollTo doesn't ping-pong); a consumer write scrolls the carousel. Snap/settle/reInit/pointer lifecycle fires as native framework events. Note the model is selectedIndex while the snap-change event is select — distinct identifiers (a model prop must not share a name with an emit).

Vue

vue
<script setup lang="ts">
import { ref } from 'vue';
import Carousel from '@rozie-ui/embla-vue';

const index = ref(0);
</script>

<template>
  <Carousel
    :slides="['A', 'B', 'C']"
    v-model:selectedIndex="index"
    :loop="true"
    @select="(i) => console.log('snap', i)"
  />
</template>

React

tsx
import { useState } from 'react';
import { Carousel } from '@rozie-ui/embla-react';

export function Demo() {
  const [index, setIndex] = useState(0);
  return (
    <Carousel
      slides={['A', 'B', 'C']}
      selectedIndex={index}
      onSelectedIndexChange={setIndex}
      loop
      onSelect={(i) => console.log('snap', i)}
    />
  );
}

API

Props

NameTypeDefaultRuntime-updatable?Description
slidesArray[]Config-array slide data (mode a). Optional — the default slot is mode b.
loopBooleanfalseWrap from the last snap back to the first.
alignString"center"Snap alignment — 'start' | 'center' | 'end'.
axisString"x"Scroll axis — 'x' (horizontal) or 'y' (vertical).
slidesToScrollNumber1Number of slides advanced per snap.
dragFreeBooleanfalseMomentum/free-scroll drag (no hard snapping).
draggableBooleantrueEnable pointer drag (Embla watchDrag).
containScrollString"trimSnaps"Edge-snap containment — '' | 'trimSnaps' | 'keepSnaps'.
startIndexNumber0Initial snap index.
skipSnapsBooleanfalseAllow a fast flick to skip intermediate snaps.
durationNumber25Scroll transition duration (Embla's relative unit).
directionString"ltr"Text/scroll direction — 'ltr' | 'rtl'.
autoplayBooleanfalseToggle the embla-carousel-autoplay plugin.
autoplayDelayNumber4000Autoplay delay between snaps (ms).
dotsBooleanfalseShow built-in dot pagination (one dot per scroll snap).
arrowsBooleanfalseShow built-in prev/next arrow buttons overlaid on the viewport.
thumbnailsBooleanfalseShow a synced thumbnail strip (its own Embla instance); fill the thumb slot for custom thumbs.
pluginsArray[]Escape hatch — extra Embla plugins appended verbatim.
optionsObject{}Escape hatch — raw EmblaOptionsType spread last.
selectedIndexNumber0Two-way — the current scroll-snap index. Distinct from the select emit.

Every option prop is runtime-updatable: changing it $watch-triggers embla.reInit() (Embla has no per-option setter; reInit is the only update path).

Events

EventPayloadDescription
selectindex: numberFires on every snap change (drag, scroll, or programmatic).
settleFires when carousel motion stops.
reInitFires when the engine re-initialises (option/slide change).
pointer-downFires when a pointer drag begins.

Imperative handle

Build prev/next/dots controls off the $expose handle (there is no #controls slot — the imperative surface exposes everything). Grab the handle with your framework's native ref mechanism:

MethodDescription
scrollNext(jump?)Scroll to the next snap.
scrollPrev(jump?)Scroll to the previous snap.
scrollToIndex(index, jump?)Scroll to a specific snap index. Named to avoid the inherited DOM HTMLElement.scrollTo.
reInitCarousel(opts?)Re-initialise the engine (recompute snaps). Named to avoid the reInit emit.
canScrollNext()Whether a next snap is reachable.
canScrollPrev()Whether a previous snap is reachable.
getSelectedIndex()The current snap index. Named to avoid the selectedIndex model prop.
scrollSnapList()The snap-point progress array.
getInstance()The underlying EmblaCarouselType instance (engine escape hatch).
vue
<script setup>
import { ref } from 'vue';
const carousel = ref();
</script>

<template>
  <Carousel ref="carousel" :slides="['A', 'B', 'C']" />
  <button @click="carousel.scrollPrev()">Prev</button>
  <button @click="carousel.scrollNext()">Next</button>
</template>

Autoplay

Set autoplay to mount the Autoplay plugin; autoplayDelay controls the interval. Toggling either at runtime rebuilds the plugin set via reInit(options, plugins). For any other Embla plugin (Fade, Class Names, Wheel Gestures, …), pass it through the :plugins escape-hatch array.

See also

Pre-v1.0 — internal monorepo.