ライブデモ Live Demo

Prev/Next ボタン・ドット・← / → キーで操作できます。非アクティブスライドには Tab フォーカスが移りません。

Navigate with Prev/Next, dot indicators, or ← / → keys. Inactive slides are not focusable.

AI向け説明 AI Description

V-007 はアクセシビリティ要件を満たしたカルーセルの実装テンプレートです。CSS Scroll Snap ベースの横スクロールコンテナと Prev/Next ボタン・ドットインジケーターを組み合わせ、以下の要件を満たします。

なぜカルーセルは難しいのか: 非表示スライドが DOM に残ったままだと、Tab キーやスクリーンリーダーがそこへフォーカスを送り込みます。ユーザーは見えないコンテンツを読まされたり、現在地を見失ったりします。

inert 属性の目的: inert を付与したスライドは、フォーカス・クリック・スクリーンリーダーの読み上げから完全に除外されます。tabindex="-1" + aria-hidden="true" をすべての子孫に適用するより堅牢で、ブラウザがネイティブに処理します。非表示スライドには必ず inert(または fallback として aria-hidden="true" + 子要素への tabindex="-1")を設定してください。

prefers-reduced-motion: 前庭障害を持つユーザーは急激なスクロールアニメーションで不快感を覚えます。CSS で scroll-behavior: smooth@media (prefers-reduced-motion: reduce) でオフにし、JS では matchMediabehavior: 'auto' に切り替えます。

オートプレイを避けた理由: 自動スライドはユーザーが読んでいる最中に内容が変わり、特にスクリーンリーダーユーザーを混乱させます。必要な場合は Pause/Play ボタン・ホバーおよび focus での自動停止・進捗表示を必ずセットにしてください(WCAG 2.1 SC 2.2.2)。

スクリーンリーダー対応: aria-live="polite" リージョンに "Slide N of 6" を更新することで、スライドが変わるたびに現在位置をアナウンスします。ライブリージョンは一度空にしてから再セットすることで、同じスライドへ再移動した場合でも確実に読み上げが発火します。

V-007 is an accessibility-first carousel template based on CSS Scroll Snap with Prev/Next buttons and dot indicators, satisfying the following requirements.

Why carousels are hard: Off-screen slides remaining in the DOM allow keyboard Tab and screen readers to reach invisible content, disorienting users.

The purpose of inert: The inert attribute removes a subtree from the accessibility tree, tab order, and pointer events in one step — more robust than manually applying tabindex="-1" + aria-hidden="true" to every focusable descendant. Always apply inert (or the fallback) to inactive slides.

Reduced motion: Users with vestibular disorders may feel discomfort from scroll animations. Disable scroll-behavior: smooth via @media (prefers-reduced-motion: reduce) and pass behavior: 'auto' to scrollTo() by checking matchMedia at runtime.

No autoplay by default: Auto-advancing carousels disrupt reading and disorient screen reader users. If autoplay is required, always pair it with a visible Play/Pause control, pause on hover/focus, and a progress indicator (WCAG 2.1 SC 2.2.2).

Screen reader position: An aria-live="polite" region announces "Slide N of 6" on each navigation. Clear the region first, then set the new text via requestAnimationFrame to reliably trigger re-announcement even when navigating to the same slide twice.

調整可能パラメータ Adjustable Parameters

実装 Implementation

HTML + CSS + JS

<!-- Visually hidden SR live region -->
<div aria-live="polite" aria-atomic="true" class="sr-only" id="carousel_live"></div>

<section aria-label="Featured items" aria-roledescription="carousel">
  <div class="carousel-track" id="carousel_track">
    <!-- Slide 1: active -->
    <div class="carousel-slide is-active"
         role="group" aria-roledescription="slide" aria-label="1 of 6">
      <h3>Slide One</h3>
      <p>Your content here.</p>
    </div>
    <!-- Slides 2-6: inactive, receive inert -->
    <div class="carousel-slide"
         role="group" aria-roledescription="slide" aria-label="2 of 6" inert>
      <h3>Slide Two</h3>
      <p>Your content here.</p>
    </div>
    <!-- ... more slides ... -->
  </div>

  <div role="group" aria-label="Carousel controls">
    <button id="btn_prev" aria-label="Previous slide" type="button">&larr;</button>
    <span aria-hidden="true" id="counter">1 / 6</span>
    <button id="btn_next" aria-label="Next slide" type="button">&rarr;</button>
  </div>
