Skip to content

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

Pre-v1.0 — internal monorepo.