page Carouselcomponent { seo { title = "Carousel component" description = "Theme-aware, responsive carousel component." } view {
W WRNexusJS
base component

Carousel

Theme-aware, responsive carousel component.

@wrnexus/ui 0.5.022 props1 slots10 events

Usage

Components are auto-discovered. Use the component directly in any .wrn view:

<Carousel
  size="default"
  color="primary"
  title="title"
  description="description"
  items="[]"
/>

Legacy mount name: data-component="Carousel".

Props and attributes

PropTypeRequirementDefault
sizestringOptional"default"
colorstringOptional"primary"
titlestringOptional""
descriptionstringOptional""
itemsstringOptional[]
activeIndexnumberOptional0
slidesPerViewnumberOptional1
gapstringOptional"0.75rem"
showPaginationbooleanOptionalfalse
isAutoPlaybooleanOptionalfalse
autoplayIntervalnumberOptional4000
isInfiniteLoopbooleanOptionalfalse
isRTLbooleanOptionalfalse
isCenteredbooleanOptionalfalse
isDraggablebooleanOptionalfalse
isAutoHeightbooleanOptionalfalse
isSnapbooleanOptionalfalse
showCounterbooleanOptionalfalse
thumbnailsstringOptional"none"
ariaLabelstringOptional"Content carousel"
variantstringOptional"default"
classstringOptional""

Slots

  • default

Events

  • initialize
  • change
  • previous
  • next
  • play
  • pause
  • reachStart
  • reachEnd
  • dragStart
  • dragEnd

Source contract

Installed source: components/carousel.wrn

// Interactive, hydration-safe content carousel.
component Carousel {
  props {
    @event initialize = function
    @event change = function
    @event previous = function
    @event next = function
    @event play = function
    @event pause = function
    @event reachStart = function
    @event reachEnd = function
    @event dragStart = function
    @event dragEnd = function
    size = "default"
    color = "primary"
    title = ""
    description = ""
    items = []
    activeIndex = 0
    slidesPerView = 1
    gap = "0.75rem"
    showPagination = false
    isAutoPlay = false
    autoplayInterval = 4000
    isInfiniteLoop = false
    isRTL = false
    isCentered = false
    isDraggable = false
    isAutoHeight = false
    isSnap = false
    showCounter = false
    thumbnails = "none"
    ariaLabel = "Content carousel"
    variant = "default"
    class = ""
  }

  state currentIndex = activeIndex
  state playing = false
  state dragOrigin = null
  state dragOffset = 0
  state autoplayTimer = null
  state carouselRoot = null

  functions {
    function slideCount() {
      return items.length
    }

    function maximumIndex(count, visible) {
      count = slideCount()
      visible = Math.max(1, Number(slidesPerView) || 1)
      if (isCentered || isSnap) {
        return Math.max(0, count - 1)
      }
      return Math.max(0, count - visible)
    }

    function normalizedIndex(index, maximum) {
      maximum = maximumIndex()
      if (slideCount() === 0) {
        return 0
      }
      if (isInfiniteLoop) {
        if (index < 0) {
          return maximum
        }
        if (index > maximum) {
          return 0
        }
      }
      return Math.max(0, Math.min(index, maximum))
    }

    function rememberRoot(sourceEvent, root) {
      if (!sourceEvent || !sourceEvent.currentTarget) {
        return
      }
      root = sourceEvent.currentTarget.closest(".wire-next--carousel")
      if (root) {
        carouselRoot = root
      }
    }

    function syncNavigation(sourceEvent, index, root, viewport, slides, slide, thumbnails, thumbnail, rail) {
      rememberRoot(sourceEvent)
      root = carouselRoot
      if (!root) {
        return
      }
      if (isSnap) {
        viewport = root.querySelector(".wire-next__carousel-viewport")
        slides = root.querySelectorAll(".wire-next__carousel-slide")
        slide = slides[index]
        if (viewport && slide) {
          viewport.scrollTo({
            left: slide.offsetLeft - (viewport.clientWidth - slide.clientWidth) / 2,
            behavior: "smooth"
          })
        }
      }
      thumbnails = root.querySelectorAll(".wire-next__carousel-thumbnails button")
      if (thumbnails[index]) {
        thumbnail = thumbnails[index]
        rail = thumbnail.parentElement
        rail.scrollTo({
          left: thumbnail.offsetLeft - (rail.clientWidth - thumbnail.clientWidth) / 2,
          top: thumbnail.offsetTop - (rail.clientHeight - thumbnail.clientHeight) / 2,
          behavior: "smooth"
        })
      }
    }

    function handleSnapScroll(sourceEvent, slides, viewport, viewportCenter, closestIndex, closestDistance, slide, slideCenter, distance, previousIndex) {
      if (!isSnap) {
        return
      }
      rememberRoot(sourceEvent)
      viewport = sourceEvent.currentTarget
      slides = viewport.querySelectorAll(".wire-next__carousel-slide")
      viewportCenter = viewport.scrollLeft + viewport.clientWidth / 2
      closestIndex = currentIndex
      closestDistance = 1000000000
      slides.forEach(function (candidate, index) {
        slideCenter = candidate.offsetLeft + candidate.clientWidth / 2
        distance = Math.abs(slideCenter - viewportCenter)
        if (distance < closestDistance) {
          closestDistance = distance
          closestIndex = index
        }
      })
      closestIndex = normalizedIndex(closestIndex)
      if (closestIndex !== currentIndex) {
        previousIndex = currentIndex
        currentIndex = closestIndex
        $emit("change", {
          index: currentIndex,
          previousIndex: previousIndex,
          item: items[currentIndex],
          reason: "snap"
        })
        slides = carouselRoot.querySelectorAll(".wire-next__carousel-thumbnails button")
        slide = slides[currentIndex]
        if (slide) {
          viewport = slide.parentElement
          viewport.scrollTo({
            left: slide.offsetLeft - (viewport.clientWidth - slide.clientWidth) / 2,
            top: slide.offsetTop - (viewport.clientHeight - slide.clientHeight) / 2,
            behavior: "smooth"
          })
        }
      }
    }

    function selectSlide(index, reason, sourceEvent, previousIndex) {
      if (slideCount() === 0) {
        return
      }
      previousIndex = currentIndex
      currentIndex = normalizedIndex(Number(index))
      syncNavigation(sourceEvent, currentIndex)
      if (previousIndex === currentIndex && reason !== "initialize") {
        return
      }
      $emit("change", {
        index: currentIndex,
        previousIndex: previousIndex,
        item: items[currentIndex],
        reason: reason || "select"
      })
      if (currentIndex === 0) {
        $emit("reachStart", { index: currentIndex })
      }
      if (currentIndex === maximumIndex()) {
        $emit("reachEnd", { index: currentIndex })
      }
    }

    function previousSlide(sourceEvent, previousIndex) {
      previousIndex = currentIndex
      selectSlide(currentIndex - 1, "previous", sourceEvent)
      $emit("previous", {
        index: currentIndex,
        previousIndex: previousIndex,
        item: items[currentIndex]
      })
    }

    function nextSlide(sourceEvent, previousIndex) {
      previousIndex = currentIndex
      selectSlide(currentIndex + 1, "next", sourceEvent)
      $emit("next", {
        index: currentIndex,
        previousIndex: previousIndex,
        item: items[currentIndex]
      })
    }

    function handleKeydown(sourceEvent) {
      if (sourceEvent.key === "ArrowLeft") {
        sourceEvent.preventDefault()
        if (isRTL) {
          nextSlide(sourceEvent)
        } else {
          previousSlide(sourceEvent)
        }
      }
      if (sourceEvent.key === "ArrowRight") {
        sourceEvent.preventDefault()
        if (isRTL) {
          previousSlide(sourceEvent)
        } else {
          nextSlide(sourceEvent)
        }
      }
      if (sourceEvent.key === "Home") {
        sourceEvent.preventDefault()
        selectSlide(0, "keyboard", sourceEvent)
      }
      if (sourceEvent.key === "End") {
        sourceEvent.preventDefault()
        selectSlide(maximumIndex(), "keyboard", sourceEvent)
      }
    }

    function beginDrag(sourceEvent) {
      if (!isDraggable || isSnap) {
        return
      }
      sourceEvent.preventDefault()
      dragOrigin = sourceEvent.clientX
      dragOffset = 0
      sourceEvent.currentTarget.setPointerCapture(sourceEvent.pointerId)
      $emit("dragStart", { index: currentIndex, x: dragOrigin })
    }

    function moveDrag(sourceEvent) {
      if (!isDraggable || isSnap || dragOrigin === null) {
        return
      }
      sourceEvent.preventDefault()
      dragOffset = sourceEvent.clientX - dragOrigin
    }

    function cancelDrag() {
      dragOrigin = null
      dragOffset = 0
    }

    function endDrag(sourceEvent, distance) {
      if (!isDraggable || isSnap || dragOrigin === null) {
        return
      }
      sourceEvent.preventDefault()
      distance = dragOffset || sourceEvent.clientX - dragOrigin
      dragOrigin = null
      dragOffset = 0
      if (Math.abs(distance) > 20) {
        if ((distance < 0 && !isRTL) || (distance > 0 && isRTL)) {
          nextSlide(sourceEvent)
        } else {
          previousSlide(sourceEvent)
        }
      }
      $emit("dragEnd", {
        index: currentIndex,
        distance: distance
      })
    }

    function advanceAutoplay() {
      if (currentIndex >= maximumIndex()) {
        selectSlide(0, "autoplay")
      } else {
        selectSlide(currentIndex + 1, "autoplay")
      }
    }

    function startAutoplay() {
      if (!isAutoPlay || playing || slideCount() < 2) {
        return
      }
      playing = true
      autoplayTimer = setInterval(advanceAutoplay, Math.max(1000, Number(autoplayInterval)))
      $emit("play", { index: currentIndex, interval: autoplayInterval })
    }

    function pauseAutoplay() {
      if (autoplayTimer) {
        clearInterval(autoplayTimer)
      }
      autoplayTimer = null
      if (playing) {
        $emit("pause", { index: currentIndex })
      }
      playing = false
    }

  }

  lifecycle {
    mount {
      $emit("initialize", { index: currentIndex, count: slideCount() })
      startAutoplay()
    }
    unmount {
      if (autoplayTimer) {
        clearInterval(autoplayTimer)
      }
    }
  }

  view {
        <section
      {...attrs}
      class="wire-next wire-next--carousel wire-next--color-{color} wire-next--size-{size} wire-next--variant-{variant} {class}"
      data-rtl="{isRTL}"
      data-centered="{isCentered}"
      data-draggable="{isDraggable && !isSnap}"
      data-dragging="{dragOrigin !== null}"
      data-auto-height="{isAutoHeight}"
      data-snap="{isSnap}"
      data-thumbnails="{thumbnails}"
      dir="{isRTL ? 'rtl' : 'ltr'}"
      role="region"
      aria-roledescription="carousel"
      aria-label="{ariaLabel}"
      @keydown="handleKeydown(event)"
      @mouseenter="rememberRoot(event); pauseAutoplay()"
      @mouseleave="startAutoplay()"
      @focusin="rememberRoot(event); pauseAutoplay()"
      @focusout="startAutoplay()"
    >
      {#if title || description}
        <header class="wire-next__carousel-header">
          {#if title}<h3>{title}</h3>{/if}
          {#if description}<p>{description}</p>{/if}
        </header>
      {/if}

        <div class="wire-next__carousel-layout">
          {#if thumbnails === "vertical"}
            <div class="wire-next__carousel-thumbnails" aria-label="Choose a slide">
              {#each items as item, index}
                <button
                  type="button"
                  data-active="{index === currentIndex}"
                  aria-label="Show slide {index + 1}: {item.label || item.title}"
                  aria-current="{index === currentIndex ? 'true' : 'false'}"
                  @click="selectSlide(index, 'thumbnail', event)"
                >
                  {#if item.thumbnail}<img src="{item.thumbnail}" alt="" />{/if}
                  <span>{item.label || item.title || "Slide " + (index + 1)}</span>
        </button>
              {/each}
            </div>
          {/if}

          <div class="wire-next__carousel-main">
        <div class="wire-next__carousel-stage">
        <div
                class="wire-next__carousel-viewport"
                tabindex="0"
                @scroll="handleSnapScroll(event)"
                @pointerdown="beginDrag(event)"
                @pointermove="moveDrag(event)"
                @pointerup="endDrag(event)"
                @pointercancel="cancelDrag()"
                @lostpointercapture="cancelDrag()"
              >
        <div
                  class="wire-next__carousel-track"
                  style="--wire-carousel-index: {currentIndex}; --wire-carousel-per-view: {slidesPerView}; --wire-carousel-gap: {gap}; --wire-carousel-drag-offset: {dragOffset}px"
                >
                  {#each items as item, index}
                    <article
                      class="wire-next__carousel-slide"
                      data-active="{index === currentIndex}"
                      role="group"
                      aria-roledescription="slide"
                      aria-label="{index + 1} of {items.length}"
                      aria-hidden="{index === currentIndex ? 'false' : 'true'}"
                    >
                      {#if item.imageSrc}
                        <img src="{item.imageSrc}" alt="{item.imageAlt || item.title || ''}" />
                      {/if}
                      <div class="wire-next__carousel-slide-content">
                        {#if item.eyebrow}<span>{item.eyebrow}</span>{/if}
                        {#if item.title || item.label}<h4>{item.title || item.label}</h4>{/if}
                        {#if item.description}<p>{item.description}</p>{/if}
                        {#if item.actionLabel}
                          <a href="{item.actionHref || '#'}">{item.actionLabel}</a>
                        {/if}
                      </div>
        </article>
                  {/each}
                  <slot />
        </div>
        </div>
        <button
                class="wire-next__carousel-control wire-next__carousel-control--previous"
                type="button"
                aria-label="Previous slide"
                disabled="{!isInfiniteLoop && currentIndex === 0}"
                @click="previousSlide(event)"
              >
        <span class="icon-[lucide--chevron-left]" aria-hidden="true">
        </span>
        </button>
        <button
                class="wire-next__carousel-control wire-next__carousel-control--next"
                type="button"
                aria-label="Next slide"
                disabled="{!isInfiniteLoop && currentIndex === maximumIndex()}"
                @click="nextSlide(event)"
              >
        <span class="icon-[lucide--chevron-right]" aria-hidden="true">
        </span>
        </button>

              {#if showCounter}
                <output class="wire-next__carousel-counter" aria-live="polite">
                  {currentIndex + 1} / {items.length}
                </output>
              {/if}
            </div>

            {#if showPagination}
              <div class="wire-next__carousel-pagination" aria-label="Choose a slide">
                {#each items as item, index}
                  <button
                    type="button"
                    data-active="{index === currentIndex}"
                    aria-label="Show slide {index + 1}"
                    aria-current="{index === currentIndex ? 'true' : 'false'}"
                    @click="selectSlide(index, 'pagination', event)"
                  >
        </button>
                {/each}
              </div>
            {/if}

            {#if thumbnails === "horizontal"}
              <div class="wire-next__carousel-thumbnails" aria-label="Choose a slide">
                {#each items as item, index}
                  <button
                    type="button"
                    data-active="{index === currentIndex}"
                    aria-label="Show slide {index + 1}: {item.label || item.title}"
                    aria-current="{index === currentIndex ? 'true' : 'false'}"
                    @click="selectSlide(index, 'thumbnail', event)"
                  >
                    {#if item.thumbnail}<img src="{item.thumbnail}" alt="" />{/if}
                    <span>{item.label || item.title || "Slide " + (index + 1)}</span>
        </button>
                {/each}
              </div>
            {/if}
          </div>
        </div>
        </section>
    }
}
} }