</section>

<style>
:root {
  --carousel-accent:   #4f6ef7;
  --carousel-btn-size: 44px;
  --carousel-gap:      16px;
  --carousel-peek:     48px;
  --carousel-radius:   12px;
}

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.carousel-track {
  display: flex;
  gap: var(--carousel-gap);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.carousel-track::-webkit-scrollbar { display: none; }

@media (prefers-reduced-motion: reduce) {
  .carousel-track { scroll-behavior: auto; }
}

.carousel-slide {
  flex: 0 0 calc(100% - var(--carousel-peek));
  scroll-snap-align: start;
  border-radius: var(--carousel-radius);
  transition: opacity 0.25s;
}

/* inert handles a11y; opacity gives visual cue */
.carousel-slide[inert]   { opacity: 0.38; }
.carousel-slide.is-active { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .carousel-slide { transition: none; }
}

button#btn_prev,
button#btn_next {
  width: var(--carousel-btn-size);
  height: var(--carousel-btn-size);
  border-radius: 50%;
}
button:disabled { opacity: 0.32; cursor: default; pointer-events: none; }
</style>

<script>
(function () {
  var track   = document.getElementById('carousel_track');
  var slides  = Array.from(track.querySelectorAll('.carousel-slide'));
  var btnPrev = document.getElementById('btn_prev');
  var btnNext = document.getElementById('btn_next');
  var counter = document.getElementById('counter');
  var liveEl  = document.getElementById('carousel_live');
  var prefersReduced = matchMedia('(prefers-reduced-motion: reduce)');
  var current = 0;
  var total   = slides.length;

  function scrollBehavior() {
    return prefersReduced.matches ? 'auto' : 'smooth';
  }

  var isScrolling = false;
  var scrollLockTimer;

  function goTo(index) {
    if (index < 0 || index >= total) return;

    isScrolling = true;
    clearTimeout(scrollLockTimer);

    // 1. Toggle inert on all slides
    slides.forEach(function (slide, i) {
      if (i === index) {
        slide.removeAttribute('inert');
        slide.classList.add('is-active');
      } else {
        slide.setAttribute('inert', '');
        slide.classList.remove('is-active');
      }
    });

    // 2. Use getBoundingClientRect for a reliable scroll target.
    //    slides[i].offsetLeft is unreliable when .carousel-track lacks position:relative.
    var trackRect = track.getBoundingClientRect();
    var slideRect = slides[index].getBoundingClientRect();
    var targetLeft = track.scrollLeft + (slideRect.left - trackRect.left);
    track.scrollTo({ left: targetLeft, behavior: scrollBehavior() });
    current = index;

    // 3. Update controls and announce
    updateUI();

    scrollLockTimer = setTimeout(function () { isScrolling = false; }, 450);
  }

  function updateUI() {
    counter.textContent = (current + 1) + ' / ' + total;
    btnPrev.disabled = current === 0;
    btnNext.disabled = current === total - 1;

    // Clear then set to re-trigger announcement even on same slide
    liveEl.textContent = '';
    requestAnimationFrame(function () {
      liveEl.textContent = 'Slide ' + (current + 1) + ' of ' + total;
    });
  }

  btnPrev.addEventListener('click', function () { goTo(current - 1); });
  btnNext.addEventListener('click', function () { goTo(current + 1); });

  // Arrow-key navigation within the carousel section
  track.closest('section').addEventListener('keydown', function (e) {
    if (e.key === 'ArrowLeft')  { e.preventDefault(); goTo(current - 1); }
    if (e.key === 'ArrowRight') { e.preventDefault(); goTo(current + 1); }
  });

  // IntersectionObserver: sync state when user swipes manually
  // isScrolling guard prevents cascade during programmatic scroll
  var observer = new IntersectionObserver(function (entries) {
    if (isScrolling) return;
    entries.forEach(function (entry) {
      if (entry.isIntersecting) {
        var idx = slides.indexOf(entry.target);
        if (idx !== -1 && idx !== current) goTo(idx);
      }
    });
  }, { root: track, threshold: 0.6 });

  slides.forEach(function (slide) { observer.observe(slide); });

  // Initialise: first slide active, rest inert
  goTo(0);
})();
</script>

React (JSX + CSS)

// react/V-007.jsx
import { useState, useRef, useEffect, useCallback } from 'react';
import './V-007.css';

