Skip to content

EpCarousel

Props

NameDescriptionTypeDefault
imagesArray of image objects to display in the carousel.
Each image object can include: src, alt, caption, aspectRatio, zoom, positionX, positionY, captionPositionarray[]

Events

NameDescriptionPayload
image-click--
slide-change--

INFO

This component does not use slots.

Component Code

vue
<!--
  EpCaseStudyCarousel - A feature-rich carousel component with center-snap behavior
  
  Features:
  1. First image begins in the center of the viewport
  2. Smooth scrolling with snap-to-center behavior
  3. Adjacent images are always visible on the edges
  4. Clickable images to navigate
  5. Rounded corner containers that clip images
  6. Image zoom and positioning support
  7. Flexible caption positioning (9 positions)
  8. Dot navigation indicators
  
  Image Object Properties:
  - src: string (required) - Image source URL
  - alt: string - Alt text for accessibility
  - caption: string - Caption text to display
  - aspectRatio: string - e.g., "16/9", "4/3"
  - zoom: number - Scale factor (e.g., 1.5 for 150%)
  - positionX: string - Horizontal position (e.g., "50%", "left", "right")
  - positionY: string - Vertical position (e.g., "50%", "top", "bottom")
  - captionPosition: string - One of:
      "top-left", "top-center", "top-right",
      "left-center", "center", "right-center",
      "bottom-left", "bottom-center", "bottom-right"
  
  Events:
  - @image-click: Emitted when an image is clicked { image, index }
  - @slide-change: Emitted when the active slide changes { image, index }
  
  CSS Custom Properties:
  - --ep-case-study-carousel-height: Carousel height (default: 50vh)
  - --ep-case-study-carousel-gap: Gap between images (default: 2rem)
  - --ep-case-study-carousel-border-radius: Border radius (default: 1rem)
  - --ep-case-study-carousel-caption-border-radius: Caption border radius (default: 0.5rem)
-->
<template>
  <div
    ref="carouselContainer"
    class="ep-case-study-carousel"
  >
    <div
      ref="carouselTrack"
      class="carousel-track"
      @scroll="handleScroll"
    >
      <div class="carousel-spacer" />
      <div
        v-for="(image, index) in images"
        :key="image?.src ?? index"
        :ref="el => itemRefs[index] = el"
        class="carousel-item"
        :class="{ 'is-active': currentIndex === index }"
        role="button"
        tabindex="0"
        @click="scrollToImage(index)"
        @keydown.enter.prevent="scrollToImage(index)"
        @keydown.space.prevent="scrollToImage(index)"
      >
        <div
          class="carousel-image-container"
          :style="getImageContainerStyle(image)"
        >
          <ep-lazy-image
            class="carousel-image"
            :src="image.src"
            :alt="image.alt"
            :aspect-ratio="image.aspectRatio"
            :lazy="!shouldLoadImages"
            :style="getImageStyle(image)"
          />
          <div
            v-if="image.caption"
            class="carousel-caption"
            :class="getCaptionPositionClass(image)"
          >
            {{ image.caption }}
          </div>
        </div>
      </div>
      <div class="carousel-spacer" />
    </div>

    <div class="carousel-navigation">
      <ep-button
        class="carousel-nav-button"
        size="xlarge"
        aria-label="Previous slide"
        :disabled="currentIndex === 0"
        @click="scrollToImage(currentIndex - 1)"
      >
        <template #icon-left>
          <ArrowLeft01 />
        </template>
      </ep-button>
      <div class="carousel-dots">
        <button
          v-for="(image, index) in images"
          :key="`dot-${index}`"
          class="carousel-dot"
          :class="{ 'is-active': currentIndex === index }"
          :aria-label="`Go to slide ${index + 1}`"
          @click="scrollToImage(index)"
        />
      </div>
      <ep-button
        class="carousel-nav-button"
        size="xlarge"
        aria-label="Next slide"
        :disabled="currentIndex === images.length - 1"
        @click="scrollToImage(currentIndex + 1)"
      >
        <template #icon-left>
          <ArrowRight01 />
        </template>
      </ep-button>
    </div>
  </div>
