V-007 Media complex

How to create a accessible carousel アクセシブルカルーセルの作り方

CSS Scroll Snap carousel meeting a11y requirements via inert, prefers-reduced-motion, and aria-live announcements. inert 属性・prefers-reduced-motion・aria-live でアクセシビリティ要件を満たした CSS Scroll Snap カルーセル。

ライブデモ Live Demo

概要・用途・特徴Overview, Usage & Features

何ができるかWhat it does

CSS Scroll Snap carousel meeting a11y requirements via inert, prefers-reduced-motion, and aria-live announcements.

inert 属性・prefers-reduced-motion・aria-live でアクセシビリティ要件を満たした CSS Scroll Snap カルーセル。

どこで使うかWhere to use

content gallery, video player, media library, portfolio showcase

コンテンツギャラリー、動画プレイヤー、メディアライブラリ、ポートフォリオショーケース

特徴Key features

WCAG-compliant carousel using inert attribute to hide off-screen slides, aria-live for announcements, and prefers-reduced-motion support. CSS Scroll Snap for native swipe. Keyboard arrow keys navigate slides. Autoplay pauses on focus.

inert属性でオフスクリーンスライドを非表示、aria-liveでアナウンス、prefers-reduced-motionをサポートするWCAG準拠カルーセル。ネイティブスワイプのためのCSS Scroll Snap。キーボード矢印キーでスライドをナビゲート。フォーカス時にオートプレイを一時停止。

調整可能パラメータ Adjustable Parameters

Parameter Default Description

実装コード Implementation Code

// 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>
  );
}
: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; }
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.' },
];

/**
 * AccessibleCarousel
 *
 * Props:
 *   slides      — array of { id, title, desc }
 *   loop        — boolean, default false (infinite loop; enable with care re: focus traps)
 */
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;

  // Capture reduced-motion preference once (stable across renders)
  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) {
        const trackRect = track.getBoundingClientRect();
        const slideRect = slideEl.getBoundingClientRect();
        const targetLeft = track.scrollLeft + (slideRect.left - trackRect.left);
        track.scrollTo({ left: targetLeft, behavior: scrollBehavior() });
      }
    }

    setCurrent(next);
    // Clear then set to force re-announcement even for the same index
    setAnnouncement('');
    setTimeout(() => setAnnouncement(`Slide ${next + 1} of ${total}`), 60);
  }, [loop, total]); // eslint-disable-line react-hooks/exhaustive-deps

  // Arrow-key navigation scoped to the carousel section
  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 `current` when the user swipes manually
  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 — announces current slide to screen readers */}
      <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;
          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>
  );
}
:root {
  --carousel-accent:   #4f6ef7;
  --carousel-btn-size: 44px;
  --carousel-gap:      16px;
  --carousel-peek:     48px;
  --carousel-radius:   12px;
}

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

.carousel-root {
  max-width: 640px;
  margin: 0 auto;
}

/* Scroll track */
.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;
  }
}

/* Slides */
.carousel-slide {
  flex: 0 0 calc(100% - var(--carousel-peek));
  scroll-snap-align: start;
  border-radius: var(--carousel-radius);
  min-height: 200px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  padding: 32px 24px;
  color: #fff;
  text-align: center;
  transition: opacity 0.25s;
}

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

