Kamil Owczarek
Opublikowano

Generyczny komponent karuzeli w Vue 3 i TypeScript

Autorzy

Problem: za dużo komponentów karuzeli

Nasz sklep potrzebował karuzeli praktycznie wszędzie:

  • Karuzele produktów (4 widoczne elementy, responsywne)
  • Hero banery (1 widoczny element, autoplay)
  • Karuzele stref i kategorii (elementy o zmiennej szerokości)
  • Wybór wykończenia (poziomy scroll, malutkie elementy)

Naiwne podejście: zrobić 4 osobne komponenty karuzeli. Efekt: zduplikowana logika, niespójne zachowanie i koszmar w utrzymaniu.

Rozwiązanie: generics w Vue 3

Vue 3.3 wprowadziło atrybut generic dla script setup. Dzięki niemu można pisać naprawdę type-safe komponenty generyczne:

<script setup lang="ts" generic="T">
type Props = {
  items: readonly T[]
  // ... other props
}

const props = defineProps<Props>()
</script>

Od tego momentu komponent przyjmuje tablicę dowolnego typu, a TypeScript wie, czym jest T w całym template i we wszystkich slotach.

Architektura komponentu

Tak wygląda rdzeń:

<script setup lang="ts" generic="T">
type SlideConfig = 'default' | 'zones' | 'banner' | 'finishes'

type AutoplayConfig = {
  delay?: number
  pauseOnHover?: boolean
}

type Props = {
  title?: string
  items: readonly T[]
  isLoading?: boolean
  itemHeight?: string
  variant?: SlideConfig
  autoplay?: boolean | AutoplayConfig
  loop?: boolean
  showNavigation?: boolean
  showPagination?: boolean
  navigationStyle?: 'default' | 'circular-white'
  navigationPosition?: 'top' | 'bottom'
}

const props = withDefaults(defineProps<Props>(), {
  isLoading: false,
  itemHeight: '450px',
  variant: 'default',
  autoplay: false,
  loop: false,
  showNavigation: true,
  showPagination: false,
  navigationStyle: 'default',
  navigationPosition: 'top',
})
</script>

Kluczowe decyzje projektowe

1. readonly T[] zamiast T[]

items: readonly T[];

Blokuje przypadkową mutację tablicy z elementami i pozwala przekazywać tablice oznaczone as const.

2. Konfiguracja oparta na wariantach

Zamiast kilkunastu propsów typu boolean używamy jednego variant, który spina powiązane ze sobą ustawienia:

type SlideConfig = 'default' | 'zones' | 'banner' | 'finishes'

Każdy wariant niesie ze sobą własny zestaw domyślnych ustawień:

  • banner: pełna szerokość, autoplay, paginacja
  • zones: częściowa szerokość, dużo widocznych elementów
  • finishes: malutkie elementy, poziomy scroll

3. Elastyczna konfiguracja autoplay

autoplay?: boolean | AutoplayConfig;

Proste użycie:

<CustomCarousel :items="products" autoplay />

Pełna kontrola:

<CustomCarousel :items="banners" :autoplay="{ delay: 8000, pauseOnHover: false }" />

Stylowanie zależne od wariantu

Każdy wariant potrzebuje innej szerokości slajdu:

const slideClasses = computed(() => {
  switch (props.variant) {
    case 'banner':
      return 'w-full'
    case 'zones':
      return 'w-[85%] sm:w-[45%] md:w-[30%] xl:w-[23%]'
    case 'finishes':
      return 'w-auto flex-shrink-0'
    default:
      return 'w-[85%] sm:w-[45%] md:w-[30%] xl:w-[22%]'
  }
})

Responsywne breakpointy pilnują, żeby na każdej szerokości ekranu widać było odpowiednią liczbę elementów.

Nawigacja oparta na scrollu

Zamiast rozbudowanych bibliotek korzystamy z natywnego scrolla:

const carouselRef = ref<HTMLElement | null>(null)
const currentSlide = ref(0)

const scrollToSlide = (index: number, smooth = true) => {
  if (!carouselRef.value) return

  const slideWidth = carouselRef.value.clientWidth
  carouselRef.value.scrollTo({
    left: slideWidth * index,
    behavior: smooth ? 'smooth' : 'auto',
  })
}

