> ## Documentation Index
> Fetch the complete documentation index at: https://www.youhoc.com/llms.txt
> Use this file to discover all available pages before exploring further.

> 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.

# Tự làm Image Slideshow trong React, không cần SwipeJS

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.

```tsx components/ImageSlideshow.tsx theme={null}
'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>
  )
}
```

```css ImageSlideshow.module.css theme={null}
.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:

```tsx theme={null}
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:

```tsx theme={null}
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>
  )
}
```

```css theme={null}
.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:

```tsx theme={null}
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>
)
```

```css theme={null}
.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.

```tsx theme={null}
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>
  )
}
```

```css theme={null}
/* 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:

```tsx theme={null}
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

| Version        | Tính năng thêm                   | Kỹ thuật chính                            |
| -------------- | -------------------------------- | ----------------------------------------- |
| Cơ bản         | Tự chạy                          | `setInterval` + CSS `opacity`             |
| + Prev/Next    | Điều hướng thủ công              | Modulo `%` để wrap index                  |
| + Dots         | Click thẳng vào slide            | Map index → button                        |
| + Progress bar | Feedback trực quan Stories-style | CSS `@keyframes` + React `key` để restart |

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.
