【Next.js】ライブラリ使わないでカルーセルスライダー

demo(Next.jsの静的ビルドファイルをiframeで表示したもの)

Next.jsを使って無限ループ機能付きのシンプルなカルーセルを実装します(画像スライドショーの基本的な機能をおさえつつ、メンテナンスしやすいコードを目指します)

このカルーセルには以下の機能が含まれています:

  • ✨ 無限ループスライド
  • 🎯 自動再生機能
  • 🎨 矢印ナビゲーション
  • 📍 インジケーター
  • 📱 レスポンシブ対応

ソースファイル

my-nextjs-app/
├── src/
│   ├── app/
│   │   └── page.tsx                    # カルーセルを使用するページ
│   ├── components/
│   │   └── SimpleCarousel.tsx          # カルーセルコンポーネント
│   └── styles/
│       └── simpleCarousel.module.css   # カルーセル用スタイル

page.tsx

/**
 * カルーセルページコンポーネント
 */

import React from 'react';
import SimpleCarousel from '@/components/simpleCarousel';

export default function CarouselPage() {
  // スライドのデータ定義
  // - 外部のAPIから取得する場合はここを修正
  const slides = [
    {
      id: 1,
      src: "https://picsum.photos/800/400",
      alt: "First slide"
    },
    {
      id: 2,
      src: "https://picsum.photos/800/400?random=1",
      alt: "Second slide"
    },
    {
      id: 3,
      src: "https://picsum.photos/800/400?random=2",
      alt: "Third slide"
    }
  ];

  return (
    <div className="container mx-auto p-4">
      <SimpleCarousel 
        slides={slides}
        autoPlayInterval={3000} // 自動再生の間隔(ミリ秒)
        showArrows={true}      // 矢印ナビゲーションの表示
        showIndicators={true}  // インジケーターの表示
      />
    </div>
  );
}

simpleCarousel.tsx

/**
 * シンプルなカルーセルコンポーネント
 * 
 * 特徴:
 * - 無限ループ機能
 * - 自動再生
 * - 矢印ナビゲーション
 * - インジケーター
 * - レスポンシブ対応
 */

'use client'; // クライアントサイドでの実行を指定

import React, { useState, useRef, useEffect, useCallback } from 'react';
import styles from '@/styles/simpleCarousel.module.css';

// スライドデータの型定義
interface SlideData {
  id: number;    // スライドの一意のID
  src: string;   // 画像のURL
  alt: string;   // 代替テキスト
}

// コンポーネントのプロパティの型定義
interface CarouselProps {
  slides: SlideData[];        // スライドデータの配列
  autoPlayInterval?: number;  // 自動再生の間隔(ミリ秒)
  showArrows?: boolean;      // 矢印ナビゲーションの表示/非表示
  showIndicators?: boolean;  // インジケーターの表示/非表示
}