const scrollToDirection = (direction: 'left' | 'right') => {
  if (!carouselRef.value) return

  if (isBannerMode.value) {
    // Banner: snap to slides
    const nextSlide =
      direction === 'right'
        ? (currentSlide.value + 1) % props.items.length
        : (currentSlide.value - 1 + props.items.length) % props.items.length
    scrollToSlide(nextSlide)
  } else {
    // Grid: scroll by viewport width
    const scrollAmount = carouselRef.value.clientWidth
    carouselRef.value.scrollBy({
      left: direction === 'right' ? scrollAmount : -scrollAmount,
      behavior: 'smooth',
    })
  }
}

Za snapowanie odpowiada CSS:

<template>
  <div
    ref="carouselRef"
    class="flex gap-4 overflow-x-auto scroll-smooth"
    :class="{ 'snap-x snap-mandatory': isBannerMode }"
    @scroll="updateCurrentSlide"
  >
    <div
      v-for="(item, index) in items"
      :key="index"
      :class="[slideClasses, { 'snap-center': isBannerMode }]"
    >
      <slot :item="item" :index="index" />
    </div>
  </div>
</template>

Implementacja autoplay

const autoplayInterval = ref<NodeJS.Timeout | null>(null)
const isHovered = ref(false)
const progressKey = ref(0)

const autoplayConfig = computed<AutoplayConfig>(() => {
  if (typeof props.autoplay === 'boolean') {
    return props.autoplay ? { delay: 6000, pauseOnHover: true } : { delay: 0 }
  }
  return { delay: 6000, pauseOnHover: true, ...props.autoplay }
})

const startAutoplay = () => {
  if (!autoplayConfig.value.delay) return
  if (!isBannerMode.value) return
  if (props.items.length <= 1) return

  stopAutoplay()
  progressKey.value++ // Reset progress animation

  autoplayInterval.value = setInterval(() => {
    if (!isHovered.value || !autoplayConfig.value.pauseOnHover) {
      const nextSlide = (currentSlide.value + 1) % props.items.length
      scrollToSlide(nextSlide)
      progressKey.value++
    }
  }, autoplayConfig.value.delay)
}

const stopAutoplay = () => {
  if (autoplayInterval.value) {
    clearInterval(autoplayInterval.value)
    autoplayInterval.value = null
  }
}

// Start autoplay when mounted, stop on unmount
onMounted(() => startAutoplay())
onUnmounted(() => stopAutoplay())

Otypowane scoped sloty

Generyczne T przechodzi aż do slotu:

<template>
  <slot :item="item" :index="index" />
</template>

Użycie z pełnym bezpieczeństwem typów:

<CustomCarousel :items="products">
  <template #default="{ item, index }">
    <!-- item is typed as Product -->
    <ProductCard :product="item" :position="index" />
  </template>
</CustomCarousel>

<CustomCarousel :items="banners">
  <template #default="{ item }">
    <!-- item is typed as Banner -->
    <BannerSlide :banner="item" />
  </template>
</CustomCarousel>

TypeScript wie, czym jest item, na podstawie tego, co przekazujesz do :items.

Warianty nawigacji

<template>
  <div v-if="shouldShowNavigation" class="flex gap-2">
    <button @click="scrollToDirection('left')" :class="navigationClasses">
      <ChevronLeftIcon />
    </button>
    <button @click="scrollToDirection('right')" :class="navigationClasses">
      <ChevronRightIcon />
    </button>
  </div>
</template>

<script setup>
const navigationClasses = computed(() => {
  if (props.navigationStyle === 'circular-white') {
    return 'bg-white rounded-full p-2 shadow-lg hover:bg-gray-100'
  }
  return 'bg-primary text-white rounded p-2 hover:bg-primary-dark'
})
</script>

Wykrywanie overflow

W wariancie finishes nawigacja pojawia się tylko wtedy, gdy zawartość faktycznie wychodzi poza kontener:

const isOverflowing = ref(false)

const checkOverflow = () => {
  if (!carouselRef.value) return
  isOverflowing.value = carouselRef.value.scrollWidth > carouselRef.value.clientWidth
}

onMounted(() => {
  checkOverflow()
  window.addEventListener('resize', checkOverflow)
})

onUnmounted(() => {
  window.removeEventListener('resize', checkOverflow)
})

const shouldShowNavigation = computed(() => {
  if (props.variant === 'finishes') {
    return isOverflowing.value && props.showNavigation
  }
  return props.showNavigation
})

Przykłady użycia

Karuzela produktów

<CustomCarousel :items="products" title="Featured Products" variant="default">
  <template #default="{ item }">
    <ProductCard :product="item" />
  </template>
