Khi cần một slideshow, phản xạ đầu tiên của nhiều người là mở npm lên và cài SwiperJS. Hoàn toàn hợp lý — Swiper mạnh, có sẵn mọi thứ. Nhưng nếu bạn chỉ cần một slideshow đơn giản, không có touch gesture, không có lazy load phức tạp, thì một vài chục dòng React là đủ.
Trong bài này, chúng ta sẽ tự xây từ con số 0 — bắt đầu từ bản đơn giản nhất, rồi lần lượt thêm nút điều hướng, numbered buttons, và cuối cùng là progress bar kiểu Stories cho ngầu.
Bước 1 — Slideshow cơ bản (chỉ CSS transition)
Ý tưởng cốt lõi rất đơn giản: render tất cả ảnh ra cùng lúc, nhưng chỉ một ảnh có opacity: 1, các ảnh còn lại opacity: 0. Mỗi 5 giây, chuyển class active sang ảnh tiếp theo.
// components/ImageSlideshow.tsx
'use client'
import { useState } from 'react'
import Image from 'next/image'
import burgerImg from '@/assets/burger.jpg'
import curryImg from '@/assets/curry.jpg'
import pizzaImg from '@/assets/pizza.jpg'
import classes from './ImageSlideshow.module.css'
const images = [
{ src: burgerImg, alt: 'A juicy burger' },
{ src: curryImg, alt: 'A spicy curry' },
{ src: pizzaImg, alt: 'A fresh pizza' },
]
export default function ImageSlideshow() {
const [currentIndex, setCurrentIndex] = useState(0)
return (
<div className={classes.slideshow}>
{images.map((image, index) => (
<Image
key={index}
src={image.src}
alt={image.alt}
className={index === currentIndex ? classes.active : ''}
/>
))}
</div>
)
}
/* ImageSlideshow.module.css */
.slideshow {
position: relative;
width: 100%;
height: 400px;
border-radius: 8px;
overflow: hidden;
}
.slideshow img {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute; /* xếp chồng tất cả ảnh lên nhau */
top: 0;
left: 0;
opacity: 0;
transform: scale(1.05);
transition: opacity 0.5s ease, transform 0.5s ease;
}
.slideshow .active {
opacity: 1;
transform: scale(1);
z-index: 1;
}
Hiện tại currentIndex không bao giờ thay đổi — slideshow đang đứng yên. Thêm useEffect để tự động chuyển ảnh:
import { useState, useEffect } from 'react'
// bên trong component:
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex(prev => (prev + 1) % images.length)
}, 5000)
return () => clearInterval(interval) // dọn dẹp khi unmount
}, [])
% images.length là trick để tự động quay vòng — khi index đến cuối mảng sẽ về lại 0.
Giả sử có 3 ảnh, images.length = 3, index hợp lệ là 0, 1, 2:
index 0 → (0 + 1) % 3 = 1 ✓
index 1 → (1 + 1) % 3 = 2 ✓
index 2 → (2 + 1) % 3 = 3 % 3 = 0 ← quay về đầu
% (modulo) trả về phần dư của phép chia. 3 % 3 = 0 vì 3 chia 3 dư 0. 4 % 3 = 1 vì 4 chia 3 dư 1 — nên nó luôn giữ kết quả trong khoảng 0 đến length - 1.
Tương tự cho nút Prev, thêm images.length trước để tránh số âm:
index 0 → (0 - 1 + 3) % 3 = 2 % 3 = 2 ← quay về cuối
index 1 → (1 - 1 + 3) % 3 = 3 % 3 = 0 ✓
index 2 → (2 - 1 + 3) % 3 = 4 % 3 = 1 ✓
Nếu không cộng + 3, (0 - 1) % 3 = -1 — index âm, không dùng được.
Bước 2 — Thêm nút Prev / Next
Thêm hai hàm điều hướng và render hai nút:
export default function ImageSlideshow() {
const [currentIndex, setCurrentIndex] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex(prev => (prev + 1) % images.length)
}, 5000)
return () => clearInterval(interval)
}, [])
function goToPrev() {
setCurrentIndex(prev => (prev - 1 + images.length) % images.length)
}
function goToNext() {
setCurrentIndex(prev => (prev + 1) % images.length)
}
return (
<div className={classes.slideshow}>
{images.map((image, index) => (
<Image
key={index}
src={image.src}
alt={image.alt}
className={index === currentIndex ? classes.active : ''}
/>
))}
<button className={classes.prev} onClick={goToPrev}>‹</button>
<button className={classes.next} onClick={goToNext}>›</button>
</div>
)
}
.prev,
.next {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
background: rgba(0, 0, 0, 0.4);
color: white;
border: none;
padding: 0.5rem 1rem;
font-size: 1.5rem;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.prev:hover,
.next:hover {
background: rgba(0, 0, 0, 0.7);
}
.prev { left: 0.75rem; }
.next { right: 0.75rem; }
Lưu ý công thức (prev - 1 + images.length) % images.length cho nút Prev — cộng thêm images.length trước khi mod để tránh kết quả âm khi prev = 0.
Bước 3 — Thêm Numbered Slide Buttons (Dots)
Thêm một row dots phía dưới, mỗi dot tương ứng một slide:
return (
<div className={classes.slideshow}>
{/* ... ảnh và nút prev/next ... */}
<div className={classes.dots}>
{images.map((_, index) => (
<button
key={index}
className={`${classes.dot} ${index === currentIndex ? classes.dotActive : ''}`}
onClick={() => setCurrentIndex(index)}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
)
.dots {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 2;
display: flex;
gap: 0.5rem;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 0;
transition: background 0.2s, transform 0.2s;
}
.dot:hover {
background: rgba(255, 255, 255, 0.8);
}
.dotActive {
background: white;
transform: scale(1.25);
}
Khi click vào một dot, setCurrentIndex(index) đưa trực tiếp đến slide đó — không cần next/prev liên tục.
Bước 4 — Auto-play với Progress Bar kiểu Stories
Đây là phần thú vị nhất. Thay vì một thanh progress bar “đếm ngược” chung cho toàn bộ slideshow, chúng ta làm theo kiểu Instagram Stories: mỗi slide có một thanh riêng, thanh của slide đang hiển thị sẽ fill từ 0% đến 100% trong 5 giây.
Trick ở đây là dùng CSS @keyframes animation kết hợp với key prop của React. Khi key thay đổi, React unmount và remount element — điều này buộc animation CSS restart từ đầu.
export default function ImageSlideshow() {
const [currentIndex, setCurrentIndex] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex(prev => (prev + 1) % images.length)
}, 5000)
return () => clearInterval(interval)
}, [])
function goToPrev() {
setCurrentIndex(prev => (prev - 1 + images.length) % images.length)
}
function goToNext() {
setCurrentIndex(prev => (prev + 1) % images.length)
}
return (
<div className={classes.slideshow}>
{/* Progress bars */}
<div className={classes.progressBars}>
{images.map((_, index) => (
<div key={index} className={classes.progressTrack}>
{index === currentIndex && (
// key={currentIndex} buộc React remount element này
// mỗi khi slide đổi → animation restart từ đầu
<div key={currentIndex} className={classes.progressFill} />
)}
{index < currentIndex && (
// slide đã xem: fill 100% tĩnh
<div className={classes.progressFillComplete} />
)}
</div>
))}
</div>
{/* Ảnh */}
{images.map((image, index) => (
<Image
key={index}
src={image.src}
alt={image.alt}
className={index === currentIndex ? classes.active : ''}
/>
))}
<button className={classes.prev} onClick={goToPrev}>‹</button>
<button className={classes.next} onClick={goToNext}>›</button>
</div>
)
}
/* Progress bar Stories */
.progressBars {
position: absolute;
top: 0.75rem;
left: 0.75rem;
right: 0.75rem;
z-index: 2;
display: flex;
gap: 4px;
}
.progressTrack {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.35);
border-radius: 2px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: white;
border-radius: 2px;
width: 0%;
animation: fillProgress 5s linear forwards;
}
.progressFillComplete {
height: 100%;
background: white;
border-radius: 2px;
width: 100%;
}
@keyframes fillProgress {
from { width: 0%; }
to { width: 100%; }
}
Animation fillProgress chạy trong 5s — khớp với interval của setInterval. Mỗi lần slide đổi, key={currentIndex} thay đổi và React remount <div className={progressFill}>, animation bắt đầu lại từ 0%.
Các slide đã xem (index < currentIndex) hiển thị thanh trắng đầy tĩnh. Các slide chưa đến thì chỉ có track xám.
Lưu ý thực tế
Đừng để interval và animation lệch nhau. Nếu setInterval là 5000ms mà animation là 4s, thanh sẽ đầy trước khi slide chuyển — trông sẽ lạ. Luôn giữ hai giá trị này đồng bộ. Cách tốt hơn là define một constant:
const SLIDE_DURATION = 5000 // ms
// dùng cho cả interval...
setInterval(() => { ... }, SLIDE_DURATION)
// ...và CSS (inline style hoặc CSS variable)
<div
className={classes.progressFill}
style={{ animationDuration: `${SLIDE_DURATION}ms` }}
/>
Pause khi hover. Nếu muốn dừng slideshow khi người dùng hover vào, dùng onMouseEnter / onMouseLeave để clear và set lại interval. Cách gọn nhất là dùng useRef để giữ reference của interval thay vì useEffect thuần.
Swipe trên mobile. Code trên không có touch gesture. Nếu cần, đây là lúc cân nhắc SwiperJS hoặc react-swipeable — đừng tự handle touchstart/touchmove bằng tay nếu không có nhu cầu học sâu về nó.
Tóm tắt
Từ một slideshow tự chạy đến Stories-style chỉ cần khoảng 80 dòng code và không có dependency nào ngoài React. Khi nào cần thêm touch, infinite loop mượt hơn, hay lazy load — lúc đó mới cần đến Swiper.