Carousel
A draggable, looping carousel built on Embla and wrapped in the embla-carousel web component, with dots, thumbnails, keyboard nav, and lightbox support driven entirely by markup.
<embla-carousel> is the carousel root. Put Embla options on the element, compose the
viewport → container → slide structure inside, and the script wires up dragging, looping,
navigation, dots, and thumbnails from the markup. There's no JavaScript to configure.
The element uses light DOM, so the markup you write is the markup that gets styled. It initializes
when connected, defers while inside a closed <dialog>, and tears down its Embla instances when
removed.
Default
<embla-carousel data-embla-classnames> <div class="flex items-end justify-between gap-md flex-wrap pb-lg"> <h2 class="text-h5 font-heading">Featured Articles</h2> <div class="flex @md:hidden gap-xs"> <button type="button" class="button" data-size="icon" data-variant="muted" data-embla="prev"> <svg class="size-sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"> <rect width="256" height="256" fill="none" /> <polyline points="160 208 80 128 160 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="14" /> </svg> </button> <button type="button" class="button" data-size="icon" data-variant="muted" data-embla="next"> <svg class="size-sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"> <rect width="256" height="256" fill="none" /> <polyline points="96 48 176 128 96 208" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="14" /> </svg> </button> </div> </div> <div data-embla="viewport" class="overflow-visible"> <div data-embla="container" class="-ml-md"> <!-- Card START --> <a data-embla="slide" class="basis-full @xs:basis-1/2 @sm:basis-1/3 @md:basis-1/4 cursor-pointer" href="#"> <div class="pl-md"> <div class="grid gap-sm bg-card text-card-foreground rounded-md shadow-sm overflow-clip"> <figure class="aspect-landscape rounded-t-md overflow-clip"> <img class="size-full object-cover hover:scale-105 hover:opacity-75 transition" width="300" height="200" loading="lazy" src="https://images.unsplash.com/photo-1500382017468-9049fed747ef?auto=format&fit=crop&w=300&h=200&q=60" alt="Zebras grazing at dawn on the savanna" /> </figure> <div class="grid gap-sm px-sm pb-sm"> <header class="grid gap-xs"> <div class="flex items-center gap-xs"> <span class="badge" data-variant="muted">Field notes</span> </div> <hgroup class="grid gap-xs"> <h3 class="text-lg font-heading line-clamp-1">How quickly zebras jump</h3> <p class="text-sm text-muted-foreground line-clamp-2"> Striped coats and sudden bursts of speed help herds evade predators when the grasslands go quiet at dusk. </p> </hgroup> </header> <footer class="grid justify-end gap-sm pb-xs pt-sm border-t"> <div class="button" data-variant="link"> Read article <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <polyline points="144 56 216 128 144 200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </div> </footer> </div> </div> </div> </a> <!-- Card END --> <!-- Card START --> <a data-embla="slide" class="basis-full @xs:basis-1/2 @sm:basis-1/3 @md:basis-1/4 cursor-pointer" href="#"> <div class="pl-md"> <div class="grid gap-sm bg-card text-card-foreground rounded-md shadow-sm overflow-clip"> <figure class="aspect-landscape rounded-t-md overflow-clip"> <img class="size-full object-cover hover:scale-105 hover:opacity-75 transition" width="300" height="200" loading="lazy" src="https://images.unsplash.com/photo-1564760055775-d63b17a55c44?auto=format&fit=crop&w=300&h=200&q=60" alt="A lion resting in tall golden grass" /> </figure> <div class="grid gap-sm px-sm pb-sm"> <header class="grid gap-xs"> <div class="flex items-center gap-xs"> <span class="badge" data-variant="muted">Wildlife</span> </div> <hgroup class="grid gap-xs"> <h3 class="text-lg font-heading line-clamp-1">Lions at golden hour</h3> <p class="text-sm text-muted-foreground line-clamp-2"> Warm light and long shadows make dawn patrols the best time to photograph big cats on the open plain. </p> </hgroup> </header> <footer class="grid justify-end gap-sm pb-xs pt-sm border-t"> <div class="button" data-variant="link"> View gallery <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <polyline points="144 56 216 128 144 200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </div> </footer> </div> </div> </div> </a> <!-- Card END --> <!-- Card START --> <a data-embla="slide" class="basis-full @xs:basis-1/2 @sm:basis-1/3 @md:basis-1/4 cursor-pointer" href="#"> <div class="pl-md"> <div class="grid gap-sm bg-card text-card-foreground rounded-md shadow-sm overflow-clip"> <figure class="aspect-landscape rounded-t-md overflow-clip"> <img class="size-full object-cover hover:scale-105 hover:opacity-75 transition" width="300" height="200" loading="lazy" src="https://images.unsplash.com/photo-1511735111819-9a3f7709049c?auto=format&fit=crop&w=300&h=200&q=60" alt="Elephants crossing a dusty riverbed" /> </figure> <div class="grid gap-sm px-sm pb-sm"> <header class="grid gap-xs"> <div class="flex items-center gap-xs"> <span class="badge" data-variant="muted">Research</span> </div> <hgroup class="grid gap-xs"> <h3 class="text-lg font-heading line-clamp-1">Migration patterns decoded</h3> <p class="text-sm text-muted-foreground line-clamp-2"> Satellite collars reveal ancient routes herds still follow each dry season across the floodplain. </p> </hgroup> </header> <footer class="grid justify-end gap-sm pb-xs pt-sm border-t"> <div class="button" data-variant="link"> See the data <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <polyline points="144 56 216 128 144 200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </div> </footer> </div> </div> </div> </a> <!-- Card END --> <!-- Card START --> <a data-embla="slide" class="basis-full @xs:basis-1/2 @sm:basis-1/3 @md:basis-1/4 cursor-pointer" href="#"> <div class="pl-md"> <div class="grid gap-sm bg-card text-card-foreground rounded-md shadow-sm overflow-clip"> <figure class="aspect-landscape rounded-t-md overflow-clip"> <img class="size-full object-cover hover:scale-105 hover:opacity-75 transition" width="300" height="200" loading="lazy" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&fit=crop&w=300&h=200&q=60" alt="Misty highland valley at sunrise" /> </figure> <div class="grid gap-sm px-sm pb-sm"> <header class="grid gap-xs"> <div class="flex items-center gap-xs"> <span class="badge" data-variant="muted">Conservation</span> </div> <hgroup class="grid gap-xs"> <h3 class="text-lg font-heading line-clamp-1">Rewilding the highlands</h3> <p class="text-sm text-muted-foreground line-clamp-2"> Community nurseries are planting native grasses and bringing endemic songbirds back to the ridgeline. </p> </hgroup> </header> <footer class="grid justify-end gap-sm pb-xs pt-sm border-t"> <div class="button" data-variant="link"> Get started <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <line x1="40" y1="128" x2="216" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <polyline points="144 56 216 128 144 200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </div> </footer> </div> </div> </div> </a> <!-- Card END --> </div> </div></embla-carousel>// carousel.js"use strict";/** * @fileoverview `<embla-carousel>` — HTML web component for Embla carousels. * @description Light-DOM custom element that wraps standard carousel markup * and owns the Embla lifecycle: it initializes on connect (via * `EmblaInit.initRoot`) and destroys its instances on disconnect, so * dynamically inserted or SPA-swapped carousels need no manual wiring. * * The element *is* the carousel root — it applies `data-embla="root"` to * itself on connect, so all existing CSS hooks and `data-embla-*` * configuration attributes work unchanged (see embla.js for the full * attribute reference). No shadow DOM; children are regular markup. * * Carousels inside a closed `<dialog>` defer initialization until the dialog * first opens (a closed dialog is `display: none`, so Embla cannot measure * the viewport). * * Load order: Embla CDN bundles → utils.js → embla.js → carousel.js. * * @example * <embla-carousel data-embla-loop="true"> * <div data-embla="viewport"> * <div data-embla="container"> * <div data-embla="slide">Slide 1</div> * <div data-embla="slide">Slide 2</div> * </div> * </div> * <button type="button" data-embla="prev">Prev</button> * <button type="button" data-embla="next">Next</button> * </embla-carousel> */import { EmblaInit } from "./embla.js";class EmblaCarouselElement extends HTMLElement { /** @type {MutationObserver|null} */ #dialogObserver = null; connectedCallback() { // The element is the carousel root — expose the CSS/config hook. this.setAttribute("data-embla", "root"); const dialog = this.closest("dialog"); if (dialog && !dialog.open) { // Closed dialogs are display:none — Embla can't measure the viewport. // Initialize on the dialog's first open instead. this.#dialogObserver = new MutationObserver(() => { if (dialog.open) this.init(); }); this.#dialogObserver.observe(dialog, { attributes: true, attributeFilter: ["open"] }); return; } this.init(); } disconnectedCallback() { this.#dialogObserver?.disconnect(); this.#dialogObserver = null; // Abort first so the per-carousel DOM listeners (prev/next, dots, thumbs, // drag-click suppression) are removed before the Embla instances are torn down. this._emblaController?.abort(); delete this._emblaController; this._emblaApi?.destroy(); this._emblaApiThumb?.destroy(); delete this._emblaApi; delete this._emblaApiThumb; // Allow re-initialization if the element is re-inserted. this.removeAttribute("data-embla-init"); } /** * @description Initializes the carousel. Idempotent — already-initialized * roots and roots inside closed dialogs are skipped by `initRoot`. * * @returns {void} */ init() { EmblaInit.initRoot(this); } /** * @returns {EmblaCarouselType|null} The Embla API, or null before initialization. */ get api() { return this._emblaApi ?? null; }}// Register the element (guarded against double script loads)if (typeof window !== "undefined" && !customElements.get("embla-carousel")) { customElements.define("embla-carousel", EmblaCarouselElement);}// Attach to window so embla.js's lightbox sync can feature-detect the element type,// and export for module consumers (lightbox.js imports it via the main.js bundle).if (typeof window !== "undefined") { window.EmblaCarouselElement = EmblaCarouselElement;}export { EmblaCarouselElement };Anatomy
Mark the structural roles with data-embla. Each slide sets its own responsive width with
basis-* utilities, so the same markup shows one slide on a phone
and several on a wide screen.
<embla-carousel data-embla-loop="true">
<div data-embla="viewport">
<div data-embla="container">
<div data-embla="slide" class="basis-full @sm:basis-1/2 @md:basis-1/3">Slide 1</div>
<div data-embla="slide" class="basis-full @sm:basis-1/2 @md:basis-1/3">Slide 2</div>
<div data-embla="slide" class="basis-full @sm:basis-1/2 @md:basis-1/3">Slide 3</div>
</div>
</div>
<button type="button" data-embla="prev">Prev</button>
<button type="button" data-embla="next">Next</button>
</embla-carousel>data-embla | Role |
|---|---|
viewport | The visible window (required) |
container | The flex track of slides |
slide | Each slide; set its width with basis-* |
prev / next | Navigation buttons (optional) |
dots / dot | Dot pagination container and template (optional) |
thumbs | Linked thumbnail carousel container (optional) |
Setup
The docs preview loads the scripts for you. To use a carousel in your own page, include the Embla CDN bundles, the shared utilities, and the init script:
<script src="./zazz/scripts/utils.js"></script>
<script src="https://unpkg.com/embla-carousel/embla-carousel.umd.js"></script>
<!-- Optional plugins -->
<script src="https://unpkg.com/embla-carousel-autoplay/embla-carousel-autoplay.umd.js"></script>
<script src="https://unpkg.com/embla-carousel-auto-scroll/embla-carousel-auto-scroll.umd.js"></script>
<script src="https://unpkg.com/embla-carousel-class-names/embla-carousel-class-names.umd.js"></script>
<script src="./zazz/scripts/embla.js"></script>Carousels auto-initialize on DOMContentLoaded.
Options
Every Embla core option goes on <embla-carousel> as a data-embla-* attribute. Names convert from
kebab-case to camelCase and values are type-coerced, so data-embla-loop="true" becomes
{ loop: true }.
<embla-carousel data-embla-loop="true" data-embla-align="start">…</embla-carousel>| Attribute | Effect |
|---|---|
data-embla-loop | "true" wraps around at the ends |
data-embla-align | start, center, or end |
data-embla-keyboard | "false" opts out of arrow keys |
Plugins
Enable a plugin with a bare attribute and configure it with prefixed options. Each plugin needs its CDN bundle from the setup above.
<!-- autoplay, advancing every 3 seconds -->
<embla-carousel data-embla-autoplay data-embla-autoplay-delay="3000">…</embla-carousel>| Plugin | Attribute prefix | CDN bundle |
|---|---|---|
| Autoplay | data-embla-autoplay-* | embla-carousel-autoplay |
| Auto Scroll | data-embla-autoscroll-* | embla-carousel-auto-scroll |
| Class Names | data-embla-classnames-* | embla-carousel-class-names |
Dot navigation
Add a dots container with a single template dot. The script clones the template once per slide
and tracks the active one with .is-active.
<div data-embla="dots">
<button type="button" data-embla="dot"></button>
</div>Thumbnail navigation
Nest a data-embla="thumbs" container inside the same <embla-carousel> to get a synced secondary
carousel. Thumb options take the data-embla-thumbs-* prefix and default to
containScroll: "keepSnaps" and dragFree: true.
<embla-carousel>
<div data-embla="viewport">
<div data-embla="container">
<div data-embla="slide">Full image 1</div>
<div data-embla="slide">Full image 2</div>
</div>
</div>
<div data-embla="thumbs">
<div data-embla="viewport">
<div data-embla="container">
<div data-embla="slide">Thumb 1</div>
<div data-embla="slide">Thumb 2</div>
</div>
</div>
</div>
</embla-carousel>Keyboard navigation
Arrow keys (← / →) scroll the active carousel: one inside an open <dialog>, or the one holding
the focused element. Keys are ignored when focus sits in a form field or contenteditable region. Turn
it off per carousel with data-embla-keyboard="false".
Dialog and lightbox
A carousel can't measure its viewport until its <dialog> is open, so Zazz defers it: a
MutationObserver watches each dialog's open attribute and initializes any carousel inside once it
opens. Set data-embla-start on the trigger to open at a specific slide.
<button commandfor="gallery-dialog" data-embla-start="2">Open at slide 3</button>The lightbox builds on this: the same Embla roles plus a fullscreen
<dialog> and a thumb strip.
When slides are clickable (a lightbox trigger with commandfor, say), a drag is told apart from a
click, and clicks are suppressed for a short window after a drag so a swipe never fires an accidental
tap.
Re-initialization
After an SPA-style navigation or any dynamic content insertion, re-scan for new carousels:
EmblaInit.init(newContentElement);The navigation script calls this for you after swapping
<main>. init() skips carousels it has already wired (data-embla-init) and defers any inside a
closed dialog.
The lower-level data-embla="root" wrapper still works for hand-built Embla markup, but prefer
<embla-carousel> for components and any markup that may be inserted or removed at runtime.