</CustomCarousel>

Hero baner

<CustomCarousel
  :items="banners"
  variant="banner"
  :autoplay="{ delay: 8000 }"
  show-pagination
  navigation-style="circular-white"
>
  <template #default="{ item }">
    <img :src="item.image" :alt="item.title" class="w-full h-full object-cover" />
    <div class="absolute bottom-8 left-8 text-white">
      <h2 class="text-4xl font-bold">{{ item.title }}</h2>
    </div>
  </template>
</CustomCarousel>

Wybór strefy

<CustomCarousel :items="zones" variant="zones" :show-navigation="false">
  <template #default="{ item }">
    <ZoneCard :zone="item" />
  </template>
</CustomCarousel>

Wybór wykończenia

<CustomCarousel :items="finishes" variant="finishes" item-height="80px">
  <template #default="{ item }">
    <FinishSwatch :finish="item" class="w-16 h-16" />
  </template>
</CustomCarousel>

Dlaczego bez zewnętrznej biblioteki?

Na start spróbowaliśmy Swiper.js. Problemy, na jakie trafiliśmy:

  • Rozmiar bundle'a (ponad 50 KB)
  • Niezgodności przy hydracji w SSR
  • Rozbudowana konfiguracja do bardzo prostych potrzeb
  • Konflikty stylów z naszym design systemem

Natywny scroll plus CSS snap dają nam:

  • Zero narzutu na bundle
  • Pełną kompatybilność z SSR
  • Pełną kontrolę nad stylowaniem
  • Lepszą wydajność na mobile

Pełny template komponentu

<template>
  <div class="relative">
    <!-- Title and Navigation -->
    <div v-if="title || shouldShowNavigation" class="mb-4 flex items-center justify-between">
      <h2 v-if="title" class="text-2xl font-bold">{{ title }}</h2>
      <div v-if="shouldShowNavigation && navigationPosition === 'top'" class="flex gap-2">
        <button @click="scrollToDirection('left')" :class="navigationClasses">
          <ChevronLeftIcon class="h-5 w-5" />
        </button>
        <button @click="scrollToDirection('right')" :class="navigationClasses">
          <ChevronRightIcon class="h-5 w-5" />
        </button>
      </div>
    </div>

    <!-- Carousel Container -->
    <div
      ref="carouselRef"
      class="scrollbar-hide flex gap-4 overflow-x-auto scroll-smooth"
      :class="{ 'snap-x snap-mandatory': isBannerMode }"
      :style="{ height: itemHeight }"
      @scroll="updateCurrentSlide"
      @mouseenter="isHovered = true"
      @mouseleave="isHovered = false"
    >
      <!-- Loading Skeleton -->
      <template v-if="isLoading">
        <div
          v-for="n in 4"
          :key="n"
          :class="slideClasses"
          class="flex-shrink-0 animate-pulse rounded-lg bg-gray-200"
        />
      </template>

      <!-- Items -->
      <template v-else>
        <div
          v-for="(item, index) in items"
          :key="index"
          :class="[slideClasses, { 'snap-center': isBannerMode }]"
          class="flex-shrink-0"
        >
          <slot :item="item" :index="index" />
        </div>
      </template>
    </div>

    <!-- Pagination Dots -->
    <div
      v-if="showPagination && isBannerMode && items.length > 1"
      class="mt-4 flex justify-center gap-2"
    >
      <button
        v-for="(_, index) in items"
        :key="index"
        @click="scrollToSlide(index)"
        class="h-3 w-3 rounded-full transition-colors"
        :class="currentSlide === index ? 'bg-primary' : 'bg-gray-300'"
      />
    </div>
  </div>
</template>

Najważniejsze wnioski

  1. Generics w Vue 3 pozwalają pisać naprawdę type-safe komponenty do wielokrotnego użytku
  2. Propsy wariantowe spinają powiązaną konfigurację i dają czystsze API
  3. Natywny scroll plus CSS snap często wygrywa z zewnętrznymi bibliotekami karuzel
  4. Klasy liczone w computed trzymają logikę wariantów w jednym miejscu
  5. Scoped sloty z generics dają konsumentom komponentu pełne bezpieczeństwo typów

Jeden komponent, 4 warianty, zero duplikacji kodu, pełne bezpieczeństwo typów.


Prawdziwy komponent, który napędza karuzele produktów, hero banery i wybór kategorii w platformie e-commerce. Ponad 30 iteracji zszlifowanych do tej architektury.