const SimpleCarousel: React.FC<CarouselProps> = ({
  slides,
  autoPlayInterval = 3000,
  showArrows = true,
  showIndicators = true
}) => {
  // State管理
  const [currentSlide, setCurrentSlide] = useState<number>(slides.length);  // 現在のスライドインデックス
  const [isTransitioning, setIsTransitioning] = useState(false);           // トランジション中かどうか
  const slideContainerRef = useRef<HTMLDivElement>(null);                  // スライドコンテナのref

  // 無限ループのためのスライド配列を作成(前後に1セットずつ追加)
  const extendedSlides = [...slides, ...slides, ...slides];

  // スライド変更のハンドラー(useCallbackでメモ化)
  const handleSlideChange = useCallback((direction: 'next' | 'prev'): void => {
    if (isTransitioning) return;  // トランジション中は処理をスキップ
    
    setIsTransitioning(true);
    if (direction === 'next') {
      setCurrentSlide(prev => prev + 1);
    } else {
      setCurrentSlide(prev => prev - 1);
    }
  }, [isTransitioning]);

  // 自動再生のための効果
  useEffect(() => {
    const timer = setInterval(() => {
      handleSlideChange('next');
    }, autoPlayInterval);

    // クリーンアップ関数
    return () => clearInterval(timer);
  }, [autoPlayInterval, handleSlideChange]);

  // トランジション終了時の処理
  useEffect(() => {
    if (!isTransitioning) return;

    const transitionEndHandler = () => {
      setIsTransitioning(false);
      
      // 最後のクローンまで来た場合、最初に戻す
      if (currentSlide >= slides.length * 2) {
        setCurrentSlide(slides.length);
      }
      // 最初のクローンより前に来た場合
      else if (currentSlide < slides.length) {
        setCurrentSlide(slides.length * 2 - 1);
      }
    };

    const container = slideContainerRef.current;
    container?.addEventListener('transitionend', transitionEndHandler);

    // クリーンアップ関数
    return () => {
      container?.removeEventListener('transitionend', transitionEndHandler);
    };
  }, [currentSlide, slides.length, isTransitioning]);

  // 特定のスライドに直接移動する関数
  const goToSlide = (index: number): void => {
    if (isTransitioning) return;
    setIsTransitioning(true);
    setCurrentSlide(index + slides.length);
  };

  // 実際のスライドインデックスを計算(表示用)
  const actualSlideIndex = ((currentSlide % slides.length) + slides.length) % slides.length;

  return (
    <div className={styles.carousel}>
      <div className={styles.slideContainer} ref={slideContainerRef}>
        {/* スライドラッパー */}
        <div 
          className={styles.slideWrapper}
          style={{ 
            transform: `translateX(-${currentSlide * 100}%)`,
            transition: isTransitioning ? 'transform 0.5s ease-in-out' : 'none'
          }}
        >
          {/* スライドの表示 */}
          {extendedSlides.map((slide, index) => (
            <div 
              key={`${slide.id}-${index}`} 
              className={styles.slide}
              style={{ position: 'relative', overflow: 'hidden', width: '100%', height: '400px' }}
            >
              {/* eslint-disable-next-line @next/next/no-img-element */}
              <img
                src={slide.src}
                alt={slide.alt}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  height: '100%',
                  objectFit: 'cover'
                }}
              />
            </div>
          ))}
        </div>

        {/* 矢印ナビゲーション */}
        {showArrows && (
          <>
            <button
              onClick={() => handleSlideChange('prev')}
              className={`${styles.arrowButton} ${styles.prevButton}`}
              aria-label="Previous slide"
              disabled={isTransitioning}
            >
              ←
            </button>
            <button
              onClick={() => handleSlideChange('next')}
              className={`${styles.arrowButton} ${styles.nextButton}`}
              aria-label="Next slide"
              disabled={isTransitioning}
            >
              →
            </button>
          </>
        )}

        {/* インジケーター */}
        {showIndicators && (
          <div className={styles.indicators}>
            {slides.map((_, index) => (
              <button
                key={index}
                onClick={() => goToSlide(index)}
                className={`${styles.indicator} ${
                  actualSlideIndex === index ? styles.indicatorActive : ''
                }`}
                aria-label={`Go to slide ${index + 1}`}
                disabled={isTransitioning}
              />
            ))}
          </div>
        )}
      </div>
    </div>
  );
};

export default SimpleCarousel;

simpleCarousel.module.css

/* styles/simpleCarousel.module.css */
.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 2rem 1rem;
  }
  
  .title {
    font-size: 1.5rem;
    font-weight: bold;
    margin-bottom: 2rem;
  }
  
  .carousel {
    position: relative;
    width: 100%;
    max-width: 48rem;
    margin: 0 auto;
    overflow: hidden;
  }
  
  .slideContainer {
    position: relative;
    overflow: hidden;
    border-radius: 0.5rem;
    aspect-ratio: 2/1;
  }
  
  .slideWrapper {
    position: absolute;
    display: flex;
    width: 100%;
    height: 100%;
  }
  
  .slide {
    flex: 0 0 100%;
    width: 100%;
    height: 100%;
  }
  
  .slide img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  
  .arrowButton {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    background-color: rgba(255, 255, 255, 0.8);
    border-radius: 50%;
    padding: 0.5rem;
    cursor: pointer;
    border: none;
    z-index: 1;
  }
  
  .arrowButton:hover {
    background-color: rgb(255, 255, 255);
  }
  
  .prevButton {
    left: 1rem;
  }
  
  .nextButton {
    right: 1rem;
  }
  
  .indicators {
    position: absolute;
    bottom: 1rem;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    gap: 0.5rem;
    z-index: 1;
  }
  
  .indicator {
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 50%;
    background-color: rgba(255, 255, 255, 0.5);
    border: none;
    cursor: pointer;
    padding: 0;
  }
  
  .indicatorActive {
    background-color: rgb(255, 255, 255);
  }

注意点と対策
(Hot Module Replacement: HMR)に関連するエラー

Next.jsの開発環境でのホットリロード(Hot Module Replacement: HMR)に関連するエラーが発生します。

エラーの原因

  1. CSSモジュールを使用している際に、HMRがCSSファイルを更新しようとする
  2. 古いスタイルシートを削除しようとする際に、すでに要素が削除されているためエラーが発生
    (無限にスライド流れるようクローンを作成する際の記述)

対策

グローバルCSSとして管理

/* globals.css */
.carousel { ... }

CSS-in-JSライブラリの使用

import styled from 'styled-components';

next.config.jsでの設定

module.exports = {
webpack: (config) => {
// HMRの設定をカスタマイズ
return config;
}
}