- 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, paginacjazones: częściowa szerokość, dużo widocznych elementówfinishes: 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
- Generics w Vue 3 pozwalają pisać naprawdę type-safe komponenty do wielokrotnego użytku
- Propsy wariantowe spinają powiązaną konfigurację i dają czystsze API
- Natywny scroll plus CSS snap często wygrywa z zewnętrznymi bibliotekami karuzel
- Klasy liczone w computed trzymają logikę wariantów w jednym miejscu
- 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.