Appearance
Embla — live demo
This is the real @rozie-ui/embla-vue package running on this page (VitePress is itself a Vue app). Drag the carousel, use the prev/next buttons, jump to a dot — then watch the bound index update. Everything below is driven by the same Carousel.rozie source that compiles to all six frameworks.
The snap index is two-way bound with v-model:selectedIndex — the readout updates live as you drag or scroll, and the buttons drive the imperative handle (scrollPrev, scrollNext, scrollToIndex). Dragging the carousel writes the new index back through the model; clicking a dot writes the index in and scrolls the carousel — round-trip, echo-guarded. See the full API for the complete prop/event/handle surface.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
Carousel.rozie — cross-framework port of Embla Carousel (v8).
One source → idiomatic React / Vue / Svelte / Angular / Solid / Lit carousels,
each a working Embla v8 instance. Fills a genuine cross-framework gap: Lit has
no Embla wrapper at all, Angular's is a single-maintainer community package, and
the four official wrappers are four divergent APIs (a hook vs a composable vs an
action vs a Solid primitive).
This is the SortableList pattern, SIMPLIFIED. Like SortableList we own the host,
render consumer children inside, attach the vanilla engine to a $refs in
$onMount, $watch→reconcile, two-way bind, and $expose the imperative surface.
Unlike SortableList, Embla only READS + transforms slide DOM (it sets
`transform: translate3d(...)` on the container it owns) — it never reorders the
framework's children. So NO r-external, NO $reconcileAfterDomMutation, NO
DOM-restore helper, NO $snapshot, NO colocated bridge.
Two slide-source modes from one component:
(a) config array: <Carousel :slides="items" r-model:selectedIndex="idx" />
(b) declarative: <Carousel><div class="rozie-embla__slide">…</div></Carousel>
Embla's native `watchSlides: true` self-reInits on default-slot child add/remove;
a $watch on $props.slides.length is the config-array backstop.
Consumer example (Vue):
<Carousel
:slides="['A', 'B', 'C']"
v-model:selectedIndex="idx"
:loop="true"
@select="(i) => console.log('snap', i)"
/>
<button @click="carousel.scrollNext()">Next</button>
-->
<rozie name="Carousel">
<props>
{
// Slide data for config-array mode (mode a). Optional — the default <slot/> is
// mode b (consumer drops slide DOM directly).
slides: {
type: Array,
default: () => [],
docs: {
description:
'Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.',
example: "<Carousel :slides=\"['A', 'B', 'C']\" r-model:selectedIndex=\"idx\" />",
},
},
// Runtime-updatable Embla options — each is $watch'd → embla.reInit() (Embla has
// no per-option setter; reInit is the only update path).
loop: {
type: Boolean,
default: false,
docs: {
description:
'Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.',
},
},
align: {
type: String,
default: 'center',
docs: {
description:
"Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.",
},
},
axis: {
type: String,
default: 'x',
docs: {
description:
"Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.",
},
},
slidesToScroll: {
type: Number,
default: 1,
docs: {
description:
'Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.',
},
},
dragFree: {
type: Boolean,
default: false,
docs: {
description:
'Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.',
},
},
// `draggable` → Embla `watchDrag` (Vue-clarity rename; false disables pointer drag).
draggable: {
type: Boolean,
default: true,
docs: {
description:
'Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.',
},
},
containScroll: {
type: String,
default: 'trimSnaps',
docs: {
description:
"Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.",
},
},
startIndex: {
type: Number,
default: 0,
docs: {
description:
'Initial snap index the carousel starts at (the Embla `startIndex` option). Runtime-updatable.',
},
},
skipSnaps: {
type: Boolean,
default: false,
docs: {
description:
'Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.',
},
},
duration: {
type: Number,
default: 25,
docs: {
description:
"Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.",
},
},
direction: {
type: String,
default: 'ltr',
docs: {
description:
"Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.",
},
},
// Autoplay plugin toggle + delay.
autoplay: {
type: Boolean,
default: false,
docs: {
description:
'Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.',
},
},
autoplayDelay: {
type: Number,
default: 4000,
docs: {
description:
'Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.',
},
},
// ─── built-in navigation UI (all opt-in, default off) ───────────────────────
// dot pagination (one dot per scroll snap; the selected snap is highlighted;
// clicking a dot scrolls to it).
dots: {
type: Boolean,
default: false,
docs: {
description:
'Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.',
},
},
// prev/next arrow buttons overlaid on the viewport (disabled at the ends unless
// `loop`).
arrows: {
type: Boolean,
default: false,
docs: {
description:
'Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.',
},
},
// a synced thumbnail strip below the carousel — its OWN Embla instance, one
// thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom
// thumb content (falls back to the slide value). Clicking a thumb scrolls the
// main carousel; the main selection highlights + scrolls the active thumb.
thumbnails: {
type: Boolean,
default: false,
docs: {
description:
'Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.',
},
},
// Escape hatches: extra Embla plugins appended verbatim; raw EmblaOptionsType
// spread last (the SortableList ...$props.options precedent).
plugins: {
type: Array,
default: () => [],
docs: {
description:
'Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.',
},
},
options: {
type: Object,
default: () => ({}),
docs: {
description:
'Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.',
},
},
// Two-way: the current scroll snap. DISTINCT from the `select` emit (the
// MapLibre model⇄emit rule — a model prop must NOT share a name with an emit or
// the continuous emit drops on Vue/Angular). Read $props.selectedIndex, write
// $model.selectedIndex.
selectedIndex: {
type: Number,
default: 0,
model: true,
docs: {
description:
'The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.',
example: '<Carousel :slides="items" r-model:selectedIndex="idx" />',
},
},
}
</props>
<data>
{
// reactive nav state — driven from the engine on select/reInit by syncNav() so
// the built-in dots/arrows render reactively across all six targets.
// snaps: one entry per scroll snap (an index array → one dot per snap).
// selected: the current snap index (highlights the active dot/thumb).
// canPrev / canNext: arrow enabled state (false at the ends unless `loop`).
snaps: [],
selected: 0,
canPrev: false,
canNext: false,
}
</data>
<script>
import EmblaCarousel from 'embla-carousel'
import Autoplay from 'embla-carousel-autoplay'
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
let embla = null
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
let emblaThumbs = null
// Stable key for config-array slides — prefer an object id, fall back to value/index.
const keyFor = (slide, i) => {
if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i
return slide ?? i
}
// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
const emblaOptionsFromProps = () => {
let opts = null
opts = {
loop: $props.loop,
align: $props.align,
axis: $props.axis,
slidesToScroll: $props.slidesToScroll,
dragFree: $props.dragFree,
watchDrag: $props.draggable,
containScroll: $props.containScroll,
startIndex: $props.startIndex,
skipSnaps: $props.skipSnaps,
duration: $props.duration,
direction: $props.direction,
...$props.options,
}
return opts
}
// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
const emblaPluginsFromProps = () => {
const builtins = $props.autoplay ? [Autoplay({ delay: $props.autoplayDelay })] : []
return [...builtins, ...$props.plugins]
}
// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as emblaOptionsFromProps (axis is a `string`).
const thumbsOptionsFromProps = () => {
let opts = null
opts = { containScroll: 'keepSnaps', dragFree: true, axis: $props.axis }
return opts
}
// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
const syncNav = () => {
if (!embla) return
const i = embla.selectedScrollSnap()
$data.snaps = embla.scrollSnapList().map((_, n) => n)
$data.selected = i
$data.canPrev = embla.canScrollPrev()
$data.canNext = embla.canScrollNext()
if (emblaThumbs) emblaThumbs.scrollTo(i)
}
// Internal nav handlers for the built-in arrows/dots/thumbs. These call the
// `any`-typed engine directly (NOT the $expose verbs scrollPrev/scrollNext/
// scrollToIndex, whose strict emitted signatures have a REQUIRED jump/index arg —
// invoking them arg-light from the template would trip TS2554 on the leaves).
const navPrev = () => { if (embla) embla.scrollPrev() }
const navNext = () => { if (embla) embla.scrollNext() }
const navTo = (i) => { if (embla) embla.scrollTo(i) }
// Thumb click → scroll the MAIN carousel. Guarded by the thumb engine's
// clickAllowed() so a drag of the strip doesn't register as a click (the Embla
// thumbs idiom).
const selectThumb = (i) => {
if (emblaThumbs && !emblaThumbs.clickAllowed()) return
navTo(i)
}
$onMount(() => {
embla = EmblaCarousel($refs.viewportEl, emblaOptionsFromProps(), emblaPluginsFromProps())
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
if ($props.thumbnails && $refs.thumbsViewportEl) {
emblaThumbs = EmblaCarousel($refs.thumbsViewportEl, thumbsOptionsFromProps())
}
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
embla.on('select', () => {
const i = embla.selectedScrollSnap()
$model.selectedIndex = i
$emit('select', i)
syncNav()
})
embla.on('settle', () => $emit('settle'))
embla.on('reInit', () => { $emit('reInit'); syncNav() })
embla.on('pointerDown', () => $emit('pointer-down'))
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
embla.on('resize', () => syncNav())
// seed the nav state immediately (covers the already-laid-out case)…
syncNav()
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
if (typeof requestAnimationFrame === 'function') {
const remeasure = () => { if (embla) embla.reInit(emblaOptionsFromProps(), emblaPluginsFromProps()) }
requestAnimationFrame(() => requestAnimationFrame(remeasure))
setTimeout(remeasure, 0)
}
return () => { embla?.destroy(); emblaThumbs?.destroy() }
})
// consumer → engine (echo-guarded, the MapLibre camera precedent): when the bound
// index changes and differs from the engine's current snap, scroll to it. The
// guard (i !== selectedScrollSnap()) makes the engine→model echo a no-op, so no
// feedback loop.
$watch(() => $props.selectedIndex, (i) => {
if (embla && typeof i === 'number' && i !== embla.selectedScrollSnap()) embla.scrollTo(i)
})
// Reactive options → reInit. Embla has no per-option setter; reInit(opts) is the
// only update path. The getter returns a stable primitive SIGNATURE string so the
// watch fires once per actual change (a fresh options object would fire every tick
// on reference inequality — playbook §4).
$watch(
() => [
$props.loop, $props.align, $props.axis, $props.slidesToScroll, $props.dragFree,
$props.draggable, $props.containScroll, $props.skipSnaps, $props.duration,
$props.direction,
].join('|'),
() => embla?.reInit(emblaOptionsFromProps()),
)
// Toggling autoplay (or its delay, or the plugin array) rebuilds the plugin set —
// reInit takes a second `plugins` arg.
$watch(
() => `${$props.autoplay}|${$props.autoplayDelay}`,
() => embla?.reInit(emblaOptionsFromProps(), emblaPluginsFromProps()),
)
// Config-array slide-count backstop: Embla's native `watchSlides` self-reInits on
// default-slot child add/remove, but the config-array (r-for) path needs an
// explicit length watch so a data-driven slide add/remove recomputes snaps. The
// thumbnail strip iterates the same slides, so reInit it too, then resync nav.
$watch(() => $props.slides.length, () => {
embla?.reInit(emblaOptionsFromProps())
emblaThumbs?.reInit(thumbsOptionsFromProps())
syncNav()
})
// Runtime toggle of `thumbnails`: build/tear down the strip's Embla instance so the
// feature can be switched on/off after mount. $refs is safe to read in a $watch
// CALLBACK (post-mount; only the GETTER is ROZ123-restricted). The r-if mounts/
// unmounts the viewport element in the same tick.
$watch(() => $props.thumbnails, (on) => {
if (on && !emblaThumbs && $refs.thumbsViewportEl) {
emblaThumbs = EmblaCarousel($refs.thumbsViewportEl, thumbsOptionsFromProps())
syncNav()
} else if (!on && emblaThumbs) {
emblaThumbs.destroy()
emblaThumbs = null
}
})
// ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
// 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
// - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
// - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
// setter collision on Lit/Angular; it's a method, the prop is the two-way value).
// - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
// bare `scrollTo` expose verb becomes a public method on the Lit custom-element
// class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
// `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
// resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
// method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
// - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
// React `setPlugins` auto-setter) — the get* getter convention. Returns the
// live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
// - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
// progress bars, lazy-load/in-view dots, and directional transitions — no
// matching prop, emit, or inherited DOM method — clear.
// - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
function scrollNext(jump) { if (embla) embla.scrollNext(jump) }
function scrollPrev(jump) { if (embla) embla.scrollPrev(jump) }
function scrollToIndex(index, jump) { if (embla) embla.scrollTo(index, jump) }
function reInitCarousel(opts) { if (embla) embla.reInit(opts ?? emblaOptionsFromProps(), emblaPluginsFromProps()) }
function canScrollNext() { return embla ? embla.canScrollNext() : false }
function canScrollPrev() { return embla ? embla.canScrollPrev() : false }
function getSelectedIndex() { return embla ? embla.selectedScrollSnap() : 0 }
function scrollSnapList() { return embla ? embla.scrollSnapList() : [] }
function scrollProgress() { return embla ? embla.scrollProgress() : 0 }
function slidesInView() { return embla ? embla.slidesInView() : [] }
function slidesNotInView() { return embla ? embla.slidesNotInView() : [] }
function previousScrollSnap() { return embla ? embla.previousScrollSnap() : 0 }
function getPlugins() { return embla ? embla.plugins() : null }
function getInstance() { return embla }
$expose({
scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev,
getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView,
previousScrollSnap, getPlugins, getInstance,
})
</script>
<template>
<div class="rozie-embla" :class="{ 'rozie-embla--vertical': $props.axis === 'y' }">
<!-- stage = the viewport + the overlaid prev/next arrows (position context so
the absolute arrows align to the VIEWPORT, not the dots/thumbs below). -->
<div class="rozie-embla__stage">
<button
type="button"
class="rozie-embla__arrow rozie-embla__arrow--prev"
r-if="$props.arrows"
:disabled="!$data.canPrev"
@click="navPrev()"
aria-label="Previous slide"
>‹</button>
<div class="rozie-embla__viewport" ref="viewportEl">
<div class="rozie-embla__container">
<!-- (a) config-array mode: render slides from $props.slides.
NOTE the loop var is `item`, NOT `slide` — a `slide`-named r-for
variable shadows the `slide` SLOT (which the Svelte emitter lowers to a
top-level `const slide = $derived(__slideProp ?? snippets?.slide)`), so
inside `{#each slides as slide}` the `{@render slide(...)}` would render
the each-ITEM (a string) instead of the snippet and throw on mount. This
is the slot-name == loop-binding shadow collision (the rete `node`→
`reteNode` lesson) — keep the iteration variable distinct from any slot. -->
<div
r-for="item, i in $props.slides"
:key="keyFor(item, i)"
class="rozie-embla__slide"
>
<slot name="slide" :slide="item" :index="i">{{ item }}</slot>
</div>
<!-- (b) declarative mode: consumer drops slide DOM directly -->
<slot />
</div>
</div>
<button
type="button"
class="rozie-embla__arrow rozie-embla__arrow--next"
r-if="$props.arrows"
:disabled="!$data.canNext"
@click="navNext()"
aria-label="Next slide"
>›</button>
</div>
<!-- dot pagination — one button per scroll snap; the selected snap is active. -->
<div class="rozie-embla__dots" r-if="$props.dots">
<button
r-for="di in $data.snaps"
:key="di"
type="button"
class="rozie-embla__dot"
:class="{ 'is-selected': di === $data.selected }"
@click="navTo(di)"
:aria-label="'Go to slide ' + (di + 1)"
/>
</div>
<!-- synced thumbnail strip — its own Embla instance (thumbsViewportEl), one thumb
per slide. Fill the `thumb` scoped slot for custom content (falls back to the
slide value). Clicking a thumb scrolls the main carousel. -->
<div class="rozie-embla__thumbs" r-if="$props.thumbnails">
<div class="rozie-embla__thumbs-viewport" ref="thumbsViewportEl">
<div class="rozie-embla__thumbs-container">
<div
r-for="item, i in $props.slides"
:key="keyFor(item, i)"
class="rozie-embla__thumb"
:class="{ 'is-selected': i === $data.selected }"
@click="selectThumb(i)"
>
<slot name="thumb" :slide="item" :index="i">{{ item }}</slot>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
.rozie-embla { position: relative; }
.rozie-embla__stage { position: relative; }
.rozie-embla__viewport { overflow: hidden; }
.rozie-embla__container { display: flex; }
.rozie-embla__slide { flex: 0 0 100%; min-width: 0; }
/* Vertical axis: stack slides in a column inside a height-constrained viewport. */
.rozie-embla--vertical .rozie-embla__container { flex-direction: column; height: 100%; }
.rozie-embla--vertical .rozie-embla__slide { flex: 0 0 100%; min-height: 0; }
/* ─── prev/next arrows (opt-in via `arrows`) ──────────────────────────────────
Overlaid on the viewport, vertically centered on the STAGE (so the dots/thumbs
below don't shift them). Consumers restyle via these classes or the :deep hatch. */
.rozie-embla__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: #1a1a1a;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow:hover { background: #fff; }
.rozie-embla__arrow:disabled { opacity: 0.35; cursor: default; }
.rozie-embla__arrow--prev { left: 0.5rem; }
.rozie-embla__arrow--next { right: 0.5rem; }
/* ─── dot pagination (opt-in via `dots`) ──────────────────────────────────────── */
.rozie-embla__dots {
display: flex;
justify-content: center;
gap: 0.4rem;
padding: 0.625rem 0;
}
.rozie-embla__dot {
width: 0.5rem;
height: 0.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot:hover { background: rgba(0, 0, 0, 0.45); }
.rozie-embla__dot.is-selected {
background: #1a1a1a;
transform: scale(1.25);
}
/* ─── synced thumbnail strip (opt-in via `thumbnails`) ────────────────────────── */
.rozie-embla__thumbs { margin-top: 0.5rem; }
.rozie-embla__thumbs-viewport { overflow: hidden; }
.rozie-embla__thumbs-container { display: flex; gap: 0.5rem; }
.rozie-embla__thumb {
flex: 0 0 auto;
cursor: pointer;
opacity: 0.5;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb:hover { opacity: 0.8; }
.rozie-embla__thumb.is-selected {
opacity: 1;
border-color: #1a1a1a;
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/embla-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, rozieAttr, rozieDisplay, useControllableState } from '@rozie/runtime-react';
import './Carousel.css';
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
interface SlideCtx { slide: any; index: any; }
interface ThumbCtx { slide: any; index: any; }
interface CarouselProps {
/**
* Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
* @example
* <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
*/
slides?: any[];
/**
* Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
*/
loop?: boolean;
/**
* Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
*/
align?: string;
/**
* Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
*/
axis?: string;
/**
* Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
*/
slidesToScroll?: number;
/**
* Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
*/
dragFree?: boolean;
/**
* Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
*/
draggable?: boolean;
/**
* Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
*/
containScroll?: string;
/**
* Initial snap index the carousel starts at (the Embla `startIndex` option). Runtime-updatable.
*/
startIndex?: number;
/**
* Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
*/
skipSnaps?: boolean;
/**
* Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
*/
duration?: number;
/**
* Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
*/
direction?: string;
/**
* Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
*/
autoplay?: boolean;
/**
* Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
*/
autoplayDelay?: number;
/**
* Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
*/
dots?: boolean;
/**
* Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
*/
arrows?: boolean;
/**
* Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
*/
thumbnails?: boolean;
/**
* Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
*/
plugins?: any[];
/**
* Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
*/
options?: Record<string, any>;
/**
* The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
* @example
* <Carousel :slides="items" r-model:selectedIndex="idx" />
*/
selectedIndex?: number;
defaultSelectedIndex?: number;
onSelectedIndexChange?: (selectedIndex: number) => void;
onSelect?: (...args: any[]) => void;
onSettle?: (...args: any[]) => void;
onReInit?: (...args: any[]) => void;
onPointerDown?: (...args: any[]) => void;
renderSlide?: (ctx: SlideCtx) => ReactNode;
children?: ReactNode;
renderThumb?: (ctx: ThumbCtx) => ReactNode;
slots?: Record<string, () => import('react').ReactNode>;
}
export interface CarouselHandle {
scrollNext: (...args: any[]) => any;
scrollPrev: (...args: any[]) => any;
scrollToIndex: (...args: any[]) => any;
reInitCarousel: (...args: any[]) => any;
canScrollNext: (...args: any[]) => any;
canScrollPrev: (...args: any[]) => any;
getSelectedIndex: (...args: any[]) => any;
scrollSnapList: (...args: any[]) => any;
scrollProgress: (...args: any[]) => any;
slidesInView: (...args: any[]) => any;
slidesNotInView: (...args: any[]) => any;
previousScrollSnap: (...args: any[]) => any;
getPlugins: (...args: any[]) => any;
getInstance: (...args: any[]) => any;
}
const Carousel = forwardRef<CarouselHandle, CarouselProps>(function Carousel(_props: CarouselProps, ref): JSX.Element {
const __defaultSlides = useState(() => (() => [])())[0];
const __defaultPlugins = useState(() => (() => [])())[0];
const __defaultOptions = useState(() => (() => ({}))())[0];
const props: Omit<CarouselProps, 'slides' | 'loop' | 'align' | 'axis' | 'slidesToScroll' | 'dragFree' | 'draggable' | 'containScroll' | 'startIndex' | 'skipSnaps' | 'duration' | 'direction' | 'autoplay' | 'autoplayDelay' | 'dots' | 'arrows' | 'thumbnails' | 'plugins' | 'options'> & { slides: any[]; loop: boolean; align: string; axis: string; slidesToScroll: number; dragFree: boolean; draggable: boolean; containScroll: string; startIndex: number; skipSnaps: boolean; duration: number; direction: string; autoplay: boolean; autoplayDelay: number; dots: boolean; arrows: boolean; thumbnails: boolean; plugins: any[]; options: Record<string, any> } = {
..._props,
slides: _props.slides ?? __defaultSlides,
loop: _props.loop ?? false,
align: _props.align ?? 'center',
axis: _props.axis ?? 'x',
slidesToScroll: _props.slidesToScroll ?? 1,
dragFree: _props.dragFree ?? false,
draggable: _props.draggable ?? true,
containScroll: _props.containScroll ?? 'trimSnaps',
startIndex: _props.startIndex ?? 0,
skipSnaps: _props.skipSnaps ?? false,
duration: _props.duration ?? 25,
direction: _props.direction ?? 'ltr',
autoplay: _props.autoplay ?? false,
autoplayDelay: _props.autoplayDelay ?? 4000,
dots: _props.dots ?? false,
arrows: _props.arrows ?? false,
thumbnails: _props.thumbnails ?? false,
plugins: _props.plugins ?? __defaultPlugins,
options: _props.options ?? __defaultOptions,
};
const attrs: Record<string, unknown> = (() => {
const { slides, loop, align, axis, slidesToScroll, dragFree, draggable, containScroll, startIndex, skipSnaps, duration, direction, autoplay, autoplayDelay, dots, arrows, thumbnails, plugins, options, selectedIndex, defaultValue, onSelectedIndexChange, defaultSelectedIndex, ...rest } = _props as CarouselProps & Record<string, unknown>;
void slides; void loop; void align; void axis; void slidesToScroll; void dragFree; void draggable; void containScroll; void startIndex; void skipSnaps; void duration; void direction; void autoplay; void autoplayDelay; void dots; void arrows; void thumbnails; void plugins; void options; void selectedIndex; void defaultValue; void onSelectedIndexChange; void defaultSelectedIndex;
return rest;
})();
const embla = useRef<any>(null);
const emblaThumbs = useRef<any>(null);
const [selectedIndex, setSelectedIndex] = useControllableState({
value: props.selectedIndex,
defaultValue: props.defaultSelectedIndex ?? 0,
onValueChange: props.onSelectedIndexChange,
});
const _thumbnailsRef = useRef(props.thumbnails);
_thumbnailsRef.current = props.thumbnails;
const [snaps, setSnaps] = useState<any[]>([]);
const [selected, setSelected] = useState(0);
const [canPrev, setCanPrev] = useState(false);
const [canNext, setCanNext] = useState(false);
const viewportEl = useRef<HTMLDivElement | null>(null);
const thumbsViewportEl = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
function keyFor(slide: any, i: any) {
if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
return slide ?? i;
}
const emblaOptionsFromProps = useCallback(() => {
let opts: any = null;
opts = {
loop: props.loop,
align: props.align,
axis: props.axis,
slidesToScroll: props.slidesToScroll,
dragFree: props.dragFree,
watchDrag: props.draggable,
containScroll: props.containScroll,
startIndex: props.startIndex,
skipSnaps: props.skipSnaps,
duration: props.duration,
direction: props.direction,
...props.options
};
return opts;
}, [props.align, props.axis, props.containScroll, props.direction, props.dragFree, props.draggable, props.duration, props.loop, props.options, props.skipSnaps, props.slidesToScroll, props.startIndex]);
const emblaPluginsFromProps = useCallback(() => {
const builtins = props.autoplay ? [Autoplay({
delay: props.autoplayDelay
})] : [];
return [...builtins, ...props.plugins];
}, [props.autoplay, props.autoplayDelay, props.plugins]);
const thumbsOptionsFromProps = useCallback(() => {
let opts: any = null;
opts = {
containScroll: 'keepSnaps',
dragFree: true,
axis: props.axis
};
return opts;
}, [props.axis]);
const syncNav = useCallback(() => {
if (!embla.current) return;
const i = embla.current.selectedScrollSnap();
setSnaps(embla.current.scrollSnapList().map((_: any, n: any) => n));
setSelected(i);
setCanPrev(embla.current.canScrollPrev());
setCanNext(embla.current.canScrollNext());
if (emblaThumbs.current) emblaThumbs.current.scrollTo(i);
}, []);
const navPrev = useCallback(() => {
if (embla.current) embla.current.scrollPrev();
}, []);
const navNext = useCallback(() => {
if (embla.current) embla.current.scrollNext();
}, []);
const navTo = useCallback((i: any) => {
if (embla.current) embla.current.scrollTo(i);
}, []);
const selectThumb = useCallback((i: any) => {
if (emblaThumbs.current && !emblaThumbs.current.clickAllowed()) return;
navTo(i);
}, [navTo]);
// ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
// 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
// - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
// - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
// setter collision on Lit/Angular; it's a method, the prop is the two-way value).
// - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
// bare `scrollTo` expose verb becomes a public method on the Lit custom-element
// class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
// `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
// resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
// method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
// - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
// React `setPlugins` auto-setter) — the get* getter convention. Returns the
// live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
// - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
// progress bars, lazy-load/in-view dots, and directional transitions — no
// matching prop, emit, or inherited DOM method — clear.
// - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
function scrollNext(jump: any) {
if (embla.current) embla.current.scrollNext(jump);
}
function scrollPrev(jump: any) {
if (embla.current) embla.current.scrollPrev(jump);
}
function scrollToIndex(index: any, jump: any) {
if (embla.current) embla.current.scrollTo(index, jump);
}
function reInitCarousel(opts: any) {
if (embla.current) embla.current.reInit(opts ?? emblaOptionsFromProps(), emblaPluginsFromProps());
}
function canScrollNext() {
return embla.current ? embla.current.canScrollNext() : false;
}
function canScrollPrev() {
return embla.current ? embla.current.canScrollPrev() : false;
}
function getSelectedIndex() {
return embla.current ? embla.current.selectedScrollSnap() : 0;
}
function scrollSnapList() {
return embla.current ? embla.current.scrollSnapList() : [];
}
function scrollProgress() {
return embla.current ? embla.current.scrollProgress() : 0;
}
function slidesInView() {
return embla.current ? embla.current.slidesInView() : [];
}
function slidesNotInView() {
return embla.current ? embla.current.slidesNotInView() : [];
}
function previousScrollSnap() {
return embla.current ? embla.current.previousScrollSnap() : 0;
}
function getPlugins() {
return embla.current ? embla.current.plugins() : null;
}
function getInstance() {
return embla.current;
}
useEffect(() => {
embla.current = EmblaCarousel(viewportEl.current!, emblaOptionsFromProps(), emblaPluginsFromProps());
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
if (_thumbnailsRef.current && thumbsViewportEl.current) {
emblaThumbs.current = EmblaCarousel(thumbsViewportEl.current!, thumbsOptionsFromProps());
}
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
embla.current.on('select', () => {
const i = embla.current.selectedScrollSnap();
setSelectedIndex(i);
props.onSelect && props.onSelect(i);
syncNav();
});
embla.current.on('settle', () => props.onSettle && props.onSettle());
embla.current.on('reInit', () => {
props.onReInit && props.onReInit();
syncNav();
});
embla.current.on('pointerDown', () => props.onPointerDown && props.onPointerDown());
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
embla.current.on('resize', () => syncNav());
// seed the nav state immediately (covers the already-laid-out case)…
syncNav();
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
if (typeof requestAnimationFrame === 'function') {
const remeasure = () => {
if (embla.current) embla.current.reInit(emblaOptionsFromProps(), emblaPluginsFromProps());
};
requestAnimationFrame(() => requestAnimationFrame(remeasure));
setTimeout(remeasure, 0);
}
return () => {
embla.current?.destroy();
emblaThumbs.current?.destroy();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const i = selectedIndex;
if (embla.current && typeof i === 'number' && i !== embla.current.selectedScrollSnap()) embla.current.scrollTo(i);
}, [selectedIndex]);
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
embla.current?.reInit(emblaOptionsFromProps());
}, [props.align, props.axis, props.containScroll, props.direction, props.dragFree, props.draggable, props.duration, props.loop, props.skipSnaps, props.slidesToScroll]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
embla.current?.reInit(emblaOptionsFromProps(), emblaPluginsFromProps());
}, [props.autoplay, props.autoplayDelay]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
embla.current?.reInit(emblaOptionsFromProps());
emblaThumbs.current?.reInit(thumbsOptionsFromProps());
syncNav();
}, [props.slides]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
const on = props.thumbnails;
if (on && !emblaThumbs.current && thumbsViewportEl.current) {
emblaThumbs.current = EmblaCarousel(thumbsViewportEl.current!, thumbsOptionsFromProps());
syncNav();
} else if (!on && emblaThumbs.current) {
emblaThumbs.current.destroy();
emblaThumbs.current = null;
}
}, [props.thumbnails]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev, getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView, previousScrollSnap, getPlugins, getInstance });
_rozieExposeRef.current = { scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev, getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView, previousScrollSnap, getPlugins, getInstance };
useImperativeHandle(ref, () => ({ scrollNext: (...args: Parameters<typeof scrollNext>): ReturnType<typeof scrollNext> => _rozieExposeRef.current.scrollNext(...args), scrollPrev: (...args: Parameters<typeof scrollPrev>): ReturnType<typeof scrollPrev> => _rozieExposeRef.current.scrollPrev(...args), scrollToIndex: (...args: Parameters<typeof scrollToIndex>): ReturnType<typeof scrollToIndex> => _rozieExposeRef.current.scrollToIndex(...args), reInitCarousel: (...args: Parameters<typeof reInitCarousel>): ReturnType<typeof reInitCarousel> => _rozieExposeRef.current.reInitCarousel(...args), canScrollNext: (...args: Parameters<typeof canScrollNext>): ReturnType<typeof canScrollNext> => _rozieExposeRef.current.canScrollNext(...args), canScrollPrev: (...args: Parameters<typeof canScrollPrev>): ReturnType<typeof canScrollPrev> => _rozieExposeRef.current.canScrollPrev(...args), getSelectedIndex: (...args: Parameters<typeof getSelectedIndex>): ReturnType<typeof getSelectedIndex> => _rozieExposeRef.current.getSelectedIndex(...args), scrollSnapList: (...args: Parameters<typeof scrollSnapList>): ReturnType<typeof scrollSnapList> => _rozieExposeRef.current.scrollSnapList(...args), scrollProgress: (...args: Parameters<typeof scrollProgress>): ReturnType<typeof scrollProgress> => _rozieExposeRef.current.scrollProgress(...args), slidesInView: (...args: Parameters<typeof slidesInView>): ReturnType<typeof slidesInView> => _rozieExposeRef.current.slidesInView(...args), slidesNotInView: (...args: Parameters<typeof slidesNotInView>): ReturnType<typeof slidesNotInView> => _rozieExposeRef.current.slidesNotInView(...args), previousScrollSnap: (...args: Parameters<typeof previousScrollSnap>): ReturnType<typeof previousScrollSnap> => _rozieExposeRef.current.previousScrollSnap(...args), getPlugins: (...args: Parameters<typeof getPlugins>): ReturnType<typeof getPlugins> => _rozieExposeRef.current.getPlugins(...args), getInstance: (...args: Parameters<typeof getInstance>): ReturnType<typeof getInstance> => _rozieExposeRef.current.getInstance(...args) }), []);
return (
<>
<div {...attrs} className={clsx(clsx("rozie-embla", { "rozie-embla--vertical": props.axis === 'y' }), (attrs.className as string | undefined))} data-rozie-s-4143c216="">
<div className={"rozie-embla__stage"} data-rozie-s-4143c216="">
{(props.arrows) && <button type="button" className={"rozie-embla__arrow rozie-embla__arrow--prev"} disabled={!canPrev} aria-label="Previous slide" onClick={($event) => { navPrev(); }} data-rozie-s-4143c216="">‹</button>}<div className={"rozie-embla__viewport"} ref={viewportEl} data-rozie-s-4143c216="">
<div className={"rozie-embla__container"} data-rozie-s-4143c216="">
{props.slides.map((item, i) => <div key={keyFor(item, i)} className={"rozie-embla__slide"} data-rozie-s-4143c216="">
{(props.renderSlide ?? props.slots?.['slide']) ? ((props.renderSlide ?? props.slots?.['slide']) as Function)({ slide: item, index: i }) : rozieDisplay(item)}
</div>)}
{(typeof (props.children ?? props.slots?.['']) === 'function' ? ((props.children ?? props.slots?.['']) as Function)() : (props.children ?? props.slots?.['']))}
</div>
</div>
{(props.arrows) && <button type="button" className={"rozie-embla__arrow rozie-embla__arrow--next"} disabled={!canNext} aria-label="Next slide" onClick={($event) => { navNext(); }} data-rozie-s-4143c216="">›</button>}</div>
{(props.dots) && <div className={"rozie-embla__dots"} data-rozie-s-4143c216="">
{snaps.map((di) => <button key={di} type="button" className={clsx("rozie-embla__dot", { "is-selected": di === selected })} aria-label={rozieAttr('Go to slide ' + (di + 1))} onClick={($event) => { navTo(di); }} data-rozie-s-4143c216="" />)}
</div>}{(props.thumbnails) && <div className={"rozie-embla__thumbs"} data-rozie-s-4143c216="">
<div className={"rozie-embla__thumbs-viewport"} ref={thumbsViewportEl} data-rozie-s-4143c216="">
<div className={"rozie-embla__thumbs-container"} data-rozie-s-4143c216="">
{props.slides.map((item, i) => <div key={keyFor(item, i)} className={clsx("rozie-embla__thumb", { "is-selected": i === selected })} onClick={($event) => { selectThumb(i); }} data-rozie-s-4143c216="">
{(props.renderThumb ?? props.slots?.['thumb']) ? ((props.renderThumb ?? props.slots?.['thumb']) as Function)({ slide: item, index: i }) : rozieDisplay(item)}
</div>)}
</div>
</div>
</div>}</div>
</>
);
});
export default Carousel;vue
<template>
<div :class="['rozie-embla', { 'rozie-embla--vertical': props.axis === 'y' }]" v-bind="$attrs">
<div class="rozie-embla__stage">
<button v-if="props.arrows" type="button" class="rozie-embla__arrow rozie-embla__arrow--prev" :disabled="!canPrev" aria-label="Previous slide" @click="navPrev()">‹</button><div class="rozie-embla__viewport" ref="viewportElRef">
<div class="rozie-embla__container">
<div v-for="(item, i) in props.slides" :key="keyFor(item, i)" class="rozie-embla__slide">
<slot name="slide" :slide="item" :index="i">{{ item }}</slot>
</div>
<slot></slot>
</div>
</div>
<button v-if="props.arrows" type="button" class="rozie-embla__arrow rozie-embla__arrow--next" :disabled="!canNext" aria-label="Next slide" @click="navNext()">›</button></div>
<div v-if="props.dots" class="rozie-embla__dots">
<button v-for="di in snaps" :key="di" type="button" :class="['rozie-embla__dot', { 'is-selected': di === selected }]" :aria-label="'Go to slide ' + (di + 1)" @click="navTo(di)"></button>
</div><div v-if="props.thumbnails" class="rozie-embla__thumbs">
<div class="rozie-embla__thumbs-viewport" ref="thumbsViewportElRef">
<div class="rozie-embla__thumbs-container">
<div v-for="(item, i) in props.slides" :key="keyFor(item, i)" :class="['rozie-embla__thumb', { 'is-selected': i === selected }]" @click="selectThumb(i)">
<slot name="thumb" :slide="item" :index="i">{{ item }}</slot>
</div>
</div>
</div>
</div></div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
* @example
* <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
*/
slides?: any[];
/**
* Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
*/
loop?: boolean;
/**
* Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
*/
align?: string;
/**
* Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
*/
axis?: string;
/**
* Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
*/
slidesToScroll?: number;
/**
* Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
*/
dragFree?: boolean;
/**
* Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
*/
draggable?: boolean;
/**
* Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
*/
containScroll?: string;
/**
* Initial snap index the carousel starts at (the Embla `startIndex` option). Runtime-updatable.
*/
startIndex?: number;
/**
* Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
*/
skipSnaps?: boolean;
/**
* Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
*/
duration?: number;
/**
* Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
*/
direction?: string;
/**
* Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
*/
autoplay?: boolean;
/**
* Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
*/
autoplayDelay?: number;
/**
* Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
*/
dots?: boolean;
/**
* Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
*/
arrows?: boolean;
/**
* Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
*/
thumbnails?: boolean;
/**
* Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
*/
plugins?: any[];
/**
* Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
*/
options?: Record<string, any>;
}>(),
{ slides: () => [], loop: false, align: 'center', axis: 'x', slidesToScroll: 1, dragFree: false, draggable: true, containScroll: 'trimSnaps', startIndex: 0, skipSnaps: false, duration: 25, direction: 'ltr', autoplay: false, autoplayDelay: 4000, dots: false, arrows: false, thumbnails: false, plugins: () => [], options: () => ({}) }
);
/**
* The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
* @example
* <Carousel :slides="items" r-model:selectedIndex="idx" />
*/
const selectedIndex = defineModel<number>('selectedIndex', { default: 0 });
const emit = defineEmits<{
select: [...args: any[]];
settle: [...args: any[]];
reInit: [...args: any[]];
'pointer-down': [...args: any[]];
}>();
defineSlots<{
slide(props: { slide: any; index: any }): any;
default(props: { }): any;
thumb(props: { slide: any; index: any }): any;
}>();
const snaps = ref<any[]>([]);
const selected = ref(0);
const canPrev = ref(false);
const canNext = ref(false);
const viewportElRef = ref<HTMLElement>();
const thumbsViewportElRef = ref<HTMLElement>();
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
let embla: any = null;
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
let emblaThumbs: any = null;
// Stable key for config-array slides — prefer an object id, fall back to value/index.
// Stable key for config-array slides — prefer an object id, fall back to value/index.
const keyFor = (slide: any, i: any) => {
if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
return slide ?? i;
};
// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
const emblaOptionsFromProps = () => {
let opts: any = null;
opts = {
loop: props.loop,
align: props.align,
axis: props.axis,
slidesToScroll: props.slidesToScroll,
dragFree: props.dragFree,
watchDrag: props.draggable,
containScroll: props.containScroll,
startIndex: props.startIndex,
skipSnaps: props.skipSnaps,
duration: props.duration,
direction: props.direction,
...props.options
};
return opts;
};
// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
const emblaPluginsFromProps = () => {
const builtins = props.autoplay ? [Autoplay({
delay: props.autoplayDelay
})] : [];
return [...builtins, ...props.plugins];
};
// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as emblaOptionsFromProps (axis is a `string`).
// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as emblaOptionsFromProps (axis is a `string`).
const thumbsOptionsFromProps = () => {
let opts: any = null;
opts = {
containScroll: 'keepSnaps',
dragFree: true,
axis: props.axis
};
return opts;
};
// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
const syncNav = () => {
if (!embla) return;
const i = embla.selectedScrollSnap();
snaps.value = embla.scrollSnapList().map((_: any, n: any) => n);
selected.value = i;
canPrev.value = embla.canScrollPrev();
canNext.value = embla.canScrollNext();
if (emblaThumbs) emblaThumbs.scrollTo(i);
};
// Internal nav handlers for the built-in arrows/dots/thumbs. These call the
// `any`-typed engine directly (NOT the $expose verbs scrollPrev/scrollNext/
// scrollToIndex, whose strict emitted signatures have a REQUIRED jump/index arg —
// invoking them arg-light from the template would trip TS2554 on the leaves).
// Internal nav handlers for the built-in arrows/dots/thumbs. These call the
// `any`-typed engine directly (NOT the $expose verbs scrollPrev/scrollNext/
// scrollToIndex, whose strict emitted signatures have a REQUIRED jump/index arg —
// invoking them arg-light from the template would trip TS2554 on the leaves).
const navPrev = () => {
if (embla) embla.scrollPrev();
};
const navNext = () => {
if (embla) embla.scrollNext();
};
const navTo = (i: any) => {
if (embla) embla.scrollTo(i);
};
// Thumb click → scroll the MAIN carousel. Guarded by the thumb engine's
// clickAllowed() so a drag of the strip doesn't register as a click (the Embla
// thumbs idiom).
// Thumb click → scroll the MAIN carousel. Guarded by the thumb engine's
// clickAllowed() so a drag of the strip doesn't register as a click (the Embla
// thumbs idiom).
const selectThumb = (i: any) => {
if (emblaThumbs && !emblaThumbs.clickAllowed()) return;
navTo(i);
};
// ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
// 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
// - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
// - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
// setter collision on Lit/Angular; it's a method, the prop is the two-way value).
// - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
// bare `scrollTo` expose verb becomes a public method on the Lit custom-element
// class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
// `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
// resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
// method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
// - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
// React `setPlugins` auto-setter) — the get* getter convention. Returns the
// live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
// - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
// progress bars, lazy-load/in-view dots, and directional transitions — no
// matching prop, emit, or inherited DOM method — clear.
// - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
function scrollNext(jump: any) {
if (embla) embla.scrollNext(jump);
}
function scrollPrev(jump: any) {
if (embla) embla.scrollPrev(jump);
}
function scrollToIndex(index: any, jump: any) {
if (embla) embla.scrollTo(index, jump);
}
function reInitCarousel(opts: any) {
if (embla) embla.reInit(opts ?? emblaOptionsFromProps(), emblaPluginsFromProps());
}
function canScrollNext() {
return embla ? embla.canScrollNext() : false;
}
function canScrollPrev() {
return embla ? embla.canScrollPrev() : false;
}
function getSelectedIndex() {
return embla ? embla.selectedScrollSnap() : 0;
}
function scrollSnapList() {
return embla ? embla.scrollSnapList() : [];
}
function scrollProgress() {
return embla ? embla.scrollProgress() : 0;
}
function slidesInView() {
return embla ? embla.slidesInView() : [];
}
function slidesNotInView() {
return embla ? embla.slidesNotInView() : [];
}
function previousScrollSnap() {
return embla ? embla.previousScrollSnap() : 0;
}
function getPlugins() {
return embla ? embla.plugins() : null;
}
function getInstance() {
return embla;
}
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
embla = EmblaCarousel(viewportElRef.value!, emblaOptionsFromProps(), emblaPluginsFromProps());
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
if (props.thumbnails && thumbsViewportElRef.value) {
emblaThumbs = EmblaCarousel(thumbsViewportElRef.value!, thumbsOptionsFromProps());
}
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
embla.on('select', () => {
const i = embla.selectedScrollSnap();
selectedIndex.value = i;
emit('select', i);
syncNav();
});
embla.on('settle', () => emit('settle'));
embla.on('reInit', () => {
emit('reInit');
syncNav();
});
embla.on('pointerDown', () => emit('pointer-down'));
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
embla.on('resize', () => syncNav());
// seed the nav state immediately (covers the already-laid-out case)…
syncNav();
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
if (typeof requestAnimationFrame === 'function') {
const remeasure = () => {
if (embla) embla.reInit(emblaOptionsFromProps(), emblaPluginsFromProps());
};
requestAnimationFrame(() => requestAnimationFrame(remeasure));
setTimeout(remeasure, 0);
}
_cleanup_0 = () => {
embla?.destroy();
emblaThumbs?.destroy();
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => selectedIndex.value, (i: any) => {
if (embla && typeof i === 'number' && i !== embla.selectedScrollSnap()) embla.scrollTo(i);
});
watch(() => [props.loop, props.align, props.axis, props.slidesToScroll, props.dragFree, props.draggable, props.containScroll, props.skipSnaps, props.duration, props.direction].join('|'), () => embla?.reInit(emblaOptionsFromProps()));
watch(() => `${props.autoplay}|${props.autoplayDelay}`, () => embla?.reInit(emblaOptionsFromProps(), emblaPluginsFromProps()));
watch(() => props.slides.length, () => {
embla?.reInit(emblaOptionsFromProps());
emblaThumbs?.reInit(thumbsOptionsFromProps());
syncNav();
});
watch(() => props.thumbnails, (on: any) => {
if (on && !emblaThumbs && thumbsViewportElRef.value) {
emblaThumbs = EmblaCarousel(thumbsViewportElRef.value!, thumbsOptionsFromProps());
syncNav();
} else if (!on && emblaThumbs) {
emblaThumbs.destroy();
emblaThumbs = null;
}
});
defineExpose({ scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev, getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView, previousScrollSnap, getPlugins, getInstance });
</script>
<style scoped>
.rozie-embla { position: relative; }
.rozie-embla__stage { position: relative; }
.rozie-embla__viewport { overflow: hidden; }
.rozie-embla__container { display: flex; }
.rozie-embla__slide { flex: 0 0 100%; min-width: 0; }
.rozie-embla--vertical .rozie-embla__container { flex-direction: column; height: 100%; }
.rozie-embla--vertical .rozie-embla__slide { flex: 0 0 100%; min-height: 0; }
.rozie-embla__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: #1a1a1a;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow:hover { background: #fff; }
.rozie-embla__arrow:disabled { opacity: 0.35; cursor: default; }
.rozie-embla__arrow--prev { left: 0.5rem; }
.rozie-embla__arrow--next { right: 0.5rem; }
.rozie-embla__dots {
display: flex;
justify-content: center;
gap: 0.4rem;
padding: 0.625rem 0;
}
.rozie-embla__dot {
width: 0.5rem;
height: 0.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot:hover { background: rgba(0, 0, 0, 0.45); }
.rozie-embla__dot.is-selected {
background: #1a1a1a;
transform: scale(1.25);
}
.rozie-embla__thumbs { margin-top: 0.5rem; }
.rozie-embla__thumbs-viewport { overflow: hidden; }
.rozie-embla__thumbs-container { display: flex; gap: 0.5rem; }
.rozie-embla__thumb {
flex: 0 0 auto;
cursor: pointer;
opacity: 0.5;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb:hover { opacity: 0.8; }
.rozie-embla__thumb.is-selected {
opacity: 1;
border-color: #1a1a1a;
}
</style>svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieDisplay } from '@rozie/runtime-svelte';
import type { Snippet } from 'svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
* @example
* <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
*/
slides?: any[];
/**
* Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
*/
loop?: boolean;
/**
* Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
*/
align?: string;
/**
* Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
*/
axis?: string;
/**
* Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
*/
slidesToScroll?: number;
/**
* Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
*/
dragFree?: boolean;
/**
* Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
*/
draggable?: boolean;
/**
* Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
*/
containScroll?: string;
/**
* Initial snap index the carousel starts at (the Embla `startIndex` option). Runtime-updatable.
*/
startIndex?: number;
/**
* Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
*/
skipSnaps?: boolean;
/**
* Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
*/
duration?: number;
/**
* Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
*/
direction?: string;
/**
* Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
*/
autoplay?: boolean;
/**
* Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
*/
autoplayDelay?: number;
/**
* Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
*/
dots?: boolean;
/**
* Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
*/
arrows?: boolean;
/**
* Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
*/
thumbnails?: boolean;
/**
* Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
*/
plugins?: any[];
/**
* Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
*/
options?: any;
/**
* The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
* @example
* <Carousel :slides="items" r-model:selectedIndex="idx" />
*/
selectedIndex?: number;
slide?: Snippet<[{ slide: any; index: any }]>;
children?: Snippet;
thumb?: Snippet<[{ slide: any; index: any }]>;
snippets?: Record<string, any>;
onselect?: (...args: unknown[]) => void;
onsettle?: (...args: unknown[]) => void;
onreinit?: (...args: unknown[]) => void;
onpointerdown?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let __defaultSlides = (() => [])();
let __defaultPlugins = (() => [])();
let __defaultOptions = (() => ({}))();
let {
slides = __defaultSlides,
loop = false,
align = 'center',
axis = 'x',
slidesToScroll = 1,
dragFree = false,
draggable = true,
containScroll = 'trimSnaps',
startIndex = 0,
skipSnaps = false,
duration = 25,
direction = 'ltr',
autoplay = false,
autoplayDelay = 4000,
dots = false,
arrows = false,
thumbnails = false,
plugins = __defaultPlugins,
options = __defaultOptions,
selectedIndex = $bindable(0),
slide: __slideProp,
children: __childrenProp,
thumb: __thumbProp,
snippets,
onselect,
onsettle,
onreinit,
onpointerdown,
...__rozieAttrs
}: Props = $props();
const slide = $derived(__slideProp ?? snippets?.slide);
const children = $derived(__childrenProp ?? snippets?.children);
const thumb = $derived(__thumbProp ?? snippets?.thumb);
let snaps: any[] = $state([]);
let selected = $state(0);
let canPrev = $state(false);
let canNext = $state(false);
let viewportEl = $state<HTMLElement | undefined>(undefined);
let thumbsViewportEl = $state<HTMLElement | undefined>(undefined);
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
let embla: any = null;
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
let emblaThumbs: any = null;
// Stable key for config-array slides — prefer an object id, fall back to value/index.
// Stable key for config-array slides — prefer an object id, fall back to value/index.
const keyFor = (slide: any, i: any) => {
if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
return slide ?? i;
};
// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
const emblaOptionsFromProps = () => {
let opts: any = null;
opts = {
loop: loop,
align: align,
axis: axis,
slidesToScroll: slidesToScroll,
dragFree: dragFree,
watchDrag: draggable,
containScroll: containScroll,
startIndex: startIndex,
skipSnaps: skipSnaps,
duration: duration,
direction: direction,
...options
};
return opts;
};
// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
const emblaPluginsFromProps = () => {
const builtins = autoplay ? [Autoplay({
delay: autoplayDelay
})] : [];
return [...builtins, ...plugins];
};
// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as emblaOptionsFromProps (axis is a `string`).
// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as emblaOptionsFromProps (axis is a `string`).
const thumbsOptionsFromProps = () => {
let opts: any = null;
opts = {
containScroll: 'keepSnaps',
dragFree: true,
axis: axis
};
return opts;
};
// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
const syncNav = () => {
if (!embla) return;
const i = embla.selectedScrollSnap();
snaps = embla.scrollSnapList().map((_: any, n: any) => n);
selected = i;
canPrev = embla.canScrollPrev();
canNext = embla.canScrollNext();
if (emblaThumbs) emblaThumbs.scrollTo(i);
};
// Internal nav handlers for the built-in arrows/dots/thumbs. These call the
// `any`-typed engine directly (NOT the $expose verbs scrollPrev/scrollNext/
// scrollToIndex, whose strict emitted signatures have a REQUIRED jump/index arg —
// invoking them arg-light from the template would trip TS2554 on the leaves).
// Internal nav handlers for the built-in arrows/dots/thumbs. These call the
// `any`-typed engine directly (NOT the $expose verbs scrollPrev/scrollNext/
// scrollToIndex, whose strict emitted signatures have a REQUIRED jump/index arg —
// invoking them arg-light from the template would trip TS2554 on the leaves).
const navPrev = () => {
if (embla) embla.scrollPrev();
};
const navNext = () => {
if (embla) embla.scrollNext();
};
const navTo = (i: any) => {
if (embla) embla.scrollTo(i);
};
// Thumb click → scroll the MAIN carousel. Guarded by the thumb engine's
// clickAllowed() so a drag of the strip doesn't register as a click (the Embla
// thumbs idiom).
// Thumb click → scroll the MAIN carousel. Guarded by the thumb engine's
// clickAllowed() so a drag of the strip doesn't register as a click (the Embla
// thumbs idiom).
const selectThumb = (i: any) => {
if (emblaThumbs && !emblaThumbs.clickAllowed()) return;
navTo(i);
};
// ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
// 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
// - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
// - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
// setter collision on Lit/Angular; it's a method, the prop is the two-way value).
// - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
// bare `scrollTo` expose verb becomes a public method on the Lit custom-element
// class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
// `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
// resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
// method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
// - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
// React `setPlugins` auto-setter) — the get* getter convention. Returns the
// live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
// - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
// progress bars, lazy-load/in-view dots, and directional transitions — no
// matching prop, emit, or inherited DOM method — clear.
// - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
export function scrollNext(jump: any) {
if (embla) embla.scrollNext(jump);
}
export function scrollPrev(jump: any) {
if (embla) embla.scrollPrev(jump);
}
export function scrollToIndex(index: any, jump: any) {
if (embla) embla.scrollTo(index, jump);
}
export function reInitCarousel(opts: any) {
if (embla) embla.reInit(opts ?? emblaOptionsFromProps(), emblaPluginsFromProps());
}
export function canScrollNext() {
return embla ? embla.canScrollNext() : false;
}
export function canScrollPrev() {
return embla ? embla.canScrollPrev() : false;
}
export function getSelectedIndex() {
return embla ? embla.selectedScrollSnap() : 0;
}
export function scrollSnapList() {
return embla ? embla.scrollSnapList() : [];
}
export function scrollProgress() {
return embla ? embla.scrollProgress() : 0;
}
export function slidesInView() {
return embla ? embla.slidesInView() : [];
}
export function slidesNotInView() {
return embla ? embla.slidesNotInView() : [];
}
export function previousScrollSnap() {
return embla ? embla.previousScrollSnap() : 0;
}
export function getPlugins() {
return embla ? embla.plugins() : null;
}
export function getInstance() {
return embla;
}
onMount(() => {
embla = EmblaCarousel(viewportEl!, emblaOptionsFromProps(), emblaPluginsFromProps());
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
if (thumbnails && thumbsViewportEl) {
emblaThumbs = EmblaCarousel(thumbsViewportEl!, thumbsOptionsFromProps());
}
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
embla.on('select', () => {
const i = embla.selectedScrollSnap();
selectedIndex = i;
onselect?.(i);
syncNav();
});
embla.on('settle', () => onsettle?.());
embla.on('reInit', () => {
onreinit?.();
syncNav();
});
embla.on('pointerDown', () => onpointerdown?.());
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
embla.on('resize', () => syncNav());
// seed the nav state immediately (covers the already-laid-out case)…
syncNav();
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
if (typeof requestAnimationFrame === 'function') {
const remeasure = () => {
if (embla) embla.reInit(emblaOptionsFromProps(), emblaPluginsFromProps());
};
requestAnimationFrame(() => requestAnimationFrame(remeasure));
setTimeout(remeasure, 0);
}
return () => {
embla?.destroy();
emblaThumbs?.destroy();
};
});
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => selectedIndex)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((i: any) => {
if (embla && typeof i === 'number' && i !== embla.selectedScrollSnap()) embla.scrollTo(i);
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { (() => [loop, align, axis, slidesToScroll, dragFree, draggable, containScroll, skipSnaps, duration, direction].join('|'))(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } (() => embla?.reInit(emblaOptionsFromProps()))(); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { (() => `${autoplay}|${autoplayDelay}`)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } (() => embla?.reInit(emblaOptionsFromProps(), emblaPluginsFromProps()))(); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { (() => slides.length)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } (() => {
embla?.reInit(emblaOptionsFromProps());
emblaThumbs?.reInit(thumbsOptionsFromProps());
syncNav();
})(); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => thumbnails)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((on: any) => {
if (on && !emblaThumbs && thumbsViewportEl) {
emblaThumbs = EmblaCarousel(thumbsViewportEl!, thumbsOptionsFromProps());
syncNav();
} else if (!on && emblaThumbs) {
emblaThumbs.destroy();
emblaThumbs = null;
}
})(__watchVal); }); });
</script>
<div {...__rozieAttrs} class={["rozie-embla", { 'rozie-embla--vertical': axis === 'y' }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-4143c216><div class="rozie-embla__stage" data-rozie-s-4143c216>{#if arrows}<button type="button" class="rozie-embla__arrow rozie-embla__arrow--prev" disabled={!canPrev} aria-label="Previous slide" onclick={($event) => { navPrev(); }} data-rozie-s-4143c216>‹</button>{/if}<div class="rozie-embla__viewport" bind:this={viewportEl} data-rozie-s-4143c216><div class="rozie-embla__container" data-rozie-s-4143c216>{#each slides as item, i (keyFor(item, i))}<div class="rozie-embla__slide" data-rozie-s-4143c216>{#if slide}{@render slide({ slide: item, index: i })}{:else}{rozieDisplay(item)}{/if}</div>{/each}{@render children?.()}</div></div>{#if arrows}<button type="button" class="rozie-embla__arrow rozie-embla__arrow--next" disabled={!canNext} aria-label="Next slide" onclick={($event) => { navNext(); }} data-rozie-s-4143c216>›</button>{/if}</div>{#if dots}<div class="rozie-embla__dots" data-rozie-s-4143c216>{#each snaps as di (di)}<button type="button" class={["rozie-embla__dot", { 'is-selected': di === selected }]} aria-label={rozieAttr('Go to slide ' + (di + 1))} onclick={($event) => { navTo(di); }} data-rozie-s-4143c216></button>{/each}</div>{/if}{#if thumbnails}<div class="rozie-embla__thumbs" data-rozie-s-4143c216><div class="rozie-embla__thumbs-viewport" bind:this={thumbsViewportEl} data-rozie-s-4143c216><div class="rozie-embla__thumbs-container" data-rozie-s-4143c216>{#each slides as item, i (keyFor(item, i))}<div class={["rozie-embla__thumb", { 'is-selected': i === selected }]} onclick={($event) => { selectThumb(i); }} data-rozie-s-4143c216>{#if thumb}{@render thumb({ slide: item, index: i })}{:else}{rozieDisplay(item)}{/if}</div>{/each}</div></div></div>{/if}</div>
<style>
:global {
.rozie-embla[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__stage[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__container[data-rozie-s-4143c216] { display: flex; }
.rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-width: 0; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__container[data-rozie-s-4143c216] { flex-direction: column; height: 100%; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-height: 0; }
.rozie-embla__arrow[data-rozie-s-4143c216] {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: #1a1a1a;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow[data-rozie-s-4143c216]:hover { background: #fff; }
.rozie-embla__arrow[data-rozie-s-4143c216]:disabled { opacity: 0.35; cursor: default; }
.rozie-embla__arrow--prev[data-rozie-s-4143c216] { left: 0.5rem; }
.rozie-embla__arrow--next[data-rozie-s-4143c216] { right: 0.5rem; }
.rozie-embla__dots[data-rozie-s-4143c216] {
display: flex;
justify-content: center;
gap: 0.4rem;
padding: 0.625rem 0;
}
.rozie-embla__dot[data-rozie-s-4143c216] {
width: 0.5rem;
height: 0.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot[data-rozie-s-4143c216]:hover { background: rgba(0, 0, 0, 0.45); }
.rozie-embla__dot.is-selected[data-rozie-s-4143c216] {
background: #1a1a1a;
transform: scale(1.25);
}
.rozie-embla__thumbs[data-rozie-s-4143c216] { margin-top: 0.5rem; }
.rozie-embla__thumbs-viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__thumbs-container[data-rozie-s-4143c216] { display: flex; gap: 0.5rem; }
.rozie-embla__thumb[data-rozie-s-4143c216] {
flex: 0 0 auto;
cursor: pointer;
opacity: 0.5;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb[data-rozie-s-4143c216]:hover { opacity: 0.8; }
.rozie-embla__thumb.is-selected[data-rozie-s-4143c216] {
opacity: 1;
border-color: #1a1a1a;
}
}
</style>ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
interface SlideCtx {
$implicit: { slide: any; index: any };
slide: any;
index: any;
}
interface DefaultCtx {}
interface ThumbCtx {
$implicit: { slide: any; index: any };
slide: any;
index: any;
}
function __rozieDisplay(v: unknown): string {
if (v == null) return '';
if (typeof v === 'string') return v;
if (typeof v === 'object') {
try {
return JSON.stringify(v, null, 2);
} catch {
// Circular structure or a non-serialisable value (BigInt nested in an
// object). Degrade to a non-throwing form so the wrap never crashes the
// render — that is the entire point of "safe" interpolation (SPEC-1).
return String(v);
}
}
return String(v);
}
function __rozieAttr(v: unknown): string | null {
return v == null ? null : __rozieDisplay(v);
}
@Component({
selector: 'rozie-carousel',
standalone: true,
imports: [NgTemplateOutlet, NgClass],
template: `
<div class="rozie-embla" [ngClass]="{ 'rozie-embla--vertical': axis() === 'y' }" #rozieSpread_0 #rozieListenersTarget_1>
<div class="rozie-embla__stage">
@if (arrows()) {
<button type="button" class="rozie-embla__arrow rozie-embla__arrow--prev" [disabled]="!canPrev()" aria-label="Previous slide" (click)="navPrev()">‹</button>
}<div class="rozie-embla__viewport" #viewportEl>
<div class="rozie-embla__container">
@for (item of slides(); track keyFor(item, i); let i = $index) {
<div class="rozie-embla__slide">
@if ((slideTpl ?? templates()?.['slide'])) {
<ng-container *ngTemplateOutlet="(slideTpl ?? templates()?.['slide']); context: { $implicit: { slide: item, index: i }, slide: item, index: i }" />
} @else {
{{ rozieDisplay(item) }}
}
</div>
}
<ng-container *ngTemplateOutlet="(defaultTpl ?? templates()?.['defaultSlot'])" />
</div>
</div>
@if (arrows()) {
<button type="button" class="rozie-embla__arrow rozie-embla__arrow--next" [disabled]="!canNext()" aria-label="Next slide" (click)="navNext()">›</button>
}</div>
@if (dots()) {
<div class="rozie-embla__dots">
@for (di of snaps(); track di) {
<button type="button" class="rozie-embla__dot" [ngClass]="{ 'is-selected': di === selected() }" [attr.aria-label]="rozieAttr('Go to slide ' + (di + 1))" (click)="navTo(di)"></button>
}
</div>
}@if (thumbnails()) {
<div class="rozie-embla__thumbs">
<div class="rozie-embla__thumbs-viewport" #thumbsViewportEl>
<div class="rozie-embla__thumbs-container">
@for (item of slides(); track keyFor(item, i); let i = $index) {
<div class="rozie-embla__thumb" [ngClass]="{ 'is-selected': i === selected() }" (click)="selectThumb(i)">
@if ((thumbTpl ?? templates()?.['thumb'])) {
<ng-container *ngTemplateOutlet="(thumbTpl ?? templates()?.['thumb']); context: { $implicit: { slide: item, index: i }, slide: item, index: i }" />
} @else {
{{ rozieDisplay(item) }}
}
</div>
}
</div>
</div>
</div>
}</div>
`,
styles: [`
.rozie-embla { position: relative; }
.rozie-embla__stage { position: relative; }
.rozie-embla__viewport { overflow: hidden; }
.rozie-embla__container { display: flex; }
.rozie-embla__slide { flex: 0 0 100%; min-width: 0; }
.rozie-embla--vertical .rozie-embla__container { flex-direction: column; height: 100%; }
.rozie-embla--vertical .rozie-embla__slide { flex: 0 0 100%; min-height: 0; }
.rozie-embla__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: #1a1a1a;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow:hover { background: #fff; }
.rozie-embla__arrow:disabled { opacity: 0.35; cursor: default; }
.rozie-embla__arrow--prev { left: 0.5rem; }
.rozie-embla__arrow--next { right: 0.5rem; }
.rozie-embla__dots {
display: flex;
justify-content: center;
gap: 0.4rem;
padding: 0.625rem 0;
}
.rozie-embla__dot {
width: 0.5rem;
height: 0.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot:hover { background: rgba(0, 0, 0, 0.45); }
.rozie-embla__dot.is-selected {
background: #1a1a1a;
transform: scale(1.25);
}
.rozie-embla__thumbs { margin-top: 0.5rem; }
.rozie-embla__thumbs-viewport { overflow: hidden; }
.rozie-embla__thumbs-container { display: flex; gap: 0.5rem; }
.rozie-embla__thumb {
flex: 0 0 auto;
cursor: pointer;
opacity: 0.5;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb:hover { opacity: 0.8; }
.rozie-embla__thumb.is-selected {
opacity: 1;
border-color: #1a1a1a;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Carousel),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Carousel {
/**
* Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
* @example
* <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
*/
slides = input<any[]>((() => [])());
/**
* Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
*/
loop = input<boolean>(false);
/**
* Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
*/
align = input<string>('center');
/**
* Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
*/
axis = input<string>('x');
/**
* Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
*/
slidesToScroll = input<number>(1);
/**
* Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
*/
dragFree = input<boolean>(false);
/**
* Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
*/
draggable = input<boolean>(true);
/**
* Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
*/
containScroll = input<string>('trimSnaps');
/**
* Initial snap index the carousel starts at (the Embla `startIndex` option). Runtime-updatable.
*/
startIndex = input<number>(0);
/**
* Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
*/
skipSnaps = input<boolean>(false);
/**
* Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
*/
duration = input<number>(25);
/**
* Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
*/
direction = input<string>('ltr');
/**
* Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
*/
autoplay = input<boolean>(false);
/**
* Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
*/
autoplayDelay = input<number>(4000);
/**
* Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
*/
dots = input<boolean>(false);
/**
* Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
*/
arrows = input<boolean>(false);
/**
* Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
*/
thumbnails = input<boolean>(false);
/**
* Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
*/
plugins = input<any[]>((() => [])());
/**
* Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
*/
options = input<Record<string, any>>((() => ({}))());
/**
* The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
* @example
* <Carousel :slides="items" r-model:selectedIndex="idx" />
*/
selectedIndex = model<number>(0);
snaps = signal<any[]>([]);
selected = signal(0);
canPrev = signal(false);
canNext = signal(false);
viewportEl = viewChild<ElementRef<HTMLDivElement>>('viewportEl');
thumbsViewportEl = viewChild<ElementRef<HTMLDivElement>>('thumbsViewportEl');
select = output<unknown>();
settle = output<void>();
reInit = output<void>();
pointerDown = output<void>({ alias: 'pointer-down' });
@ContentChild('slide', { read: TemplateRef }) slideTpl?: TemplateRef<SlideCtx>;
@ContentChild('defaultSlot', { read: TemplateRef }) defaultTpl?: TemplateRef<DefaultCtx>;
@ContentChild('thumb', { read: TemplateRef }) thumbTpl?: TemplateRef<ThumbCtx>;
templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
constructor() {
effect(() => { const __watchVal = (() => this.selectedIndex())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((i: any) => {
if (this.embla && typeof i === 'number' && i !== this.embla.selectedScrollSnap()) this.embla.scrollTo(i);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => [this.loop(), this.align(), this.axis(), this.slidesToScroll(), this.dragFree(), this.draggable(), this.containScroll(), this.skipSnaps(), this.duration(), this.direction()].join('|'))(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } (() => this.embla?.reInit(this.emblaOptionsFromProps()))(); }); });
effect(() => { const __watchVal = (() => `${this.autoplay()}|${this.autoplayDelay()}`)(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } (() => this.embla?.reInit(this.emblaOptionsFromProps(), this.emblaPluginsFromProps()))(); }); });
effect(() => { const __watchVal = (() => this.slides().length)(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } (() => {
this.embla?.reInit(this.emblaOptionsFromProps());
this.emblaThumbs?.reInit(this.thumbsOptionsFromProps());
this.syncNav();
})(); }); });
effect(() => { const __watchVal = (() => this.thumbnails())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((on: any) => {
if (on && !this.emblaThumbs && this.thumbsViewportEl()?.nativeElement) {
this.emblaThumbs = EmblaCarousel(this.thumbsViewportEl()!.nativeElement, this.thumbsOptionsFromProps());
this.syncNav();
} else if (!on && this.emblaThumbs) {
this.emblaThumbs.destroy();
this.emblaThumbs = null;
}
})(__watchVal); }); });
}
ngAfterViewInit() {
this.embla = EmblaCarousel(this.viewportEl()!.nativeElement, this.emblaOptionsFromProps(), this.emblaPluginsFromProps());
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
if (this.thumbnails() && this.thumbsViewportEl()?.nativeElement) {
this.emblaThumbs = EmblaCarousel(this.thumbsViewportEl()!.nativeElement, this.thumbsOptionsFromProps());
}
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
this.embla.on('select', () => {
const i = this.embla.selectedScrollSnap();
this.selectedIndex.set(i), this.__rozieCvaOnChange(i);
this.select.emit(i);
this.syncNav();
});
this.embla.on('settle', () => this.settle.emit());
this.embla.on('reInit', () => {
this.reInit.emit();
this.syncNav();
});
this.embla.on('pointerDown', () => this.pointerDown.emit());
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
this.embla.on('resize', () => this.syncNav());
// seed the nav state immediately (covers the already-laid-out case)…
// seed the nav state immediately (covers the already-laid-out case)…
this.syncNav();
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
if (typeof requestAnimationFrame === 'function') {
const remeasure = () => {
if (this.embla) this.embla.reInit(this.emblaOptionsFromProps(), this.emblaPluginsFromProps());
};
requestAnimationFrame(() => requestAnimationFrame(remeasure));
setTimeout(remeasure, 0);
}
this.__rozieDestroyRef.onDestroy(() => {
this.embla?.destroy();
this.emblaThumbs?.destroy();
});
}
embla: any = null;
emblaThumbs: any = null;
keyFor = (slide: any, i: any) => {
if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
return slide ?? i;
};
emblaOptionsFromProps = () => {
let opts: any = null;
opts = {
loop: this.loop(),
align: this.align(),
axis: this.axis(),
slidesToScroll: this.slidesToScroll(),
dragFree: this.dragFree(),
watchDrag: this.draggable(),
containScroll: this.containScroll(),
startIndex: this.startIndex(),
skipSnaps: this.skipSnaps(),
duration: this.duration(),
direction: this.direction(),
...this.options()
};
return opts;
};
emblaPluginsFromProps = () => {
const builtins = this.autoplay() ? [Autoplay({
delay: this.autoplayDelay()
})] : [];
return [...builtins, ...this.plugins()];
};
thumbsOptionsFromProps = () => {
let opts: any = null;
opts = {
containScroll: 'keepSnaps',
dragFree: true,
axis: this.axis()
};
return opts;
};
syncNav = () => {
if (!this.embla) return;
const i = this.embla.selectedScrollSnap();
this.snaps.set(this.embla.scrollSnapList().map((_: any, n: any) => n));
this.selected.set(i);
this.canPrev.set(this.embla.canScrollPrev());
this.canNext.set(this.embla.canScrollNext());
if (this.emblaThumbs) this.emblaThumbs.scrollTo(i);
};
navPrev = () => {
if (this.embla) this.embla.scrollPrev();
};
navNext = () => {
if (this.embla) this.embla.scrollNext();
};
navTo = (i: any) => {
if (this.embla) this.embla.scrollTo(i);
};
selectThumb = (i: any) => {
if (this.emblaThumbs && !this.emblaThumbs.clickAllowed()) return;
this.navTo(i);
};
scrollNext = (jump: any) => {
if (this.embla) this.embla.scrollNext(jump);
};
scrollPrev = (jump: any) => {
if (this.embla) this.embla.scrollPrev(jump);
};
scrollToIndex = (index: any, jump: any) => {
if (this.embla) this.embla.scrollTo(index, jump);
};
reInitCarousel = (opts: any) => {
if (this.embla) this.embla.reInit(opts ?? this.emblaOptionsFromProps(), this.emblaPluginsFromProps());
};
canScrollNext = () => {
return this.embla ? this.embla.canScrollNext() : false;
};
canScrollPrev = () => {
return this.embla ? this.embla.canScrollPrev() : false;
};
getSelectedIndex = () => {
return this.embla ? this.embla.selectedScrollSnap() : 0;
};
scrollSnapList = () => {
return this.embla ? this.embla.scrollSnapList() : [];
};
scrollProgress = () => {
return this.embla ? this.embla.scrollProgress() : 0;
};
slidesInView = () => {
return this.embla ? this.embla.slidesInView() : [];
};
slidesNotInView = () => {
return this.embla ? this.embla.slidesNotInView() : [];
};
previousScrollSnap = () => {
return this.embla ? this.embla.previousScrollSnap() : 0;
};
getPlugins = () => {
return this.embla ? this.embla.plugins() : null;
};
getInstance = () => {
return this.embla;
};
private __rozieCvaOnChange: (v: number) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: number | null): void {
this.selectedIndex.set(v ?? 0);
}
registerOnChange(fn: (v: number) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
static ngTemplateContextGuard(
_dir: Carousel,
_ctx: unknown,
): _ctx is SlideCtx | DefaultCtx | ThumbCtx {
return true;
}
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
rozieDisplay(v: unknown): string { return __rozieDisplay(v); }
rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}
export default Carousel;tsx
import type { JSX } from 'solid-js';
import { For, Show, children, createEffect, createSignal, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
__rozieInjectStyle('Carousel-4143c216', `.rozie-embla[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__stage[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__container[data-rozie-s-4143c216] { display: flex; }
.rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-width: 0; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__container[data-rozie-s-4143c216] { flex-direction: column; height: 100%; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-height: 0; }
.rozie-embla__arrow[data-rozie-s-4143c216] {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: #1a1a1a;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow[data-rozie-s-4143c216]:hover { background: #fff; }
.rozie-embla__arrow[data-rozie-s-4143c216]:disabled { opacity: 0.35; cursor: default; }
.rozie-embla__arrow--prev[data-rozie-s-4143c216] { left: 0.5rem; }
.rozie-embla__arrow--next[data-rozie-s-4143c216] { right: 0.5rem; }
.rozie-embla__dots[data-rozie-s-4143c216] {
display: flex;
justify-content: center;
gap: 0.4rem;
padding: 0.625rem 0;
}
.rozie-embla__dot[data-rozie-s-4143c216] {
width: 0.5rem;
height: 0.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot[data-rozie-s-4143c216]:hover { background: rgba(0, 0, 0, 0.45); }
.rozie-embla__dot.is-selected[data-rozie-s-4143c216] {
background: #1a1a1a;
transform: scale(1.25);
}
.rozie-embla__thumbs[data-rozie-s-4143c216] { margin-top: 0.5rem; }
.rozie-embla__thumbs-viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__thumbs-container[data-rozie-s-4143c216] { display: flex; gap: 0.5rem; }
.rozie-embla__thumb[data-rozie-s-4143c216] {
flex: 0 0 auto;
cursor: pointer;
opacity: 0.5;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb[data-rozie-s-4143c216]:hover { opacity: 0.8; }
.rozie-embla__thumb.is-selected[data-rozie-s-4143c216] {
opacity: 1;
border-color: #1a1a1a;
}`);
interface SlideSlotCtx { slide: any; index: any; }
interface ThumbSlotCtx { slide: any; index: any; }
interface CarouselProps {
/**
* Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
* @example
* <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
*/
slides?: any[];
/**
* Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
*/
loop?: boolean;
/**
* Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
*/
align?: string;
/**
* Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
*/
axis?: string;
/**
* Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
*/
slidesToScroll?: number;
/**
* Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
*/
dragFree?: boolean;
/**
* Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
*/
draggable?: boolean;
/**
* Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
*/
containScroll?: string;
/**
* Initial snap index the carousel starts at (the Embla `startIndex` option). Runtime-updatable.
*/
startIndex?: number;
/**
* Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
*/
skipSnaps?: boolean;
/**
* Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
*/
duration?: number;
/**
* Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
*/
direction?: string;
/**
* Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
*/
autoplay?: boolean;
/**
* Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
*/
autoplayDelay?: number;
/**
* Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
*/
dots?: boolean;
/**
* Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
*/
arrows?: boolean;
/**
* Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
*/
thumbnails?: boolean;
/**
* Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
*/
plugins?: any[];
/**
* Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
*/
options?: Record<string, any>;
/**
* The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
* @example
* <Carousel :slides="items" r-model:selectedIndex="idx" />
*/
selectedIndex?: number;
defaultSelectedIndex?: number;
onSelectedIndexChange?: (selectedIndex: number) => void;
onSelect?: (...args: unknown[]) => void;
onSettle?: (...args: unknown[]) => void;
onReInit?: (...args: unknown[]) => void;
onPointerDown?: (...args: unknown[]) => void;
slideSlot?: (ctx: SlideSlotCtx) => JSX.Element;
// D-131: default slot resolved via children() at body top
children?: JSX.Element;
thumbSlot?: (ctx: ThumbSlotCtx) => JSX.Element;
slots?: Record<string, (ctx: any) => JSX.Element>;
ref?: (h: CarouselHandle) => void;
}
export interface CarouselHandle {
scrollNext: (...args: any[]) => any;
scrollPrev: (...args: any[]) => any;
scrollToIndex: (...args: any[]) => any;
reInitCarousel: (...args: any[]) => any;
canScrollNext: (...args: any[]) => any;
canScrollPrev: (...args: any[]) => any;
getSelectedIndex: (...args: any[]) => any;
scrollSnapList: (...args: any[]) => any;
scrollProgress: (...args: any[]) => any;
slidesInView: (...args: any[]) => any;
slidesNotInView: (...args: any[]) => any;
previousScrollSnap: (...args: any[]) => any;
getPlugins: (...args: any[]) => any;
getInstance: (...args: any[]) => any;
}
export default function Carousel(_props: CarouselProps): JSX.Element {
const _merged = mergeProps({ slides: (() => [])(), loop: false, align: 'center', axis: 'x', slidesToScroll: 1, dragFree: false, draggable: true, containScroll: 'trimSnaps', startIndex: 0, skipSnaps: false, duration: 25, direction: 'ltr', autoplay: false, autoplayDelay: 4000, dots: false, arrows: false, thumbnails: false, plugins: (() => [])(), options: (() => ({}))() }, _props);
const [local, attrs] = splitProps(_merged, ['slides', 'loop', 'align', 'axis', 'slidesToScroll', 'dragFree', 'draggable', 'containScroll', 'startIndex', 'skipSnaps', 'duration', 'direction', 'autoplay', 'autoplayDelay', 'dots', 'arrows', 'thumbnails', 'plugins', 'options', 'selectedIndex', 'children', 'ref']);
const resolved = children(() => local.children);
onMount(() => { local.ref?.({ scrollNext, scrollPrev, scrollToIndex, reInitCarousel, canScrollNext, canScrollPrev, getSelectedIndex, scrollSnapList, scrollProgress, slidesInView, slidesNotInView, previousScrollSnap, getPlugins, getInstance }); });
const [selectedIndex, setSelectedIndex] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'selectedIndex', 0);
const [snaps, setSnaps] = createSignal<any[]>([]);
const [selected, setSelected] = createSignal(0);
const [canPrev, setCanPrev] = createSignal(false);
const [canNext, setCanNext] = createSignal(false);
onMount(() => {
const _cleanup = (() => {
embla = EmblaCarousel(viewportElRef, emblaOptionsFromProps(), emblaPluginsFromProps());
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
if (local.thumbnails && thumbsViewportElRef) {
emblaThumbs = EmblaCarousel(thumbsViewportElRef, thumbsOptionsFromProps());
}
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
embla.on('select', () => {
const i = embla.selectedScrollSnap();
setSelectedIndex(i);
_props.onSelect?.(i);
syncNav();
});
embla.on('settle', () => _props.onSettle?.());
embla.on('reInit', () => {
_props.onReInit?.();
syncNav();
});
embla.on('pointerDown', () => _props.onPointerDown?.());
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
embla.on('resize', () => syncNav());
// seed the nav state immediately (covers the already-laid-out case)…
syncNav();
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
if (typeof requestAnimationFrame === 'function') {
const remeasure = () => {
if (embla) embla.reInit(emblaOptionsFromProps(), emblaPluginsFromProps());
};
requestAnimationFrame(() => requestAnimationFrame(remeasure));
setTimeout(remeasure, 0);
}
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
embla?.destroy();
emblaThumbs?.destroy();
});
});
createEffect(on(() => (() => selectedIndex())(), (v) => untrack(() => ((i: any) => {
if (embla && typeof i === 'number' && i !== embla.selectedScrollSnap()) embla.scrollTo(i);
})(v)), { defer: true }));
createEffect(on(() => (() => [local.loop, local.align, local.axis, local.slidesToScroll, local.dragFree, local.draggable, local.containScroll, local.skipSnaps, local.duration, local.direction].join('|'))(), (v) => untrack(() => (() => embla?.reInit(emblaOptionsFromProps()))()), { defer: true }));
createEffect(on(() => (() => `${local.autoplay}|${local.autoplayDelay}`)(), (v) => untrack(() => (() => embla?.reInit(emblaOptionsFromProps(), emblaPluginsFromProps()))()), { defer: true }));
createEffect(on(() => (() => local.slides.length)(), (v) => untrack(() => (() => {
embla?.reInit(emblaOptionsFromProps());
emblaThumbs?.reInit(thumbsOptionsFromProps());
syncNav();
})()), { defer: true }));
createEffect(on(() => (() => local.thumbnails)(), (v) => untrack(() => ((on: any) => {
if (on && !emblaThumbs && thumbsViewportElRef) {
emblaThumbs = EmblaCarousel(thumbsViewportElRef, thumbsOptionsFromProps());
syncNav();
} else if (!on && emblaThumbs) {
emblaThumbs.destroy();
emblaThumbs = null;
}
})(v)), { defer: true }));
let viewportElRef: HTMLElement | null = null;
let thumbsViewportElRef: HTMLElement | null = null;
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
let embla: any = null;
// The SECOND Embla instance powering the optional synced thumbnail strip (null
// when `thumbnails` is off). Top-level let for the same hoist reason as `embla`.
let emblaThumbs: any = null;
// Stable key for config-array slides — prefer an object id, fall back to value/index.
function keyFor(slide: any, i: any) {
if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
return slide ?? i;
}
// Map the curated props → an EmblaOptionsType. `draggable` → `watchDrag`. The
// `...$props.options` escape hatch spreads last so a consumer can override anything.
//
// NOTE the null-let return discipline: Embla's EmblaOptionsType narrows the string
// options to literal unions (align→'start'|'center'|'end', axis→'x'|'y', …). The
// untyped `String` props are `string`, which does NOT structurally narrow to those
// unions under strict tsc on the emitted leaves. Building the object into a
// pre-nulled `let` (auto type-neutralized to `any`) launders the literal so the
// engine accepts it — the .rozie-native fix (no codegen type-aid, no lang="ts"),
// the same laundering discipline MapLibre uses for its untyped option object.
function emblaOptionsFromProps() {
let opts: any = null;
opts = {
loop: local.loop,
align: local.align,
axis: local.axis,
slidesToScroll: local.slidesToScroll,
dragFree: local.dragFree,
watchDrag: local.draggable,
containScroll: local.containScroll,
startIndex: local.startIndex,
skipSnaps: local.skipSnaps,
duration: local.duration,
direction: local.direction,
...local.options
};
return opts;
}
// Build the plugin array: gate Autoplay behind the `autoplay` prop, then append
// any consumer-supplied plugins verbatim.
function emblaPluginsFromProps() {
const builtins = local.autoplay ? [Autoplay({
delay: local.autoplayDelay
})] : [];
return [...builtins, ...local.plugins];
}
// Thumbnail-strip Embla options (the canonical Embla "thumbs" config): keep every
// snap reachable + free dragging so the strip scrolls independently of the main
// carousel, sharing the main axis. Built into a pre-nulled let for the same
// literal-union laundering reason as emblaOptionsFromProps (axis is a `string`).
function thumbsOptionsFromProps() {
let opts: any = null;
opts = {
containScroll: 'keepSnaps',
dragFree: true,
axis: local.axis
};
return opts;
}
// Mirror the engine's live nav state into reactive $data so the built-in dots /
// arrows re-render on every snap change. `snaps` is an INDEX array (one entry per
// scroll snap → one dot), so the dot r-for needs no unused loop value. Also keeps
// the thumbnail strip's scroll position in sync with the main selection.
function syncNav() {
if (!embla) return;
const i = embla.selectedScrollSnap();
setSnaps(embla.scrollSnapList().map((_: any, n: any) => n));
setSelected(i);
setCanPrev(embla.canScrollPrev());
setCanNext(embla.canScrollNext());
if (emblaThumbs) emblaThumbs.scrollTo(i);
}
// Internal nav handlers for the built-in arrows/dots/thumbs. These call the
// `any`-typed engine directly (NOT the $expose verbs scrollPrev/scrollNext/
// scrollToIndex, whose strict emitted signatures have a REQUIRED jump/index arg —
// invoking them arg-light from the template would trip TS2554 on the leaves).
function navPrev() {
if (embla) embla.scrollPrev();
}
function navNext() {
if (embla) embla.scrollNext();
}
function navTo(i: any) {
if (embla) embla.scrollTo(i);
}
// Thumb click → scroll the MAIN carousel. Guarded by the thumb engine's
// clickAllowed() so a drag of the strip doesn't register as a click (the Embla
// thumbs idiom).
function selectThumb(i: any) {
if (emblaThumbs && !emblaThumbs.clickAllowed()) return;
navTo(i);
}
// ─── imperative handle (Phase 21 $expose) — collision-suffix discipline ──────
// 14 verbs, each guarding the pre-mount/destroyed `embla = null`.
// - reInitCarousel ≠ the `reInit` emit (ROZ121 expose-verb==emit collision).
// - getSelectedIndex ≠ the `selectedIndex` model prop (ROZ524-class — avoids any
// setter collision on Lit/Angular; it's a method, the prop is the two-way value).
// - scrollToIndex ≠ the inherited DOM/LitElement `HTMLElement.scrollTo(x, y)`. A
// bare `scrollTo` expose verb becomes a public method on the Lit custom-element
// class and its `(index, jump)` signature is INCOMPATIBLE with the inherited
// `Element.scrollTo` overloads (TS2416 → the whole class decorator fails to
// resolve). This is a NEW collision class: expose-verb shadows an inherited DOM
// method on the Lit target. Suffix it (the reInit→reInitCarousel discipline).
// - getPlugins ≠ the `plugins` prop (bare `plugins` collides with the prop + its
// React `setPlugins` auto-setter) — the get* getter convention. Returns the
// live plugin API map (e.g. `getPlugins().autoplay.play()/.stop()`).
// - scrollProgress/slidesInView/slidesNotInView/previousScrollSnap drive custom
// progress bars, lazy-load/in-view dots, and directional transitions — no
// matching prop, emit, or inherited DOM method — clear.
// - scrollNext/scrollPrev/canScrollNext/canScrollPrev/scrollSnapList clear.
function scrollNext(jump: any) {
if (embla) embla.scrollNext(jump);
}
function scrollPrev(jump: any) {
if (embla) embla.scrollPrev(jump);
}
function scrollToIndex(index: any, jump: any) {
if (embla) embla.scrollTo(index, jump);
}
function reInitCarousel(opts: any) {
if (embla) embla.reInit(opts ?? emblaOptionsFromProps(), emblaPluginsFromProps());
}
function canScrollNext() {
return embla ? embla.canScrollNext() : false;
}
function canScrollPrev() {
return embla ? embla.canScrollPrev() : false;
}
function getSelectedIndex() {
return embla ? embla.selectedScrollSnap() : 0;
}
function scrollSnapList() {
return embla ? embla.scrollSnapList() : [];
}
function scrollProgress() {
return embla ? embla.scrollProgress() : 0;
}
function slidesInView() {
return embla ? embla.slidesInView() : [];
}
function slidesNotInView() {
return embla ? embla.slidesNotInView() : [];
}
function previousScrollSnap() {
return embla ? embla.previousScrollSnap() : 0;
}
function getPlugins() {
return embla ? embla.plugins() : null;
}
function getInstance() {
return embla;
}
return (
<>
<div {...attrs} class={"rozie-embla" + " " + rozieClass({ 'rozie-embla--vertical': local.axis === 'y' }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-4143c216="">
<div class={"rozie-embla__stage"} data-rozie-s-4143c216="">
{<Show when={local.arrows}><button type="button" aria-label="Previous slide" class={"rozie-embla__arrow rozie-embla__arrow--prev"} disabled={!canPrev()} onClick={($event) => { navPrev(); }} data-rozie-s-4143c216="">‹</button></Show>}<div class={"rozie-embla__viewport"} ref={(el) => { viewportElRef = el as HTMLElement; }} data-rozie-s-4143c216="">
<div class={"rozie-embla__container"} data-rozie-s-4143c216="">
<For each={local.slides}>{(item, i) => <div class={"rozie-embla__slide"} data-rozie-s-4143c216="">
{(_props.slideSlot ?? _props.slots?.['slide'])?.({ slide: item, index: i() }) ?? rozieDisplay(item)}
</div>}</For>
{resolved()}
</div>
</div>
{<Show when={local.arrows}><button type="button" aria-label="Next slide" class={"rozie-embla__arrow rozie-embla__arrow--next"} disabled={!canNext()} onClick={($event) => { navNext(); }} data-rozie-s-4143c216="">›</button></Show>}</div>
{<Show when={local.dots}><div class={"rozie-embla__dots"} data-rozie-s-4143c216="">
<For each={snaps()}>{(di) => <button type="button" aria-label={rozieAttr('Go to slide ' + (di + 1))} class={"rozie-embla__dot" + " " + rozieClass({ 'is-selected': di === selected() })} onClick={($event) => { navTo(di); }} data-rozie-s-4143c216="" />}</For>
</div></Show>}{<Show when={local.thumbnails}><div class={"rozie-embla__thumbs"} data-rozie-s-4143c216="">
<div class={"rozie-embla__thumbs-viewport"} ref={(el) => { thumbsViewportElRef = el as HTMLElement; }} data-rozie-s-4143c216="">
<div class={"rozie-embla__thumbs-container"} data-rozie-s-4143c216="">
<For each={local.slides}>{(item, i) => <div class={"rozie-embla__thumb" + " " + rozieClass({ 'is-selected': i() === selected() })} onClick={($event) => { selectThumb(i()); }} data-rozie-s-4143c216="">
{(_props.thumbSlot ?? _props.slots?.['thumb'])?.({ slide: item, index: i() }) ?? rozieDisplay(item)}
</div>}</For>
</div>
</div>
</div></Show>}</div>
</>
);
}ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieDisplay, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';
import EmblaCarousel from 'embla-carousel';
import Autoplay from 'embla-carousel-autoplay';
// Top-level null-let (untyped → auto type-neutralized to `any`; React hoists it to
// useRef cleanly). Do NOT annotate to a concrete EmblaCarouselType.
interface RozieSlideSlotCtx {
slide: unknown;
index: unknown;
}
interface RozieThumbSlotCtx {
slide: unknown;
index: unknown;
}
@customElement('rozie-carousel')
export default class Carousel extends SignalWatcher(LitElement) {
static styles = css`
.rozie-embla[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__stage[data-rozie-s-4143c216] { position: relative; }
.rozie-embla__viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__container[data-rozie-s-4143c216] { display: flex; }
.rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-width: 0; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__container[data-rozie-s-4143c216] { flex-direction: column; height: 100%; }
.rozie-embla--vertical[data-rozie-s-4143c216] .rozie-embla__slide[data-rozie-s-4143c216] { flex: 0 0 100%; min-height: 0; }
.rozie-embla__arrow[data-rozie-s-4143c216] {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: #1a1a1a;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
transition: opacity 0.15s ease, background 0.15s ease;
}
.rozie-embla__arrow[data-rozie-s-4143c216]:hover { background: #fff; }
.rozie-embla__arrow[data-rozie-s-4143c216]:disabled { opacity: 0.35; cursor: default; }
.rozie-embla__arrow--prev[data-rozie-s-4143c216] { left: 0.5rem; }
.rozie-embla__arrow--next[data-rozie-s-4143c216] { right: 0.5rem; }
.rozie-embla__dots[data-rozie-s-4143c216] {
display: flex;
justify-content: center;
gap: 0.4rem;
padding: 0.625rem 0;
}
.rozie-embla__dot[data-rozie-s-4143c216] {
width: 0.5rem;
height: 0.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
}
.rozie-embla__dot[data-rozie-s-4143c216]:hover { background: rgba(0, 0, 0, 0.45); }
.rozie-embla__dot.is-selected[data-rozie-s-4143c216] {
background: #1a1a1a;
transform: scale(1.25);
}
.rozie-embla__thumbs[data-rozie-s-4143c216] { margin-top: 0.5rem; }
.rozie-embla__thumbs-viewport[data-rozie-s-4143c216] { overflow: hidden; }
.rozie-embla__thumbs-container[data-rozie-s-4143c216] { display: flex; gap: 0.5rem; }
.rozie-embla__thumb[data-rozie-s-4143c216] {
flex: 0 0 auto;
cursor: pointer;
opacity: 0.5;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.rozie-embla__thumb[data-rozie-s-4143c216]:hover { opacity: 0.8; }
.rozie-embla__thumb.is-selected[data-rozie-s-4143c216] {
opacity: 1;
border-color: #1a1a1a;
}
`;
/**
* Slide data for config-array mode (mode a): Rozie renders one `.rozie-embla__slide` per item, optionally via the scoped `slide` slot for custom markup. Optional — leave it unset and use the default slot (mode b) to drop slide DOM directly.
* @example
* <Carousel :slides="['A', 'B', 'C']" r-model:selectedIndex="idx" />
*/
@property({ type: Array }) slides: any[] = [];
/**
* Wrap from the last snap back to the first (the Embla `loop` option). Runtime-updatable — toggling it re-inits the engine.
*/
@property({ type: Boolean, reflect: true }) loop: boolean = false;
/**
* Snap alignment of slides within the viewport — one of `'start'`, `'center'`, or `'end'`. Runtime-updatable.
*/
@property({ type: String, reflect: true }) align: string = 'center';
/**
* Scroll axis — `'x'` for a horizontal carousel or `'y'` for a vertical one. Runtime-updatable.
*/
@property({ type: String, reflect: true }) axis: string = 'x';
/**
* Number of slides advanced per snap (the Embla `slidesToScroll` option). Runtime-updatable.
*/
@property({ type: Number, reflect: true }) slidesToScroll: number = 1;
/**
* Enable momentum/free-scroll dragging with no hard snapping (the Embla `dragFree` option). Runtime-updatable.
*/
@property({ type: Boolean, reflect: true }) dragFree: boolean = false;
/**
* Enable pointer drag (mapped to the Embla `watchDrag` option — a Vue-clarity rename). Set `false` to disable dragging and leave only programmatic/arrow navigation. Runtime-updatable.
*/
@property({ type: Boolean, reflect: true }) draggable: boolean = true;
/**
* Edge-snap containment (the Embla `containScroll` option) — `''` (off), `'trimSnaps'`, or `'keepSnaps'`. Runtime-updatable.
*/
@property({ type: String, reflect: true }) containScroll: string = 'trimSnaps';
/**
* Initial snap index the carousel starts at (the Embla `startIndex` option). Runtime-updatable.
*/
@property({ type: Number, reflect: true }) startIndex: number = 0;
/**
* Allow a fast flick to skip intermediate snaps (the Embla `skipSnaps` option). Runtime-updatable.
*/
@property({ type: Boolean, reflect: true }) skipSnaps: boolean = false;
/**
* Scroll transition duration in Embla's relative unit (the `duration` option) — lower is snappier. Runtime-updatable.
*/
@property({ type: Number, reflect: true }) duration: number = 25;
/**
* Text/scroll direction — `'ltr'` or `'rtl'` (the Embla `direction` option). Runtime-updatable.
*/
@property({ type: String, reflect: true }) direction: string = 'ltr';
/**
* Mount the `embla-carousel-autoplay` plugin to auto-advance the carousel. Toggling it at runtime rebuilds the plugin set.
*/
@property({ type: Boolean, reflect: true }) autoplay: boolean = false;
/**
* Delay in milliseconds between auto-advances when `autoplay` is on. Runtime-updatable.
*/
@property({ type: Number, reflect: true }) autoplayDelay: number = 4000;
/**
* Show built-in dot pagination — one dot per scroll snap, the active snap highlighted, and clicking a dot scrolls to it. Opt-in, off by default.
*/
@property({ type: Boolean, reflect: true }) dots: boolean = false;
/**
* Show built-in prev/next arrow buttons overlaid on the viewport. The arrows disable at the ends unless `loop` is set. Opt-in, off by default.
*/
@property({ type: Boolean, reflect: true }) arrows: boolean = false;
/**
* Show a synced thumbnail strip below the carousel — its own Embla instance with one thumb per slide (config-array mode). Fill the `thumb` scoped slot for custom thumb content (falls back to the slide value). Clicking a thumb scrolls the main carousel; the main selection highlights and scrolls the active thumb. Opt-in, off by default.
*/
@property({ type: Boolean, reflect: true }) thumbnails: boolean = false;
/**
* Escape hatch — extra Embla plugins (Fade, Class Names, Wheel Gestures, …) appended verbatim after the built-in Autoplay plugin.
*/
@property({ type: Array }) plugins: any[] = [];
/**
* Escape hatch — a raw `EmblaOptionsType` object spread last over the curated option props, so a consumer can override anything Embla supports.
*/
@property({ type: Object }) options: any = {};
/**
* The current scroll-snap index (two-way `r-model`). Dragging or scrolling writes the new index back (echo-guarded so a programmatic `scrollTo` does not ping-pong); a consumer write scrolls the carousel. Distinct from the `select` emit — a model prop must not share a name with an emit.
* @example
* <Carousel :slides="items" r-model:selectedIndex="idx" />
*/
@property({ type: Number, attribute: 'selected-index' }) _selectedIndex_attr: number = 0;
private _selectedIndexControllable = createLitControllableProperty<number>({ host: this, eventName: 'selected-index-change', defaultValue: 0, initialControlledValue: undefined });
private _snaps = signal<any[]>([]);
private _selected = signal(0);
private _canPrev = signal(false);
private _canNext = signal(false);
@query('[data-rozie-ref="viewportEl"]') private _refViewportEl!: HTMLElement;
@query('[data-rozie-ref="thumbsViewportEl"]') private _refThumbsViewportEl!: HTMLElement;
private __rozieWatchInitial_0 = true;
private __rozieFirstUpdateDone = false;
@state() private _hasSlotSlide = false;
@queryAssignedElements({ slot: 'slide', flatten: true }) private _slotSlideElements!: Element[];
@property({ attribute: false }) slide?: (scope: { slide: unknown; index: unknown }) => unknown;
@state() private _hasSlotDefault = false;
@queryAssignedElements({ flatten: true }) private _slotDefaultElements!: Element[];
@state() private _hasSlotThumb = false;
@queryAssignedElements({ slot: 'thumb', flatten: true }) private _slotThumbElements!: Element[];
@property({ attribute: false }) thumb?: (scope: { slide: unknown; index: unknown }) => unknown;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
private _armListeners(): void {
{
const slotEl = this.shadowRoot?.querySelector('slot[name="slide"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotSlide = this._slotSlideElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot:not([name])');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotDefault = this._slotDefaultElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
{
const slotEl = this.shadowRoot?.querySelector('slot[name="thumb"]');
if (slotEl !== null && slotEl !== undefined) {
const update = () => { this._hasSlotThumb = this._slotThumbElements.length > 0; };
slotEl.addEventListener('slotchange', update);
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
update();
}
}
}
connectedCallback(): void {
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
this._hasSlotSlide = Array.from(this.children).some((el) => el.getAttribute('slot') === 'slide');
this._hasSlotDefault = Array.from(this.children).some((el) => !el.hasAttribute('slot') && (el.nodeType !== 3 || (el.textContent?.trim().length ?? 0) > 0));
this._hasSlotThumb = Array.from(this.children).some((el) => el.getAttribute('slot') === 'thumb');
super.connectedCallback();
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
}
firstUpdated(): void {
this._armListeners();
this._disconnectCleanups.push((() => {
this.embla?.destroy();
this.emblaThumbs?.destroy();
}));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.selectedIndex)(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((i: any) => {
if (this.embla && typeof i === 'number' && i !== this.embla.selectedScrollSnap()) this.embla.scrollTo(i);
})(__watchVal); }); }));
this.embla = EmblaCarousel(this._refViewportEl, this.emblaOptionsFromProps(), this.emblaPluginsFromProps());
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
// Build the thumbnail strip's own Embla instance when enabled. $refs.thumbsViewportEl
// exists exactly when the `thumbnails` r-if has rendered (read here in $onMount, the
// only $refs-safe site). Stays null otherwise (zero overhead).
if (this.thumbnails && this._refThumbsViewportEl) {
this.emblaThumbs = EmblaCarousel(this._refThumbsViewportEl, this.thumbsOptionsFromProps());
}
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
// engine → consumer: on every snap change write the two-way model AND fire the
// distinctly-named `select` emit (model `selectedIndex` ≠ emit `select`). syncNav
// refreshes the built-in dots/arrows + thumb sync.
this.embla.on('select', () => {
const i = this.embla.selectedScrollSnap();
this._selectedIndexControllable.write(i);
this.dispatchEvent(new CustomEvent("select", {
detail: i,
bubbles: true,
composed: true
}));
this.syncNav();
});
this.embla.on('settle', () => this.dispatchEvent(new CustomEvent("settle", {
detail: undefined,
bubbles: true,
composed: true
})));
this.embla.on('reInit', () => {
this.dispatchEvent(new CustomEvent("reInit", {
detail: undefined,
bubbles: true,
composed: true
}));
this.syncNav();
});
this.embla.on('pointerDown', () => this.dispatchEvent(new CustomEvent("pointer-down", {
detail: undefined,
bubbles: true,
composed: true
})));
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
// Embla caches SLIDE sizes at init. If a slide's CSS (or a root width applied via
// attribute fallthrough) settles a frame after $onMount, the snap COUNT measured
// at init is stale — and a slide-size change (vs a viewport resize or slide
// add/remove) fires neither `resize` nor `reInit`, so Embla never re-measures on
// its own. Re-measure once after the first layout flush via reInit (its `reInit`
// handler resyncs the dot count); `resize` keeps the viewport-resize case covered.
this.embla.on('resize', () => this.syncNav());
// seed the nav state immediately (covers the already-laid-out case)…
// seed the nav state immediately (covers the already-laid-out case)…
this.syncNav();
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
// …then re-measure after layout fully settles (a consumer's slide CSS / a root
// width via attribute fallthrough can land a couple of frames after $onMount;
// Embla caches slide sizes at init and a slide-size change alone fires no
// re-measure). Two rAFs out, then a macrotask, each reInit → its handler resyncs
// the dot count. Idempotent: a reInit on already-correct sizes is a no-op diff.
if (typeof requestAnimationFrame === 'function') {
const remeasure = () => {
if (this.embla) this.embla.reInit(this.emblaOptionsFromProps(), this.emblaPluginsFromProps());
};
requestAnimationFrame(() => requestAnimationFrame(remeasure));
setTimeout(remeasure, 0);
}
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('loop') || changedProperties.has('align') || changedProperties.has('axis') || changedProperties.has('slidesToScroll') || changedProperties.has('dragFree') || changedProperties.has('draggable') || changedProperties.has('containScroll') || changedProperties.has('skipSnaps') || changedProperties.has('duration') || changedProperties.has('direction'))) { const __watchVal = (() => [this.loop, this.align, this.axis, this.slidesToScroll, this.dragFree, this.draggable, this.containScroll, this.skipSnaps, this.duration, this.direction].join('|'))(); (() => this.embla?.reInit(this.emblaOptionsFromProps()))(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('autoplay') || changedProperties.has('autoplayDelay'))) { const __watchVal = (() => `${this.autoplay}|${this.autoplayDelay}`)(); (() => this.embla?.reInit(this.emblaOptionsFromProps(), this.emblaPluginsFromProps()))(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('slides'))) { const __watchVal = (() => this.slides.length)(); (() => {
this.embla?.reInit(this.emblaOptionsFromProps());
this.emblaThumbs?.reInit(this.thumbsOptionsFromProps());
this.syncNav();
})(); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('thumbnails'))) { const __watchVal = (() => this.thumbnails)(); ((on: any) => {
if (on && !this.emblaThumbs && this._refThumbsViewportEl) {
this.emblaThumbs = EmblaCarousel(this._refThumbsViewportEl, this.thumbsOptionsFromProps());
this.syncNav();
} else if (!on && this.emblaThumbs) {
this.emblaThumbs.destroy();
this.emblaThumbs = null;
}
})(__watchVal); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'selected-index') this._selectedIndexControllable.notifyAttributeChange(value === null ? 0 : Number(value));
}
render() {
return html`
<div class="${Object.entries({ "rozie-embla": true, 'rozie-embla--vertical': this.axis === 'y' }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-4143c216>
<div class="rozie-embla__stage" data-rozie-s-4143c216>
${this.arrows ? html`<button class="rozie-embla__arrow rozie-embla__arrow--prev" type="button" ?disabled=${!this._canPrev.value} aria-label="Previous slide" @click=${($event: Event) => { this.navPrev(); }} data-rozie-s-4143c216>‹</button>` : nothing}<div class="rozie-embla__viewport" data-rozie-ref="viewportEl" data-rozie-s-4143c216>
<div class="rozie-embla__container" data-rozie-s-4143c216>
${repeat<any>(this.slides, (item, i) => this.keyFor(item, i), (item, i) => html`<div class="rozie-embla__slide" key=${rozieAttr(this.keyFor(item, i))} data-rozie-s-4143c216>
${this.slide !== undefined ? this.slide({slide: item, index: i}) : html`<slot name="slide" data-rozie-params=${(() => { try { return JSON.stringify({slide: item, index: i}); } catch { return '{}'; } })()}>${rozieDisplay(item)}</slot>`}
</div>`)}
<slot></slot>
</div>
</div>
${this.arrows ? html`<button class="rozie-embla__arrow rozie-embla__arrow--next" type="button" ?disabled=${!this._canNext.value} aria-label="Next slide" @click=${($event: Event) => { this.navNext(); }} data-rozie-s-4143c216>›</button>` : nothing}</div>
${this.dots ? html`<div class="rozie-embla__dots" data-rozie-s-4143c216>
${repeat<any>(this._snaps.value, (di, _idx) => di, (di, _idx) => html`<button class="${Object.entries({ "rozie-embla__dot": true, 'is-selected': di === this._selected.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieAttr(di)} type="button" aria-label=${rozieAttr('Go to slide ' + (di + 1))} @click=${($event: Event) => { this.navTo(di); }} data-rozie-s-4143c216></button>`)}
</div>` : nothing}${this.thumbnails ? html`<div class="rozie-embla__thumbs" data-rozie-s-4143c216>
<div class="rozie-embla__thumbs-viewport" data-rozie-ref="thumbsViewportEl" data-rozie-s-4143c216>
<div class="rozie-embla__thumbs-container" data-rozie-s-4143c216>
${repeat<any>(this.slides, (item, i) => this.keyFor(item, i), (item, i) => html`<div class="${Object.entries({ "rozie-embla__thumb": true, 'is-selected': i === this._selected.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" key=${rozieAttr(this.keyFor(item, i))} @click=${($event: Event) => { this.selectThumb(i); }} data-rozie-s-4143c216>
${this.thumb !== undefined ? this.thumb({slide: item, index: i}) : html`<slot name="thumb" data-rozie-params=${(() => { try { return JSON.stringify({slide: item, index: i}); } catch { return '{}'; } })()}>${rozieDisplay(item)}</slot>`}
</div>`)}
</div>
</div>
</div>` : nothing}</div>
`;
}
embla: any = null;
emblaThumbs: any = null;
keyFor = (slide: any, i: any) => {
if (slide !== null && typeof slide === 'object') return slide.id ?? slide.key ?? i;
return slide ?? i;
};
emblaOptionsFromProps = () => {
let opts: any = null;
opts = {
loop: this.loop,
align: this.align,
axis: this.axis,
slidesToScroll: this.slidesToScroll,
dragFree: this.dragFree,
watchDrag: this.draggable,
containScroll: this.containScroll,
startIndex: this.startIndex,
skipSnaps: this.skipSnaps,
duration: this.duration,
direction: this.direction,
...this.options
};
return opts;
};
emblaPluginsFromProps = () => {
const builtins = this.autoplay ? [Autoplay({
delay: this.autoplayDelay
})] : [];
return [...builtins, ...this.plugins];
};
thumbsOptionsFromProps = () => {
let opts: any = null;
opts = {
containScroll: 'keepSnaps',
dragFree: true,
axis: this.axis
};
return opts;
};
syncNav = () => {
if (!this.embla) return;
const i = this.embla.selectedScrollSnap();
this._snaps.value = this.embla.scrollSnapList().map((_: any, n: any) => n);
this._selected.value = i;
this._canPrev.value = this.embla.canScrollPrev();
this._canNext.value = this.embla.canScrollNext();
if (this.emblaThumbs) this.emblaThumbs.scrollTo(i);
};
navPrev = () => {
if (this.embla) this.embla.scrollPrev();
};
navNext = () => {
if (this.embla) this.embla.scrollNext();
};
navTo = (i: any) => {
if (this.embla) this.embla.scrollTo(i);
};
selectThumb = (i: any) => {
if (this.emblaThumbs && !this.emblaThumbs.clickAllowed()) return;
this.navTo(i);
};
scrollNext(jump: any) {
if (this.embla) this.embla.scrollNext(jump);
}
scrollPrev(jump: any) {
if (this.embla) this.embla.scrollPrev(jump);
}
scrollToIndex(index: any, jump: any) {
if (this.embla) this.embla.scrollTo(index, jump);
}
reInitCarousel(opts: any) {
if (this.embla) this.embla.reInit(opts ?? this.emblaOptionsFromProps(), this.emblaPluginsFromProps());
}
canScrollNext() {
return this.embla ? this.embla.canScrollNext() : false;
}
canScrollPrev() {
return this.embla ? this.embla.canScrollPrev() : false;
}
getSelectedIndex() {
return this.embla ? this.embla.selectedScrollSnap() : 0;
}
scrollSnapList() {
return this.embla ? this.embla.scrollSnapList() : [];
}
scrollProgress() {
return this.embla ? this.embla.scrollProgress() : 0;
}
slidesInView() {
return this.embla ? this.embla.slidesInView() : [];
}
slidesNotInView() {
return this.embla ? this.embla.slidesNotInView() : [];
}
previousScrollSnap() {
return this.embla ? this.embla.previousScrollSnap() : 0;
}
getPlugins() {
return this.embla ? this.embla.plugins() : null;
}
getInstance() {
return this.embla;
}
get selectedIndex(): number { return this._selectedIndexControllable.read(); }
set selectedIndex(v: number) { this._selectedIndexControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['slides', 'loop', 'align', 'axis', 'slides-to-scroll', 'slidestoscroll', 'drag-free', 'dragfree', 'draggable', 'contain-scroll', 'containscroll', 'start-index', 'startindex', 'skip-snaps', 'skipsnaps', 'duration', 'direction', 'autoplay', 'autoplay-delay', 'autoplaydelay', 'dots', 'arrows', 'thumbnails', 'plugins', 'options', 'selected-index', 'selectedindex']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same events, same two-way selectedIndex, same imperative handle, all from the one source above.
See also
- Embla — showcase & API — install, quick starts for all six frameworks, and the full reference.
- Embla libraries comparison — how
@rozie-ui/emblastacks up against the per-framework wrappers.