ライブデモ 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 では matchMedia で behavior: '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
- --carousel-accent — ボタン・アクティブドットのアクセントカラーAccent color for buttons and active dot indicator
- --carousel-btn-size — Prev/Next ボタンのサイズ(WCAG タッチターゲット最小 24px、実用上 44px 以上推奨)Prev/Next button size (WCAG minimum 24px, 44px+ recommended in practice)
- --carousel-gap — スライド間のギャップGap between slides
- --carousel-peek — 次スライドの見え幅(「横に続く」ことをユーザーに示すアフォーダンス)How much of the next slide peeks into view (affordance signalling more content)
- --carousel-radius — スライドの角丸Slide border radius
- snapBehavior —
smooth/auto。prefers-reduced-motionが有効な場合は強制的にautoになりますsmoothorauto. Forced toautowhenprefers-reduced-motionis active - loop — デフォルト OFF。ON にするとフォーカストラップが生じる可能性があるため慎重に使用してくださいDefault OFF. Enable with care — infinite loop can create a focus trap for keyboard users
実装 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">←</button>
<span aria-hidden="true" id="counter">1 / 6</span>
<button id="btn_next" aria-label="Next slide" type="button">→</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.