Реалізація слайдера/каруселі на вебсайті
Слайдер — один з найбільш зловживаних UI-елементів і одночасно джерело болю з продуктивністю. Нативний CSS Scroll Snap розв'язує 70% випадків без JS. Для решти — Swiper.js.
CSS Scroll Snap: без JS
<div class="slider" role="region" aria-label="Слайдер">
<div class="slider__track">
<div class="slider__slide" id="slide-1">
<img src="/images/slide1.webp" alt="Слайд 1" loading="eager">
</div>
<div class="slider__slide" id="slide-2">
<img src="/images/slide2.webp" alt="Слайд 2" loading="lazy">
</div>
<div class="slider__slide" id="slide-3">
<img src="/images/slide3.webp" alt="Слайд 3" loading="lazy">
</div>
</div>
</div>
.slider {
overflow: hidden;
}
.slider__track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
}
.slider__track::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.slider__slide {
flex: 0 0 100%;
scroll-snap-align: start;
scroll-snap-stop: always; /* не пропускати слайди при швидкому свайпі */
}
.slider__slide img {
width: 100%;
height: 400px;
object-fit: cover;
display: block;
}
// Навігація через JS (опціонально)
function goToSlide(index: number) {
const track = document.querySelector('.slider__track')!
const slide = track.children[index] as HTMLElement
slide.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' })
}
// Визначаємо поточний слайд через IntersectionObserver
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = Array.from(entry.target.parentElement!.children).indexOf(entry.target)
updateDots(index)
}
})
}, { root: document.querySelector('.slider__track'), threshold: 0.5 })
document.querySelectorAll('.slider__slide').forEach(slide => observer.observe(slide))
Swiper.js: повнофункціональна карусель
npm install swiper
import { Swiper, SwiperSlide } from 'swiper/react'
import { Navigation, Pagination, Autoplay, A11y, Thumbs, FreeMode } from 'swiper/modules'
import { useState } from 'react'
import 'swiper/css'
import 'swiper/css/navigation'
import 'swiper/css/pagination'
import 'swiper/css/thumbs'
interface Slide {
src: string
alt: string
caption?: string
link?: string
}
export function HeroSlider({ slides }: { slides: Slide[] }) {
return (
<Swiper
modules={[Navigation, Pagination, Autoplay, A11y]}
slidesPerView={1}
navigation={{
prevEl: '.swiper-btn-prev',
nextEl: '.swiper-btn-next',
}}
pagination={{ clickable: true, dynamicBullets: true }}
autoplay={{ delay: 5000, disableOnInteraction: true, pauseOnMouseEnter: true }}
loop={true}
grabCursor={true}
a11y={{
prevSlideMessage: 'Попередній слайд',
nextSlideMessage: 'Наступний слайд',
paginationBulletMessage: 'Перейти до слайду {{index}}',
}}
keyboard={{ enabled: true }}
speed={600}
className="hero-slider"
>
{slides.map((slide, i) => (
<SwiperSlide key={i}>
<figure className="hero-slider__slide">
{slide.link
? <a href={slide.link}><img src={slide.src} alt={slide.alt} loading={i === 0 ? 'eager' : 'lazy'} /></a>
: <img src={slide.src} alt={slide.alt} loading={i === 0 ? 'eager' : 'lazy'} />
}
{slide.caption && <figcaption>{slide.caption}</figcaption>}
</figure>
</SwiperSlide>
))}
<button className="swiper-btn-prev" aria-label="Попередній">‹</button>
<button className="swiper-btn-next" aria-label="Наступний">›</button>
</Swiper>
)
}
Карусель товарів з брейкпоінтами
export function ProductCarousel({ products }: { products: Product[] }) {
return (
<Swiper
modules={[Navigation, FreeMode]}
navigation
freeMode={{ enabled: true, sticky: false }}
slidesPerView={1.2}
spaceBetween={16}
breakpoints={{
480: { slidesPerView: 2.2, spaceBetween: 16 },
768: { slidesPerView: 3.2, spaceBetween: 20 },
1024: { slidesPerView: 4, spaceBetween: 24, freeMode: { enabled: false } },
1280: { slidesPerView: 5, spaceBetween: 24 },
}}
grabCursor={true}
>
{products.map(product => (
<SwiperSlide key={product.id}>
<ProductCard product={product} />
</SwiperSlide>
))}
</Swiper>
)
}
Галерея з мініатюрами
export function GalleryWithThumbs({ images }: { images: string[] }) {
const [thumbsSwiper, setThumbsSwiper] = useState<any>(null)
return (
<div className="gallery-with-thumbs">
<Swiper
modules={[FreeMode, Navigation, Thumbs]}
thumbs={{ swiper: thumbsSwiper && !thumbsSwiper.destroyed ? thumbsSwiper : null }}
spaceBetween={10}
navigation={true}
className="gallery-main"
>
{images.map((src, i) => (
<SwiperSlide key={i}>
<img src={src} alt={`Фото ${i + 1}`} loading={i === 0 ? 'eager' : 'lazy'} />
</SwiperSlide>
))}
</Swiper>
<Swiper
onSwiper={setThumbsSwiper}
modules={[FreeMode, Thumbs]}
spaceBetween={8}
slidesPerView={5}
freeMode={true}
watchSlidesProgress={true}
className="gallery-thumbs"
>
{images.map((src, i) => (
<SwiperSlide key={i}>
<img src={src} alt={`Мініатюра ${i + 1}`} loading="lazy" />
</SwiperSlide>
))}
</Swiper>
</div>
)
}
Продуктивність: запобігання CLS
Слайдер без явних розмірів викликає Layout Shift при ініціалізації:
/* Резервуємо висоту до завантаження JS */
.swiper {
aspect-ratio: 16/9; /* або конкретна висота */
}
.swiper-slide img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Скелетон до ініціалізації */
.swiper:not(.swiper-initialized) {
background: #f1f5f9;
}
.swiper:not(.swiper-initialized) .swiper-slide:not(:first-child) {
display: none;
}
Автоплей з паузою на hover та focus
// Swiper вбудована пауза працює для hover
// Додаємо паузу для доступності при фокусі на слайді
const swiper = document.querySelector('.swiper')?.swiper
swiper?.el.addEventListener('focusin', () => swiper.autoplay.stop())
swiper?.el.addEventListener('focusout', () => swiper.autoplay.start())
Терміни
CSS Scroll Snap слайдер з навігацією — 3–4 години. Swiper з брейкпоінтами, автоплеєм та кастомною навігацією — 1 день. Складна карусель з мініатюрами, lazy-завантаженням, запобіганням CLS та доступністю — 1,5–2 дні.







