画像スライダーやカルーセルの作成

以下では、DOMを直接操作して「画像スライダー / カルーセル」を実装する方法を、基礎からアクセシビリティ・パフォーマンス配慮まで段階的に解説します。フレームワークは使わず、HTML/CSS/JavaScriptの素の構成で進めます。


1. 基本設計の考え方

  • レイアウト:横一列に並べたスライドを transform: translateX() で移動。GPU支援が効きやすく、スムーズ。

  • ナビゲーション:前後ボタン(Prev/Next)とインジケータ(ドット)。

  • アクセシビリティrole="region"aria-roledescription="carousel", aria-label、各スライドに role="group" と「現在位置/全体枚数」の読み上げ。ボタンに aria-controls とラベル。

  • 操作:クリック、キーボード(← → / Home / End)、タッチ/ドラッグ(スワイプ)。

  • パフォーマンスwill-change: transform、画像は loading="lazy"IntersectionObserver で近傍だけプリロード。

  • 無限ループ:両端にクローンを置いてシームレスに巻き戻す。

  • オートプレイsetInterval ではなく、ユーザー操作で停止・再開できる制御と、フォーカス時/ホバー時は停止。


2. 最小HTML(意味付けとARIA)

html
<section class="carousel" role="region" aria-roledescription="carousel" aria-label="ギャラリー"> <div class="carousel__viewport" id="viewport" aria-live="off"> <div class="carousel__track"> <article class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 / 5"> <img src="img/01.jpg" alt="朝焼けの山並み" loading="lazy"> </article> <article class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 / 5"> <img src="img/02.jpg" alt="街の夜景" loading="lazy"> </article> <article class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 / 5"> <img src="img/03.jpg" alt="海辺の夕日" loading="lazy"> </article> <article class="carousel__slide" role="group" aria-roledescription="slide" aria-label="4 / 5"> <img src="img/04.jpg" alt="森の小道" loading="lazy"> </article> <article class="carousel__slide" role="group" aria-roledescription="slide" aria-label="5 / 5"> <img src="img/05.jpg" alt="雪の平原" loading="lazy"> </article> </div> </div> <button class="carousel__prev" aria-label="前のスライド" aria-controls="viewport" type="button">Prev</button> <button class="carousel__next" aria-label="次のスライド" aria-controls="viewport" type="button">Next</button> <ul class="carousel__dots" role="tablist" aria-label="スライド移動"> <li><button role="tab" aria-selected="true" aria-controls="viewport" type="button">1</button></li> <li><button role="tab" aria-selected="false" aria-controls="viewport" type="button">2</button></li> <li><button role="tab" aria-selected="false" aria-controls="viewport" type="button">3</button></li> <li><button role="tab" aria-selected="false" aria-controls="viewport" type="button">4</button></li> <li><button role="tab" aria-selected="false" aria-controls="viewport" type="button">5</button></li> </ul> <button class="carousel__play" type="button" aria-pressed="false" aria-label="自動再生を開始">Play</button> </section>

3. 基本CSS(1画面に1枚、トランジション)

