Reactコンポーネントの構造
各Reactコンポーネントがどのような外観であるべきか、私の考え
421
13 分。
SSS規格は、スライダーのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を定めています。
要件は各コンポーネントの動作に応じて適用します。たとえば、端で停止するか循環するか、手動操作か自動切り替えか、同時に表示するスライド数などが該当します。
実装例ではReactアダプターを使ったEmbla Carouselを使用します。このライブラリは例として取り上げるものであり、必須の依存関係ではありません。例のAPIはEmbla v8に対応しています。
<button>を使用します。リンクは別のURLへの移動に使用し、スライダーの操作には使用しません。disabled属性で示します。見た目も有効状態と区別します。たとえばopacityやnot-allowedカーソルを変更できます。見た目だけでは支援技術に状態が伝わりません。ResizeObserverなどでコンテナとスライド列のサイズを測定できます。要素のサイズはウィンドウサイズが変わらなくても変化するため、window.resizeだけの監視では不十分です。select、reInitイベント発生時に、canScrollPrev()とcanScrollNext()に合わせて「前へ」「次へ」ボタンの状態を同期します。ボタンはドラッグ操作を処理するビューポート要素の外側に配置します。gapは、コンポーネントが利用できる幅に応じて設定します。画面幅に応じてレイアウトを変える場合はメディアクエリ、親コンテナの幅に応じて変える場合はコンテナクエリが適しています。role="region"を、独立したランドマークにする必要がない場合はrole="group"を指定します。コンポーネントの種類は、ローカライズした説明をaria-roledescriptionに設定して示します。aria-labelledby、または見出しが表示されていない場合はaria-labelでアクセシブルな名前を設定します。aria-roledescriptionを指定した場合、名前に「カルーセル」を繰り返す必要はありません。role="group"とローカライズした値のaria-roledescriptionを指定し、見出し、または専用の見出しがない場合は「2 / 5」のような位置情報で名前を付けます。aria-roledescriptionを指定した場合、名前に「スライド」を重ねて含めません。aria-label="前のスライド"、aria-label="次のスライド"のようにアクセシブルな名前を設定します。ボタンに表示されたテキスト自体を名前として使用する場合、aria-labelは不要です。aria-disabled="true"を指定し、タブ方式ではaria-selected="true"を指定します。aria-current="true"をスライドコンテナに機械的に設定せず、採用したパターンに沿って操作ボタンに現在位置を示します。aria-hidden="true"は、コンテンツをアクセシビリティツリーとフォーカス移動の両方から一貫して除外する場合に限って使用します。部分表示されているスライドは非表示扱いにしません。scrollSnapList()で位置を取得し、scrollTo(index)で移動します。aria-disabled="true"を指定します。タブ方式ではaria-selectedとキーボード操作を含むtabsパターン全体が必要です。色や透明度の変更だけでは不十分です。aria-label="5つの位置のうち2番目へ移動"。スライドをグループ化する場合は、その範囲または内容も名前に含めます。<button>を使用し、:focus-visibleのフォーカス表示を明確にします。ポインター操作のターゲットサイズはWCAG 2.2 SC 2.5.8に適合させます。原則として24 × 24 CSS px以上とし、十分な間隔や同等の操作手段など、基準に定められた例外も考慮します。useEmblaCarouselに渡すビューポートの外側に配置します。ビューポートはポインターとドラッグ操作を処理するため、その内部にボタンを置くとクリックを妨げることがあります。select、reInitイベント発生時にEmblaと同期します。コンテナ幅やスライド構成の変更によって、snap位置とドット数も変わる場合があります。grab、押しながらドラッグしている間はgrabbingカーソルを表示できます。この視覚的な手がかりは、操作ボタン、キーボード操作、タッチ操作の代わりにはなりません。aria-live="polite"を指定できます。自動切り替え中は、通知が連続しないようにaria-live="off"を使用します。prefers-reduced-motionを尊重します。動きを減らす設定が有効な場合、状態理解に必要でないトランジションは短縮または無効化します。height: autoを一律に禁止しません。高さの自動計算自体が表示の跳ねを引き起こすわけではありません。コンテンツ切り替え時の高さ変更が考慮されていない場合にレイアウトシフトが発生します。min-height、またはスライド間で統一した高さを使って領域を確保します。固定高によって、テキスト量の増加、言語切り替え、拡大表示時にコンテンツが切れないようにします。transformを、適切な場合はopacityを使用します。leftやmarginの変更はレイアウト再計算を発生させる場合があるためです。最終的な選択はプロファイリングで確認します。CSSプロパティだけで滑らかさが保証されるわけではありません。will-change: transformをすべてのスライドに初期設定しません。パフォーマンス上の問題が測定で確認された場合に限り使用します。多くのレイヤーにリソースを常時確保すると、逆にパフォーマンスが低下する場合があります。loading="lazy"を指定しません。初期ビューポート外にある画像には遅延読み込みを使用できます。loading="lazy"は「1〜2枚前から」のような距離を指定する属性ではありません。読み込み開始のタイミングは、ビューポートとの距離などに基づいてブラウザーが決定します。次のスライドをより早く読み込む場合は、計測結果とネットワーク負荷を根拠に別途判断します。widthとheight、または既知のアスペクト比を指定します。読み込み前に領域を確保し、レイアウトシフトのリスクを減らします。srcsetとsizesを使う場合は、スライド内で画像が表示されるレスポンシブ幅に合わせます。必要がないのにサムネイル用として原寸画像を読み込まないようにします。disabledが支援技術に伝わり、見た目でも判別できます。aria-live、動きを減らすシステム設定、表示領域外のスライドの扱いを確認します。transformやwill-changeの有無だけで判断しません。ナビゲーションとドットを備えた手動操作のEmblaカルーセル:
'use client'
import { useCallback, useEffect, useState } from 'react'
import useEmblaCarousel from 'embla-carousel-react'
import type { EmblaCarouselType } from 'embla-carousel'
const slides = [
{ id: 'article-1', title: '最初の記事', image: '/images/article-1.webp' },
{ id: 'article-2', title: '2つ目の記事', image: '/images/article-2.webp' },
{ id: 'article-3', title: '3つ目の記事', image: '/images/article-3.webp' },
]
export const ArticleCarousel = () => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false, slidesToScroll: 1 })
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
const [selectedSnap, setSelectedSnap] = useState(0)
const onSelect = useCallback((api: EmblaCarouselType) => {
setSelectedSnap(api.selectedScrollSnap())
}, [])
const onReInit = useCallback((api: EmblaCarouselType) => {
setScrollSnaps(api.scrollSnapList())
setSelectedSnap(api.selectedScrollSnap())
}, [])
useEffect(() => {
if (!emblaApi) return
onReInit(emblaApi)
emblaApi.on('select', onSelect)
emblaApi.on('reInit', onReInit)
return () => {
emblaApi.off('select', onSelect)
emblaApi.off('reInit', onReInit)
}
}, [emblaApi, onReInit, onSelect])
return (
<section
className='embla'
role='region'
aria-roledescription='カルーセル'
aria-labelledby='featured-title'
>
<h2 id='featured-title'>おすすめ記事</h2>
<div
className='embla__viewport'
ref={emblaRef}
aria-live='polite'
aria-atomic='false'
>
<div className='embla__container'>
{slides.map((slide) => (
<article
className='embla__slide'
key={slide.id}
role='group'
aria-roledescription='スライド'
aria-labelledby={`slide-title-${slide.id}`}
>
<img
src={slide.image}
width='960'
height='640'
alt={`記事のカバー画像:${slide.title}`}
/>
<h3 id={`slide-title-${slide.id}`}>{slide.title}</h3>
</article>
))}
</div>
</div>
{scrollSnaps.length > 1 && (
<div className='embla__controls'>
<button
type='button'
aria-label='前のスライド'
disabled={!emblaApi?.canScrollPrev()}
onClick={() => emblaApi?.scrollPrev()}
>
<span aria-hidden='true'>←</span>
</button>
<button
type='button'
aria-label='次のスライド'
disabled={!emblaApi?.canScrollNext()}
onClick={() => emblaApi?.scrollNext()}
>
<span aria-hidden='true'>→</span>
</button>
<div className='embla__dots' role='group' aria-label='表示位置を選択'>
{scrollSnaps.map((_, index) => {
const isSelected = index === selectedSnap
return (
<button
className='embla__dot'
type='button'
key={index}
aria-label={`${scrollSnaps.length}か所のうち${index + 1}番目へ移動`}
aria-disabled={isSelected}
onClick={() => {
if (!isSelected) emblaApi?.scrollTo(index)
}}
>
<span aria-hidden='true' />
</button>
)
})}
</div>
</div>
)}
</section>
)
};この例は手動操作のため、スライド領域にaria-live="polite"を指定しています。ドラッグ操作でボタンのクリックが妨げられないよう、操作ボタンは.embla__viewportの外側に配置します。scrollSnapList()でスクロール位置ごとのドットを生成し、selectとreInitで状態を更新します。初期表示時に見える画像数はレスポンシブレイアウトによって異なるため、例ではloading属性を省略しています。実際のコンポーネントでは初期表示される画像の読み込みを遅らせず、初期ビューポート外の画像には遅延読み込みを使用できます。pnpm環境でEmblaCarouselTypeをインポートする場合は、embla-carouselも直接の開発用依存関係として追加します。
次のカードを一部表示し、ドットの操作領域を確保するEmblaのレスポンシブレイアウト:
.embla__viewport {
overflow: hidden;
cursor: grab;
touch-action: pan-y pinch-zoom;
}
.embla__viewport:active {
cursor: grabbing;
}
.embla__container {
display: flex;
gap: 12px;
}
.embla__slide {
flex: 0 0 calc(100% - 28px);
min-width: 0;
}
.embla__dot {
display: inline-grid;
min-width: 24px;
min-height: 24px;
place-items: center;
border: 0;
background: transparent;
cursor: pointer;
}
.embla__dot span {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.embla__dot[aria-disabled='true'] span {
width: 12px;
height: 12px;
}
.embla__dot:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
@media (min-width: 768px) {
.embla__slide {
flex-basis: calc((100% - 24px) / 3);
}
}最初のカードをcalc(100% - 28px)、gapを12pxにすると、ビューポート内に次のカードが約16px表示されます。これは一つのレイアウト例です。表示するカード数、gap、隣のスライドの表示幅、ドットの操作領域は、実際のコンテナ幅に合わせて確認します。Embla v8を使用する場合、APIの例は同じメジャーバージョンのドキュメントで確認します。
各Reactコンポーネントがどのような外観であるべきか、私の考え
421
13 分。
SST規格は、タブのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています
62
11 分。
SSF-U規格は、フルスクリーン表示のセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています
248
3 分。
SSA規格は、アコーディオンのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を定めています
258
4 分。
ウェブデザインにおける致命的なミスの分析。スライダー、自動再生、重いページがコンバージョン率やGoogleでの検索順位を低下させる理由
232
2 分。
SSP規格は、ページネーションのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています
240
3 分。