</template>

<script setup>
  import ArrowLeft01 from '@ericpitcock/epicenter-icons/icons/ArrowLeft01'
  import ArrowRight01 from '@ericpitcock/epicenter-icons/icons/ArrowRight01'
  import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

  import EpButton from '../button/EpButton.vue'
  import EpLazyImage from '../lazy-image/EpLazyImage.vue'

  const props = defineProps({
    /**
     * Array of image objects to display in the carousel.
     * Each image object can include: src, alt, caption, aspectRatio, zoom, positionX, positionY, captionPosition
     */
    images: {
      type: Array,
      default: () => [],
    }
  })

  const emit = defineEmits(['image-click', 'slide-change'])

  const carouselContainer = ref(null)
  const carouselTrack = ref(null)
  const itemRefs = ref([])
  const currentIndex = ref(0)
  const shouldLoadImages = ref(false)
  let scrollTimeout = null
  let containerObserver = null
  let resizeObserver = null

  // Reset to first image when images prop changes (e.g., switching case studies)
  watch(() => props.images, () => {
    currentIndex.value = 0
    nextTick(() => {
      if (itemRefs.value[0] && carouselTrack.value) {
        scrollToImage(0)
      }
    })
  })

  const handleScroll = () => {
    // Debounce scroll handling
    if (scrollTimeout) {
      clearTimeout(scrollTimeout)
    }

    scrollTimeout = setTimeout(() => {
      updateCurrentIndex()
    }, 100)
  }

  const updateCurrentIndex = () => {
    if (!carouselTrack.value) return

    const trackRect = carouselTrack.value.getBoundingClientRect()
    const centerX = trackRect.left + trackRect.width / 2

    let closestIndex = 0
    let closestDistance = Infinity

    itemRefs.value.forEach((item, index) => {
      if (!item) return
      const itemRect = item.getBoundingClientRect()
      const itemCenterX = itemRect.left + itemRect.width / 2
      const distance = Math.abs(centerX - itemCenterX)

      if (distance < closestDistance) {
        closestDistance = distance
        closestIndex = index
      }
    })

    if (currentIndex.value !== closestIndex) {
      currentIndex.value = closestIndex
      emit('slide-change', { index: closestIndex, image: props.images[closestIndex] })
    }
  }

  const scrollToImage = (index) => {
    const item = itemRefs.value[index]
    if (!item || !carouselTrack.value) return

    const trackRect = carouselTrack.value.getBoundingClientRect()
    const itemRect = item.getBoundingClientRect()
    const scrollLeft = carouselTrack.value.scrollLeft

    // Calculate the offset needed to center the item
    const itemCenter = itemRect.left - trackRect.left + itemRect.width / 2
    const trackCenter = trackRect.width / 2
    const scrollOffset = itemCenter - trackCenter

    carouselTrack.value.scrollTo({
      left: scrollLeft + scrollOffset,
      behavior: 'smooth'
    })

    currentIndex.value = index
    emit('image-click', { image: props.images[index], index })
  }

  const getImageContainerStyle = (image) => {
    const aspectRatio = image.aspectRatio || '16/9'

    // Parse aspect ratio (e.g., "16/9" or "16 / 9")
    const [width, height] = aspectRatio.split('/').map(v => parseFloat(v.trim()))
    const ratio = width / height

    // Calculate width based on carousel height and aspect ratio
    // This ensures the container has the correct dimensions before the image loads
    return {
      aspectRatio: aspectRatio,
      // height: 'var(--ep-carousel-height)',
      width: `calc(var(--ep-carousel-height) * ${ratio})`,
    }
  }

  const getImageStyle = (image) => {
    const styles = {}

    if (image.zoom) {
      styles.transform = `scale(${image.zoom})`
    }

    if (image.positionX !== undefined || image.positionY !== undefined) {
      const x = image.positionX || '50%'
      const y = image.positionY || '50%'
      styles.transformOrigin = `${x} ${y}`
    }

    return styles
  }

  const getCaptionPositionClass = (image) => {
    const position = image.captionPosition || 'bottom-center'
    return `caption-${position}`
  }

  onMounted(() => {
    // Set up intersection observer for the carousel container
    // This will trigger image loading before the carousel enters the viewport
    containerObserver = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            // Carousel is about to enter viewport, start loading images
            shouldLoadImages.value = true
            // Once triggered, we don't need the observer anymore
            containerObserver.disconnect()
          }
        })
      },
      {
        // Start loading when carousel is 500px away from viewport
        rootMargin: '500px 0px 500px 0px',
        threshold: 0
      }
    )

    if (carouselContainer.value) {
      containerObserver.observe(carouselContainer.value)
    }

    // Set up resize observer to re-center active image on viewport resize
    resizeObserver = new ResizeObserver(() => {
      // Re-center the current image without smooth scrolling
      const item = itemRefs.value[currentIndex.value]
      if (!item || !carouselTrack.value) return

      const trackRect = carouselTrack.value.getBoundingClientRect()
      const itemRect = item.getBoundingClientRect()
      const scrollLeft = carouselTrack.value.scrollLeft

      const itemCenter = itemRect.left - trackRect.left + itemRect.width / 2
      const trackCenter = trackRect.width / 2
      const scrollOffset = itemCenter - trackCenter

      carouselTrack.value.scrollTo({
        left: scrollLeft + scrollOffset,
        behavior: 'instant'
      })
    })

    if (carouselContainer.value) {
      resizeObserver.observe(carouselContainer.value)
    }

    nextTick(() => {
      // Scroll to first image on mount
      if (itemRefs.value[0] && carouselTrack.value) {
        scrollToImage(0)
      }
    })
  })

  onBeforeUnmount(() => {
    if (scrollTimeout) {
      clearTimeout(scrollTimeout)
    }
    if (containerObserver) {
      containerObserver.disconnect()
    }
    if (resizeObserver) {
      resizeObserver.disconnect()
    }
  })
