こんにちは、かつコーチです。
前回はモーダルウィンドウの表示・非表示を実装しました。
実践UI実装シリーズの最後は、トップページなどでよく見るスライダー(カルーセル)です。
これまでの「開く・閉じる」の2択とは違い、「複数枚の画像を順番に切り替える」「自動再生する」といった、状態管理がひとつ複雑になるテーマです。
少し難易度が上がるので、中級者向けとして扱います。
スライダー・カルーセルとは?
基本的な仕組み
スライダー(カルーセルとも呼ばれます)は、複数の画像やコンテンツを1枚ずつ切り替えて表示するUIです。
- トップページのメインビジュアル
- 商品ページの複数画像ギャラリー
- お客様の声を横にスクロールして紹介するコーナー
やっていること自体は「複数の要素のうち、1つだけを表示する」というタブUIと似ています。
ただしスライダーでは、これに加えて「前へ・次へボタン」「自動で切り替わるタイマー」「現在何枚目かを示すインジケーター」といった要素が絡んでくるため、管理する状態が一気に増えます。
実装方針を先に整理する
複雑になりがちなテーマなので、コードを書く前に「何を状態として管理するか」を整理しておきます。
- 現在のスライド番号(
currentIndex) - スライドの総数(
slideCount) - 自動再生のタイマーID(止めるときに必要)
この3つを変数として持っておけば、ボタン操作・自動再生・インジケーターのどこから呼ばれても、同じロジックで「表示するスライド」を決められます。
基本の実装手順
手順1:HTMLでスライドを並べる
<div class="slider">
<div class="slider__track" id="sliderTrack">
<div class="slider__slide"><img src="slide1.jpg" alt="スライド1"></div>
<div class="slider__slide"><img src="slide2.jpg" alt="スライド2"></div>
<div class="slider__slide"><img src="slide3.jpg" alt="スライド3"></div>
</div>
<button class="slider__btn slider__btn--prev" id="prevBtn" aria-label="前へ">‹</button>
<button class="slider__btn slider__btn--next" id="nextBtn" aria-label="次へ">›</button>
<div class="slider__dots" id="sliderDots"></div>
</div>
インジケーター(ドット)は、スライド枚数によって数が変わるため、あえてHTMLには書かず、JavaScriptから動的に生成する方針にしています。
手順2:CSSで横並びと切り替えの土台を作る
.slider {
position: relative;
overflow: hidden;
max-width: 600px;
}
.slider__track {
display: flex;
transition: transform 0.4s ease;
}
.slider__slide {
min-width: 100%;
}
.slider__slide img {
width: 100%;
display: block;
}
.slider__btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.4);
color: #fff;
border: none;
font-size: 24px;
width: 36px;
height: 36px;
cursor: pointer;
}
.slider__btn--prev { left: 8px; }
.slider__btn--next { right: 8px; }
.slider__dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.slider__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ccc;
cursor: pointer;
}
.slider__dot.is-active {
background: #333;
}
すべてのスライドを .slider__track の中に横並び(display: flex)で配置し、.slider 側で overflow: hidden によって表示範囲を1枚分に制限しています。
見せたいスライドを切り替えるには、.slider__track 全体を transform: translateX() でスライドの幅ぶん左右に動かす、というのが基本の仕組みです。
手順3:JavaScriptで切り替えロジックを実装する
const track = document.getElementById('sliderTrack');
const slides = track.children;
const slideCount = slides.length;
const dotsContainer = document.getElementById('sliderDots');
const prevBtn = document.getElementById('prevBtn');
const nextBtn = document.getElementById('nextBtn');
let currentIndex = 0;
// インジケーターをスライド枚数分だけ動的に生成する
for (let i = 0; i < slideCount; i++) {
const dot = document.createElement('button');
dot.classList.add('slider__dot');
if (i === 0) dot.classList.add('is-active');
dot.addEventListener('click', () => goToSlide(i));
dotsContainer.appendChild(dot);
}
const dots = dotsContainer.children;
function goToSlide(index) {
currentIndex = index;
track.style.transform = `translateX(-${currentIndex * 100}%)`;
// ドットの見た目も現在位置に合わせて更新する
Array.from(dots).forEach((dot, i) => {
dot.classList.toggle('is-active', i === currentIndex);
});
}
function nextSlide() {
const nextIndex = (currentIndex + 1) % slideCount; // 最後の次は最初に戻る
goToSlide(nextIndex);
}
function prevSlide() {
const prevIndex = (currentIndex - 1 + slideCount) % slideCount; // 最初の前は最後に戻る
goToSlide(prevIndex);
}
nextBtn.addEventListener('click', nextSlide);
prevBtn.addEventListener('click', prevSlide);
ポイントは %(剰余演算子)を使った「循環」の処理です。
(currentIndex + 1) % slideCount と書いておくと、currentIndex がスライド数を超えた瞬間に自動的に0へ戻ってくれます。
同じように prevSlide 側では、0番目からさらに戻ろうとしたときにマイナスにならないよう + slideCount を足してから % を取る、という書き方をしています。
この「剰余演算子を使った循環」は、スライダーに限らずページネーションなど色々な場面で使える定番テクニックです。
つまずきやすいポイント:自動再生タイマーが多重に動いてしまう
私が実際にハマった経験
私が自動再生付きのスライダーを実装したとき、「マウスを乗せたら止まり、離したら再開する」という仕様を追加した際に、スライドが異常な速さで切り替わるようになってしまったことがありました。
原因は、マウスが乗る・離れるを繰り返すたびに setInterval を新しく発行してしまい、同時に複数のタイマーが動いてしまっていたことでした。
setInterval は呼び出すたびに新しいタイマーを作る関数なので、前のタイマーを止めずに何度も呼び出すと、その分だけ同時進行のタイマーが増えていきます。
❌ Before:タイマーを止めずに何度も再生成してしまう
let timerId;
function startAutoPlay() {
timerId = setInterval(nextSlide, 3000);
}
const sliderEl = document.querySelector('.slider');
sliderEl.addEventListener('mouseleave', () => {
startAutoPlay(); // 呼ばれるたびに新しいタイマーが増えていく
});
✅ After:新しく開始する前に、必ず既存のタイマーを止める
let timerId = null;
function startAutoPlay() {
stopAutoPlay(); // 念のため、既存のタイマーがあれば先に止める
timerId = setInterval(nextSlide, 3000);
}
function stopAutoPlay() {
clearInterval(timerId);
timerId = null;
}
const sliderEl = document.querySelector('.slider');
sliderEl.addEventListener('mouseenter', stopAutoPlay);
sliderEl.addEventListener('mouseleave', startAutoPlay);
startAutoPlay(); // 初期表示時に自動再生を開始
clearInterval() に渡す timerId は、setInterval() が返してきたタイマーの識別番号です。
「開始する前に必ず一度止める」という順序を徹底しておくと、意図せず複数のタイマーが並走する事故を防げます。
setInterval を扱うUIでは、この「多重起動対策」が非常によくあるバグの温床になるので、実装のたびに意識するようにしています。
応用:タッチ操作(スワイプ)に対応する
スマホでのスワイプ切り替え
スマホでは、ボタンを押すよりも指でスワイプして切り替えたいというニーズが強いため、タッチイベントにも対応しておくと親切です。
let touchStartX = 0;
track.addEventListener('touchstart', (event) => {
touchStartX = event.touches[0].clientX;
});
track.addEventListener('touchend', (event) => {
const touchEndX = event.changedTouches[0].clientX;
const diff = touchStartX - touchEndX;
const SWIPE_THRESHOLD = 50; // これ以上動いたらスワイプとみなす
if (diff > SWIPE_THRESHOLD) {
nextSlide(); // 左にスワイプ → 次へ
} else if (diff < -SWIPE_THRESHOLD) {
prevSlide(); // 右にスワイプ → 前へ
}
});
指を置いた瞬間の座標(touchstart)と、離した瞬間の座標(touchend)の差分を計算し、一定以上動いていればスワイプとみなす、という考え方です。
SWIPE_THRESHOLD のようなしきい値を変数として持たせておくと、「反応が敏感すぎる/鈍すぎる」といった調整を後から1箇所変えるだけで済みます。
jQueryではこう書けます(参考)
function goToSlide(index) {
$('#sliderTrack').css('transform', `translateX(-${index * 100}%)`);
$('.slider__dot').removeClass('is-active').eq(index).addClass('is-active');
}
.eq(index) で「何番目の要素か」を指定できるのがjQueryらしい書き方です。
タイマーやスワイプの考え方自体は素のJavaScriptと変わらないため、詳細は別カテゴリのjQuery記事に譲ります。
まとめ
この記事のポイント
- スライダーは「現在のスライド番号」「総数」「タイマーID」を状態として管理すると実装が整理しやすい
- 剰余演算子(
%)を使うと、最後の次は最初に戻る「循環」処理をシンプルに書ける setIntervalは呼び出すたびに新しいタイマーが動くため、開始前に必ずclearIntervalで止める習慣が必須- タッチイベントの座標差分を使えば、スマホのスワイプ操作にも対応できる
次に読むべき記事
これで実践UI実装シリーズの基本パーツはひと通り実装できました。
次回は、スクロールに合わせて要素をふわっと表示させる「Intersection Observerでスクロールアニメーションを実装する」を扱います。
これまでのクリックイベント中心の実装とは違い、「要素が画面に入ってきたかどうか」を検知する新しいAPIが登場します。
→ 次の記事:Intersection Observerでスクロールアニメーションを実装する