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)に関連するエラーが発生します。
エラーの原因
- CSSモジュールを使用している際に、HMRがCSSファイルを更新しようとする
- 古いスタイルシートを削除しようとする際に、すでに要素が削除されているためエラーが発生
(無限にスライド流れるようクローンを作成する際の記述)
対策
グローバルCSSとして管理:
/* globals.css */
.carousel { ... }
CSS-in-JSライブラリの使用:
import styled from 'styled-components';
next.config.jsでの設定:
module.exports = {
webpack: (config) => {
// HMRの設定をカスタマイズ
return config;
}
}