</script>

<style lang="scss" scoped>

  // @use '@/assets/scss/_variables.scss' as variables;
  .ep-case-study-carousel {
    --ep-carousel-height: var(--ep-case-study-carousel-height, 75vh);
    --ep-carousel-gap: var(--ep-case-study-carousel-gap, 2rem);
    --ep-carousel-border-radius: var(--ep-case-study-carousel-border-radius, 1rem);
    --ep-carousel-caption-border-radius: var(--ep-case-study-carousel-caption-border-radius, 0.3rem);
    --ep-carousel-peek-width: var(--ep-case-study-carousel-peek-width, 10vw);
    position: relative;
    width: 100vw;
    // max-height: var(--ep-carousel-height);
    margin-left: calc(-50vw + 50%);
  }

  .carousel-track {
    display: flex;
    gap: var(--ep-carousel-gap);
    -ms-overflow-style: none;
    overflow-x: auto;
    overflow-y: hidden;
    overscroll-behavior-x: contain;
    padding-block: 2rem;
    scroll-behavior: smooth;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;

    &::-webkit-scrollbar {
      display: none;
    }

    // &::after {
    //   flex: 0 0 50vw;
    //   content: '';
    // }
  }

  .carousel-spacer {
    min-width: 2rem;
    flex: 0 0 50vw;
  }

  .carousel-image-container {
    position: relative;
    display: flex;
    overflow: hidden;
    max-width: 90vw;
    max-height: var(--ep-carousel-height);
    border-radius: var(--ep-carousel-border-radius);
    background-color: hsl(0 0% 0% / 0.05);
    transition: box-shadow 0.2s ease;
  }

  .carousel-item {
    flex: 0 0 auto;
    align-self: center;
    cursor: pointer;
    outline: none;
    scroll-snap-align: center;
    scroll-snap-stop: always;
    transition: opacity 0.3s ease, transform 0.3s ease;

    &.is-active {
      cursor: default;
    }

    &:focus-visible {
      .carousel-image-container {
        box-shadow: 0 0 0 3px var(--primary-color-base);
      }
    }
  }

  .carousel-image {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 0.3s ease;
  }

  .carousel-caption {
    position: absolute;
    padding: 0.75rem 1.5rem;
    background-color: hsla(0, 0%, 100%, 0.5);
    color: hsl(var(--gray-440));
    text-align: center;
    text-wrap: balance;

    // Position variants
    &.caption-top-left {
      top: 0;
      left: 0;
      border-radius: 0 0 var(--ep-carousel-caption-border-radius) 0;
    }

    &.caption-top-center {
      top: 0;
      left: 50%;
      border-radius: 0 0 var(--ep-carousel-caption-border-radius) var(--ep-carousel-caption-border-radius);
      transform: translateX(-50%);
    }

    &.caption-top-right {
      top: 0;
      right: 0;
      border-radius: 0 0 0 var(--ep-carousel-caption-border-radius);
    }

    &.caption-left-center {
      top: 50%;
      left: 0;
      border-radius: 0 var(--ep-carousel-caption-border-radius) var(--ep-carousel-caption-border-radius) 0;
      transform: translateY(-50%);
    }

    &.caption-center {
      top: 50%;
      left: 50%;
      border-radius: var(--ep-carousel-caption-border-radius);
      transform: translate(-50%, -50%);
    }

    &.caption-right-center {
      top: 50%;
      right: 0;
      border-radius: var(--ep-carousel-caption-border-radius) 0 0 var(--ep-carousel-caption-border-radius);
      transform: translateY(-50%);
    }

    &.caption-bottom-left {
      bottom: 0;
      left: 0;
      border-radius: 0 var(--ep-carousel-caption-border-radius) 0 0;
    }

    &.caption-bottom-center {
      bottom: 0;
      left: 50%;
      border-radius: var(--ep-carousel-caption-border-radius) var(--ep-carousel-caption-border-radius) 0 0;
      transform: translateX(-50%);
    }

    &.caption-bottom-right {
      right: 0;
      bottom: 0;
      border-radius: var(--ep-carousel-caption-border-radius) 0 0 0;
    }
  }

  .carousel-navigation {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 2rem;
  }

  .carousel-nav-button {
    --ep-button-bg-color: var(--interface-overlay);
    --ep-button-border-color: var(--border-color--lighter);
    --ep-button-text-color: var(--text-color--loud);
    --ep-button-hover-bg-color: var(--interface-overlay--accent);
    --ep-button-hover-border-color: var(--ep-button-border-color);
    --ep-button-hover-text-color: var(--ep-button-text-color);
    --ep-button-disabled-bg-color: transparent;
    --ep-button-disabled-border-color: transparent;
    --ep-button-disabled-text-color: hsl(from var(--ep-button-text-color) h s l / 0.3);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border-radius: 50%;
    aspect-ratio: 1 / 1;
    transition: all 0.3s ease;

    &:hover {
      transform: scale(1.1);
    }
  }

  .carousel-dots {
    display: flex;
    min-height: calc(0.75rem + 2rem);
    align-items: center;
    justify-content: center;
    padding: 1rem;
    gap: 1rem;
  }

  .carousel-dot {
    width: 1rem;
    height: 1rem;
    padding: 0;
    border: none;
    border-radius: 50%;
    background-color: hsl(from var(--primary-color-base) h s l / 0.3);
    cursor: pointer;
    transition: all 0.3s ease;

    &:focus-visible {
      outline: 2px solid var(--primary-color-base);
      outline-offset: 2px;
    }

    &:not(.is-active):hover {
      background-color: hsl(from var(--primary-color-base) h s l / 0.6);
      transform: scale(1.2);
    }

    &.is-active {
      background-color: var(--primary-color-base);
      cursor: default;
      transform: scale(1.5);
    }
  }

  @media (max-width: 768px) {
    .carousel-spacer {
      flex: 0 0 calc(50vw - 40vw / 2 - var(--ep-carousel-gap) / 2);
    }
  }
</style>