css
.carousel { position: relative; max-width: 800px; margin: auto; user-select: none; } .carousel__viewport { overflow: hidden; } .carousel__track { display: grid; grid-auto-flow: column; grid-auto-columns: 100%; transition: transform 400ms ease; will-change: transform; } .carousel__slide { position: relative; } .carousel__slide img { display: block; width: 100%; height: auto; object-fit: cover; } .carousel__prev, .carousel__next { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(0,0,0,.5); color: #fff; border: none; padding: .5rem .75rem; } .carousel__prev { left: .5rem; } .carousel__next { right: .5rem; } .carousel__dots { display: flex; gap: .5rem; justify-content: center; list-style: none; padding: .75rem 0; margin: 0; } .carousel__dots button[aria-selected="true"] { outline: 2px solid #333; } @media (prefers-reduced-motion: reduce) { .carousel__track { transition: none; } }

4. JavaScript(基本の移動・ボタン・ドット)

html
<script> (() => { const root = document.querySelector('.carousel'); const track = root.querySelector('.carousel__track'); const slides = [...root.querySelectorAll('.carousel__slide')]; const prev = root.querySelector('.carousel__prev'); const next = root.querySelector('.carousel__next'); const dots = [...root.querySelectorAll('.carousel__dots button')]; const play = root.querySelector('.carousel__play'); let index = 0; let autoplayId = null; const last = slides.length - 1; function goTo(i, {announce=false} = {}) { index = Math.max(0, Math.min(i, last)); track.style.transform = `translateX(${-100 * index}%)`; dots.forEach((b, j) => b.setAttribute('aria-selected', String(j === index))); // 読み上げは必要時のみ(頻繁な変更は煩雑) if (announce) { const vp = root.querySelector('.carousel__viewport'); vp.setAttribute('aria-live', 'polite'); vp.setAttribute('aria-label', `${index+1} / ${slides.length}`); // 次フレームで戻す requestAnimationFrame(() => vp.setAttribute('aria-live', 'off')); } } prev.addEventListener('click', () => goTo(index - 1, {announce:true})); next.addEventListener('click', () => goTo(index + 1, {announce:true})); dots.forEach((b, i) => b.addEventListener('click', () => goTo(i, {announce:true}))); // キーボード操作:← → / Home / End root.addEventListener('keydown', (e) => { switch (e.key) { case 'ArrowLeft': e.preventDefault(); goTo(index - 1, {announce:true}); break; case 'ArrowRight': e.preventDefault(); goTo(index + 1, {announce:true}); break; case 'Home': e.preventDefault(); goTo(0, {announce:true}); break; case 'End': e.preventDefault(); goTo(last, {announce:true}); break; } }); // オートプレイ(ホバー/フォーカスで停止) function startAutoplay() { if (autoplayId) return; play.setAttribute('aria-pressed', 'true'); play.setAttribute('aria-label', '自動再生を停止'); autoplayId = setInterval(() => { goTo((index + 1) % (last + 1)); }, 3000); } function stopAutoplay() { clearInterval(autoplayId); autoplayId = null; play.setAttribute('aria-pressed', 'false'); play.setAttribute('aria-label', '自動再生を開始'); } play.addEventListener('click', () => autoplayId ? stopAutoplay() : startAutoplay()); root.addEventListener('mouseenter', stopAutoplay); root.addEventListener('mouseleave', () => { if (play.getAttribute('aria-pressed') === 'true') startAutoplay(); }); root.addEventListener('focusin', stopAutoplay); // スワイプ(タッチ/ドラッグ) let startX = 0, currentX = 0, dragging = false; const viewport = root.querySelector('.carousel__viewport'); function pointerDown(x) { dragging = true; startX = currentX = x; track.style.transition = 'none'; } function pointerMove(x) { if (!dragging) return; currentX = x; const delta = ((currentX - startX) / viewport.clientWidth) * 100; track.style.transform = `translateX(calc(${-100 * index}% + ${delta}%))`; } function pointerUp() { if (!dragging) return; track.offsetHeight; // reflow for safety track.style.transition = ''; const delta = (currentX - startX); const threshold = viewport.clientWidth * 0.2; if (delta > threshold) goTo(index - 1, {announce:true}); else if (delta < -threshold) goTo(index + 1, {announce:true}); else goTo(index); // 戻す dragging = false; } viewport.addEventListener('pointerdown', (e) => { viewport.setPointerCapture(e.pointerId); pointerDown(e.clientX); }); viewport.addEventListener('pointermove', (e) => pointerMove(e.clientX)); viewport.addEventListener('pointerup', pointerUp); viewport.addEventListener('pointercancel', pointerUp); viewport.addEventListener('dragstart', (e) => e.preventDefault()); // 近傍プリロード(簡易) if ('IntersectionObserver' in window) { const io = new IntersectionObserver((entries) => { entries.forEach(({isIntersecting, target}) => { if (!isIntersecting) return; const img = target.querySelector('img'); const src = img.getAttribute('data-src'); if (src) { img.src = src; img.removeAttribute('data-src'); } io.unobserve(target); }); }, {root: viewport, threshold: 0.1}); slides.forEach(s => io.observe(s)); } // 初期表示 goTo(0); })(); </script>

5. 無限ループの実装(発展)

「端で止まらず連続スクロール」したい場合は、先頭と末尾のスライドをクローンして前後に挿入し、トランジション完了時にインデックスを巻き戻す方式が扱いやすいです。

手順の要点のみ:

  1. firstClone = firstSlide.cloneNode(true), lastClone = lastSlide.cloneNode(true) を作成し、track の前後に挿入。

  2. 実インデックスを 1(最初のクローンの次)から始める。

  3. transitionend イベントで、index がクローン位置になったらトランジションを一時的に無効化し、本来の対応スライドへ瞬間移動してから再度有効化。

この方式により、視覚的には無限に回って見えます。


6. アクセシビリティの要点

  • ロールとラベル:カルーセル全体に role="region"aria-roledescription="carousel"。視覚的ラベルは aria-label

  • 現在位置の通知aria-live="polite" を必要時のみ短時間有効化。頻繁なライブ更新は避ける。

  • フォーカス制御:ボタンやドットは tabindex=0 のデフォルトで操作可能。スライド内部リンクが多い場合はタブ移動が増えるため、tabindex 設計を見直す。

  • キーボード操作:左右矢印、Home/End をサポート。焦点がカルーセル内にある間のみ反応。

  • 自動再生:ユーザーの操作を優先。ホバー/フォーカスで停止、prefers-reduced-motion ではアニメーションを抑制。


7. パフォーマンスと実運用のコツ

  • トランジションは transform に限定し、left/margin 移動は避ける。

  • 画像は loading="lazy"decoding="async" を併用可能。

  • ビューポート外のスライドに visibility: hidden を使うより、overflow: hidden と移動の方がコストが低い。

  • ウィンドウ幅変更に伴うスナップずれは ResizeObservergoTo(index) を呼び再計算。

  • スライド数が多いときは仮想化(近傍のみ DOM に置く)を検討。


8. よくある不具合と対策

  • ドラッグ後に微妙に位置がずれる:ドラッグ中は transition: none、確定時にだけ戻す。

  • 画像高さでコンテンツジャンプaspect-ratio か固定高さのプレースホルダを設定。

  • オートプレイが止まらないmouseenter/focusin など全停止条件を網羅。visibilitychange(タブ非表示)でも停止。


9. 仕様拡張の例

  • サムネイル列を aria-controls 付きの「タブ」にして直接ジャンプ。

  • スライド内にキャプションを置き、figcaption で意味付け。

  • ページネーションを X / N 表示に同期。

ChatGPT5 生成日:2025/09/11