Implementing Slider/Carousel on Website
Slider is one of the most overused UI elements and simultaneously a source of performance pain. Native CSS Scroll Snap solves 70% of cases without JS. For the rest — Swiper.js.
CSS Scroll Snap: Without JS
<div class="slider" role="region" aria-label="Slider">
<div class="slider__track">
<div class="slider__slide" id="slide-1">
<img src="/images/slide1.webp" alt="Slide 1" loading="eager">
</div>
<div class="slider__slide" id="slide-2">
<img src="/images/slide2.webp" alt="Slide 2" loading="lazy">
</div>
<div class="slider__slide" id="slide-3">
<img src="/images/slide3.webp" alt="Slide 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; /* don't skip slides on fast swipe */
}
.slider__slide img {
width: 100%;
height: 400px;
object-fit: cover;
display: block;
}
// Navigation via JS (optional)
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' })
}
// Determine current slide via 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: Full-Featured Carousel
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: 'Previous slide',
nextSlideMessage: 'Next slide',
paginationBulletMessage: 'Go to slide {{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="Previous">‹</button>
<button className="swiper-btn-next" aria-label="Next">›</button>
</Swiper>
)
}
Product Carousel with Breakpoints
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>
)
}
Gallery with Thumbnails
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={`Photo ${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={`Thumbnail ${i + 1}`} loading="lazy" />
</SwiperSlide>
))}
</Swiper>
</div>
)
}
Performance: Preventing CLS
Slider without explicit sizes causes Layout Shift on initialization:
/* Reserve height before JS loads */
.swiper {
aspect-ratio: 16/9; /* or specific height */
}
.swiper-slide img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Skeleton until initialization */
.swiper:not(.swiper-initialized) {
background: #f1f5f9;
}
.swiper:not(.swiper-initialized) .swiper-slide:not(:first-child) {
display: none;
}
Autoplay with Pause on Hover and Focus
// Swiper built-in pause works for hover
// Add pause for accessibility on slide focus
const swiper = document.querySelector('.swiper')?.swiper
swiper?.el.addEventListener('focusin', () => swiper.autoplay.stop())
swiper?.el.addEventListener('focusout', () => swiper.autoplay.start())
Timeline
CSS Scroll Snap slider with navigation — 3–4 hours. Swiper with breakpoints, autoplay, custom navigation — 1 day. Complex carousel with thumbnails, lazy loading, CLS prevention, accessibility — 1.5–2 days.