/* Gradient palettes */
.carousel-slide:nth-child(1) { background: linear-gradient(135deg, #667eea, #764ba2); }
.carousel-slide:nth-child(2) { background: linear-gradient(135deg, #f093fb, #f5576c); }
.carousel-slide:nth-child(3) { background: linear-gradient(135deg, #4facfe, #00f2fe); }
.carousel-slide:nth-child(4) { background: linear-gradient(135deg, #43e97b, #38f9d7); }
.carousel-slide:nth-child(5) { background: linear-gradient(135deg, #fa709a, #fee140); }
.carousel-slide:nth-child(6) { background: linear-gradient(135deg, #a18cd1, #fbc2eb); }

/* inert dims the slide visually; a11y exclusion is handled by the browser */
.carousel-slide[inert]    { opacity: 0.38; }
.carousel-slide.is-active { opacity: 1; }

.carousel-slide-num {
  font-size: 11px;
  opacity: 0.65;
  margin-bottom: 10px;
  letter-spacing: 0.09em;
  text-transform: uppercase;
}

.carousel-slide-title {
  font-size: 20px;
  font-weight: 700;
  margin: 0 0 10px;
  line-height: 1.25;
}

.carousel-slide-desc {
  font-size: 14px;
  opacity: 0.85;
  margin: 0;
  max-width: 260px;
  line-height: 1.55;
}

/* Controls */
.carousel-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  margin-top: 16px;
}

.carousel-btn {
  width: var(--carousel-btn-size);
  height: var(--carousel-btn-size);
  border-radius: 50%;
  background: #fff;
  border: 1.5px solid #e5e7eb;
  color: #111827;
  font-size: 18px;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
  font-family: inherit;
}

.carousel-btn:hover {
  background: var(--carousel-accent);
  border-color: var(--carousel-accent);
  color: #fff;
}

.carousel-btn:focus-visible {
  outline: 2px solid var(--carousel-accent);
  outline-offset: 2px;
}

.carousel-btn:active {
  transform: scale(0.92);
}

.carousel-btn:disabled {
  opacity: 0.32;
  cursor: default;
  pointer-events: none;
}

.carousel-counter {
  font-size: 13px;
  color: #6b7280;
  min-width: 46px;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

仕組みとカスタマイズHow It Works & Customization

仕組みHow it works

Off-screen slides have the inert attribute, preventing focus and interaction with hidden content. The visible slide is announced via an aria-live="polite" region ("Slide 2 of 5"). CSS scroll-snap-type: x mandatory handles smooth swipe and scroll. A MutationObserver or direct JS updates inert on slide change. Autoplay uses setInterval and clears on focusin/mouseenter.

オフスクリーンのスライドはinert属性を持ち、非表示コンテンツへのフォーカスとインタラクションを防止。表示中のスライドはaria-live="polite"リージョンでアナウンス("スライド5枚中2枚目")。CSS scroll-snap-type:x mandatoryがスムーズなスワイプとスクロールを処理。MutationObserverまたは直接JSがスライド変更時にinertを更新。オートプレイはsetIntervalを使用しfocusin/mouseenterでクリア。

カスタマイズ方法Customization

Add a "pause autoplay" button that persists the paused preference in localStorage. Implement a full-bleed variant where slides are viewport-width. Add a fade transition instead of scroll for a slideshow-style presentation.

一時停止したオートプレイの設定をlocalStorageに保存する"オートプレイを一時停止"ボタンを追加。スライドがビューポート幅のフルブリードバリアントを実装。スライドショー形式のプレゼンテーションのためにスクロールの代わりにフェードトランジションを追加。

注意点Caveats

WCAG 2.1 Success Criterion 2.2.2 requires that auto-advancing content can be paused, stopped, or hidden. The inert approach is the most robust solution for hiding off-screen slides from all assistive technologies.

WCAG 2.1 達成基準 2.2.2では自動進行するコンテンツを一時停止・停止・非表示にできることが必要です。inertアプローチは全ての支援技術からオフスクリーンスライドを非表示にする最も堅牢なソリューションです。

よくある質問 Frequently Asked Questions

How to customize the accessible carousel? Accessible Carouselをカスタマイズするには?

Modify the CSS custom properties and class styles defined in the code section. Key adjustable values include colors, sizes, durations, and spacing. See the Adjustable Parameters section for specific variables.

コードセクションで定義されているCSSカスタムプロパティとクラススタイルを変更してください。色、サイズ、時間、間隔が主な調整可能値です。具体的な変数は調整可能パラメータセクションを参照してください。

How to use the accessible carousel in React? ReactでAccessible Carouselを使うには?

Import the provided React component and its CSS file. The component accepts props for customization. Check the React code section for the full implementation and available props.

提供されているReactコンポーネントとCSSファイルをインポートしてください。コンポーネントのpropsでカスタマイズできます。完全な実装と利用可能なpropsはReactコードセクションを参照してください。

What are the performance implications of accessible carousel? Accessible Carouselのパフォーマンスへの影響は?

This implementation uses CSS transforms and opacity for animations, which are GPU-accelerated. It's lightweight and doesn't cause layout thrashing. Consider using prefers-reduced-motion for accessibility.

この実装はCSSのtransformとopacityを使用しており、GPUアクセラレーションされます。軽量でレイアウトスラッシングを引き起こしません。アクセシビリティのためにprefers-reduced-motionの使用を検討してください。

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

以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.