const DEFAULT_SLIDES = [
  { id: 1, title: 'Focus Management',    desc: 'Inactive slides receive `inert` — zero focus leakage.' },
  { id: 2, title: 'Keyboard Navigation', desc: '← → keys move between slides. Tab reaches only controls.' },
  { id: 3, title: 'Scroll Snap',         desc: 'CSS `scroll-snap-type: x mandatory` anchors each slide.' },
  { id: 4, title: 'Reduced Motion',      desc: '`prefers-reduced-motion` switches scroll to instant.' },
  { id: 5, title: 'ARIA Live Region',    desc: 'Screen reader hears "Slide N of 6" on every change.' },
  { id: 6, title: 'No Autoplay',         desc: 'Users control the pace. Add pause/play if you need it.' },
];

export default function AccessibleCarousel({ slides = DEFAULT_SLIDES, loop = false }) {
  const [current, setCurrent]       = useState(0);
  const [announcement, setAnnouncement] = useState('');
  const trackRef = useRef(null);
  const total    = slides.length;

  const prefersReduced = useRef(
    typeof window !== 'undefined'
      ? matchMedia('(prefers-reduced-motion: reduce)').matches
      : false
  );

  const scrollBehavior = () => (prefersReduced.current ? 'auto' : 'smooth');

  const goTo = useCallback((rawIndex) => {
    const next = loop
      ? (rawIndex + total) % total
      : Math.max(0, Math.min(rawIndex, total - 1));

    const track = trackRef.current;
    if (track) {
      const slideEl = track.children[next];
      if (slideEl) {
        // Use getBoundingClientRect so the target is correct regardless of offsetParent
        const trackRect = track.getBoundingClientRect();
        const slideRect = slideEl.getBoundingClientRect();
        const targetLeft = track.scrollLeft + (slideRect.left - trackRect.left);
        track.scrollTo({ left: targetLeft, behavior: scrollBehavior() });
      }
    }

    setCurrent(next);
    setAnnouncement('');
    setTimeout(() => setAnnouncement(`Slide ${next + 1} of ${total}`), 60);
  }, [loop, total]);

  const handleKeyDown = useCallback((e) => {
    if (e.key === 'ArrowLeft')  { e.preventDefault(); goTo(current - 1); }
    if (e.key === 'ArrowRight') { e.preventDefault(); goTo(current + 1); }
  }, [current, goTo]);

  // Sync from manual swipe
  useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const slideEls = Array.from(track.children);
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const idx = slideEls.indexOf(entry.target);
          if (idx !== -1) setCurrent(idx);
        }
      });
    }, { root: track, threshold: 0.6 });
    slideEls.forEach((el) => observer.observe(el));
    return () => observer.disconnect();
  }, []);

  return (
    <section
      aria-label="Featured items"
      aria-roledescription="carousel"
      onKeyDown={handleKeyDown}
      className="carousel-root"
    >
      {/* Visually hidden live region */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {announcement}
      </div>

      <div className="carousel-track" ref={trackRef}>
        {slides.map((slide, i) => {
          const isActive = i === current;
          // Spread inert as a boolean attribute when inactive
          const inertProps = isActive ? {} : { inert: '' };
          return (
            <div
              key={slide.id}
              className={`carousel-slide${isActive ? ' is-active' : ''}`}
              role="group"
              aria-roledescription="slide"
              aria-label={`${i + 1} of ${total}`}
              {...inertProps}
            >
              <span className="carousel-slide-num">{i + 1} / {total}</span>
              <h3 className="carousel-slide-title">{slide.title}</h3>
              <p className="carousel-slide-desc">{slide.desc}</p>
            </div>
          );
        })}
      </div>

      <div role="group" aria-label="Carousel controls" className="carousel-controls">
        <button
          type="button"
          className="carousel-btn"
          aria-label="Previous slide"
          onClick={() => goTo(current - 1)}
          disabled={!loop && current === 0}
        >←</button>

        <span aria-hidden="true" className="carousel-counter">
          {current + 1} / {total}
        </span>

        <button
          type="button"
          className="carousel-btn"
          aria-label="Next slide"
          onClick={() => goTo(current + 1)}
          disabled={!loop && current === total - 1}
        >→</button>
      </div>
    </section>
  );
}

AIへの指示テンプレート AI Prompt Template

以下のテンプレートをコピーしてAIアシスタントに貼り付けると、このパターンの実装を依頼できます。 Copy the template below and paste it into your AI assistant to request an implementation of this pattern.