Carousel
Draggable, looping carousels built with Embla and the ui-carousel custom element.
<ui-carousel> initializes an Embla carousel with markup-driven options, drag interactions, looping, keyboard navigation, and thumbnail synchronization.
Default
<ui-carousel data-carousel-plugins="class-names"> <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="ui-button" data-size="icon" data-variant="muted" data-slot="carousel-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="ui-button" data-size="icon" data-variant="muted" data-slot="carousel-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-slot="carousel-viewport" class="overflow-visible"> <div data-slot="carousel-container" class="-ml-md"> <!-- Card START --> <a data-slot="carousel-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-102 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="ui-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="ui-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-slot="carousel-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-102 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="ui-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="ui-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-slot="carousel-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-102 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="ui-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="ui-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-slot="carousel-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-102 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="ui-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="ui-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></ui-carousel>// primitives/carousel/carousel.js"use strict";/** * @fileoverview `<ui-carousel>` — HTML web component for 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 — CSS and `embla.js` target * `:is(ui-carousel, .ui-carousel)` directly, and `data-carousel-*` * configuration attributes are read off the element (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: the module graph resolves it — `index.js` imports embla.js * (which imports the Embla packages via the page's import map) before this file. * * @example * <ui-carousel data-carousel-loop="true"> * <div data-slot="carousel-viewport"> * <div data-slot="carousel-container"> * <div data-slot="carousel-slide">Slide 1</div> * <div data-slot="carousel-slide">Slide 2</div> * </div> * </div> * <button type="button" data-slot="carousel-prev">Prev</button> * <button type="button" data-slot="carousel-next">Next</button> * </ui-carousel> */import { EmblaInit } from "../../base/embla.js";import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";class UiCarouselElement extends ZazzElement { setup(signal) { 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 open instead (zazz:dialog-open, ADR-0003). dialog.addEventListener("zazz:dialog-open", () => this.init(), { signal }); return; } this.init(); } teardown() { // 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-carousel-init"); } /** * @description Initializes the carousel. Idempotent — already-initialized * roots and roots inside closed dialogs are skipped by `initRoot`. */ init() { EmblaInit.initRoot(this); } /** * @returns The Embla API, or null before initialization. */ get api() { return this._emblaApi ?? null; }}defineZazzElement("ui-carousel", UiCarouselElement);export { UiCarouselElement };/** * carousel.css — Embla carousel (ui-carousel | .ui-carousel + data-slot="carousel-*" hooks) * * @layer variables, components * @requires layers.css, _variables.css * @uses data-carousel attributes — layout hooks only; behaviour from zazz/scripts/embla.js * @uses container-type on viewport — basis-* / @sm:basis-* utils query [data-slot~="carousel-viewport"] width * @tokens --ui-carousel-* (@layer variables) * @consumedby lightbox.css */@layer variables { :root { --ui-carousel-slide-basis: calc(100% - var(--_gap, 0px)); --ui-carousel-slide-min-width: 0; --ui-carousel-touch-action: pan-y pinch-zoom; --ui-carousel-cursor--grabbing: grabbing; }}/* =========================================================================== EMBLA CAROUSEL STYLES - Documentation: https://www.embla-carousel.com/ =========================================================================== */@layer zazz.components { /* <ui-carousel> is a custom element (inline by default) — give it block flow like a div; the class form inherits div flow already. */ ui-carousel { display: block; } :is(body:has([data-slot~="carousel-viewport"].is-dragging) *) { cursor: var(--ui-carousel-cursor--grabbing); } :where(ui-carousel, .ui-carousel) { position: relative; isolation: isolate; } [data-slot~="carousel-viewport"] { overflow: clip; max-block-size: 100%; max-inline-size: 100%; } [data-slot~="carousel-container"] { display: flex; gap: var(--_gap, 0); touch-action: var(--ui-carousel-touch-action); } [data-slot~="carousel-slide"] { flex: 0 0 var(--ui-carousel-slide-basis); min-width: var(--ui-carousel-slide-min-width); } [data-slot~="carousel-thumbs"] [data-slot~="carousel-slide"] { flex: 0 0 auto; }}Anatomy
The root is the <ui-carousel> element itself; interior parts are marked with data-slot="carousel-{part}". Set slide widths with basis-* utilities:
<ui-carousel data-carousel-loop="true">
<div data-slot="carousel-viewport">
<div data-slot="carousel-container">
<div data-slot="carousel-slide" class="basis-full @sm:basis-1/2 @md:basis-1/3">Slide 1</div>
<div data-slot="carousel-slide" class="basis-full @sm:basis-1/2 @md:basis-1/3">Slide 2</div>
<div data-slot="carousel-slide" class="basis-full @sm:basis-1/2 @md:basis-1/3">Slide 3</div>
</div>
</div>
<button type="button" data-slot="carousel-prev">Prev</button>
<button type="button" data-slot="carousel-next">Next</button>
</ui-carousel>data-slot value | Purpose |
|---|---|
carousel-viewport | Visible window (required) |
carousel-container | Flex track for slides |
carousel-slide | Individual slide item |
carousel-prev / carousel-next | Navigation buttons (optional) |
carousel-dots / carousel-dot | Pagination container and template dot (optional) |
carousel-thumbs | Synchronized thumbnail container (optional) |
data-slot is a space-separated token list, so an element can serve two primitives at once: a lightbox slide is also a carousel slide (data-slot="lightbox-slide carousel-slide"). Selectors match slots with [data-slot~="carousel-slide"].
Setup
Load index.js as an ES module:
<script type="module" src="./zazz/index.js"></script>Third-party dependencies (Embla core and plugins) resolve through the <script type="importmap"> in <head>.
Options
Configure Embla options as data-carousel-* attributes on <ui-carousel>:
| Attribute | Purpose |
|---|---|
data-carousel-loop | Set to "true" for infinite loop |
data-carousel-align | start, center, or end |
data-carousel-keyboard | Set to "false" to disable keys |
Plugins
Enable plugins with a space-separated data-carousel-plugins list. Configure each plugin with its option prefix; setting options alone will not load the plugin.
<ui-carousel data-carousel-plugins="autoplay" data-carousel-autoplay-delay="3000">
...
</ui-carousel>| Plugin | Slug | Options prefix | Package specifier |
|---|---|---|---|
| Autoplay | autoplay | data-carousel-autoplay-* | embla-carousel-autoplay |
| Auto Scroll | auto-scroll | data-carousel-auto-scroll-* | embla-carousel-auto-scroll |
| Class Names | class-names | data-carousel-class-names-* | embla-carousel-class-names |
Dots pagination
<div data-slot="carousel-dots">
<button type="button" data-slot="carousel-dot"></button>
</div>The script clones the template button for each slide and toggles .is-active.
Thumbnail navigation
Nest a data-slot="carousel-thumbs" container inside <ui-carousel> for synchronized thumbnail strips.
Dialog integration
Carousels inside closed <dialog> elements defer initialization until the modal opens. Add data-carousel-start="2" to the trigger button to open at an exact slide.
Dynamic re-initialization
After inserting new HTML into the page:
EmblaInit.init(containerElement);