SSS - スライダーの統一規格

SSS - スライダーの統一規格
基準

35

19 分。

定義

SSS規格は、スライダーのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を定めています。

要件は各コンポーネントの動作に応じて適用します。たとえば、端で停止するか循環するか、手動操作か自動切り替えか、同時に表示するスライド数などが該当します。

実装例ではReactアダプターを使ったEmbla Carouselを使用します。このライブラリは例として取り上げるものであり、必須の依存関係ではありません。例のAPIはEmbla v8に対応しています。

構造とセマンティクス

ナビゲーションとスライドの端

  • スライドの切り替えにはネイティブの<button>を使用します。リンクは別のURLへの移動に使用し、スライダーの操作には使用しません。
  • 端で停止するスライダーでは、最初のsnap位置で「前へ」ボタンを、最後の位置で「次へ」ボタンを無効にします。循環方式では、端を越えて移動できる仕様の場合、両方のボタンを引き続き使用可能にします。
  • 操作できない状態はネイティブのdisabled属性で示します。見た目も有効状態と区別します。たとえばopacityやnot-allowedカーソルを変更できます。見た目だけでは支援技術に状態が伝わりません。
  • すべての項目がビューポート内に収まり、位置を切り替える必要がない場合は、矢印とスライド選択ボタンを非表示にします。判定にはスライド総数だけでなく、実際のオーバーフローを使用します。
  • 初期化時、およびビューポートやコンテンツのサイズが変わったときにオーバーフローを確認します。ResizeObserverなどでコンテナとスライド列のサイズを測定できます。要素のサイズはウィンドウサイズが変わらなくても変化するため、window.resizeだけの監視では不十分です。
  • データ更新、フォント読み込み、レスポンシブレイアウトの変更がスライドの寸法に影響する場合も、オーバーフローを再確認します。
  • Emblaでは、初期化時とselect、reInitイベント発生時に、canScrollPrev()とcanScrollNext()に合わせて「前へ」「次へ」ボタンの状態を同期します。ボタンはドラッグ操作を処理するビューポート要素の外側に配置します。

レスポンシブ動作

  • 表示するスライド数とgapは、コンポーネントが利用できる幅に応じて設定します。画面幅に応じてレイアウトを変える場合はメディアクエリ、親コンテナの幅に応じて変える場合はコンテナクエリが適しています。
  • 狭い画面では、隣のスライドを10〜20pxほど部分表示すると、続きのコンテンツがあることを示せます。これは固定要件ではなくデザイン上の推奨です。表示幅はコンテナ幅、間隔、文字の方向に応じて調整します。
  • 部分表示するスライドが操作ボタンに重なったり、重要なテキストを切り取ったり、ページ全体に横スクロールを発生させたりしないようにします。
  • 移動方向、項目の順序、ナビゲーションのラベルは、インターフェースの読み方向に合わせます。RTLの画面では「前へ」「次へ」の動作と矢印の向きを確認します。

アクセシビリティとインタラクション

セマンティクスとアクセシブルな名前

  • スライダーがページ上の重要な領域である場合はコンテナに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は不要です。
  • スライドまたはスライドグループを選ぶボタンはグループ化し、グループにもアクセシブルな名前を設定します。選択中のボタンには、採用するパターンに合ったセマンティクスを使います。APGに沿った通常のボタングループでは選択中のボタンにaria-disabled="true"を指定し、タブ方式ではaria-selected="true"を指定します。aria-current="true"をスライドコンテナに機械的に設定せず、採用したパターンに沿って操作ボタンに現在位置を示します。
  • ビューポート外に表示されるスライドに、画面上で見えない位置のフォーカス可能な要素を残さないようにします。aria-hidden="true"は、コンテンツをアクセシビリティツリーとフォーカス移動の両方から一貫して除外する場合に限って使用します。部分表示されているスライドは非表示扱いにしません。

ドット(ページネーション)

  • ドットは任意のナビゲーション手段であり、すべてのスライダーに必須ではありません。特定の位置へ直接移動することが有用で、Tab順に過剰な数の操作対象を追加しない場合に適しています。
  • 各ドットは1つのスクロール位置(snap point)に対応し、必ずしも1枚のスライドに対応するわけではありません。1回の操作でスライドグループを移動する場合、ドットのアクセシブルな名前でそのグループを説明します。EmblaではscrollSnapList()で位置を取得し、scrollTo(index)で移動します。
  • 選択中のドットは視覚的に区別し、選択したパターンに沿ってプログラム上でも示します。通常のボタンによる選択グループでは、APGのパターンに沿って選択中のボタンにaria-disabled="true"を指定します。タブ方式ではaria-selectedとキーボード操作を含むtabsパターン全体が必要です。色や透明度の変更だけでは不十分です。
  • 各ドットボタンには、目的と位置が分かるアクセシブルな名前を設定します。例:aria-label="5つの位置のうち2番目へ移動"。スライドをグループ化する場合は、その範囲または内容も名前に含めます。
  • ドットにはネイティブの<button>を使用し、:focus-visibleのフォーカス表示を明確にします。ポインター操作のターゲットサイズはWCAG 2.2 SC 2.5.8に適合させます。原則として24 × 24 CSS px以上とし、十分な間隔や同等の操作手段など、基準に定められた例外も考慮します。
  • Emblaでは、ドットのコンテナをuseEmblaCarouselに渡すビューポートの外側に配置します。ビューポートはポインターとドラッグ操作を処理するため、その内部にボタンを置くとクリックを妨げることがあります。
  • ドットの状態は、初期化時とselect、reInitイベント発生時にEmblaと同期します。コンテナ幅やスライド構成の変更によって、snap位置とドット数も変わる場合があります。

キーボード、ドラッグ、自動切り替え

  • ポインターやタッチで行える操作は、キーボードでも実行できるようにします。ナビゲーションにはボタンと標準のTab順を使用し、矢印やドットを操作した際にフォーカスが予期せず移動しないようにします。
  • ポインターによるドラッグに対応する場合、ドラッグ領域の通常時にはgrab、押しながらドラッグしている間はgrabbingカーソルを表示できます。この視覚的な手がかりは、操作ボタン、キーボード操作、タッチ操作の代わりにはなりません。
  • 横方向のスワイプが、ページの縦スクロール、テキスト選択、内部のインタラクティブ要素の操作を妨げないようにします。
  • 手動操作のスライダーでは、変更を現在の読み上げを中断せずに通知するため、スライド領域にaria-live="polite"を指定できます。自動切り替え中は、通知が連続しないようにaria-live="off"を使用します。
  • 自動切り替えを行う場合は、停止・再開できるアクセシブルなボタンを設けます。スライダー内のいずれかの要素にキーボードフォーカスが移動したとき、またはポインターがスライダー上にある間は自動切り替えを停止します。ユーザーが明示的に操作しない限り再開しません。
  • アニメーションはシステム設定prefers-reduced-motionを尊重します。動きを減らす設定が有効な場合、状態理解に必要でないトランジションは短縮または無効化します。

高さとパフォーマンス

高さとレイアウトの安定性

  • height: autoを一律に禁止しません。高さの自動計算自体が表示の跳ねを引き起こすわけではありません。コンテンツ切り替え時の高さ変更が考慮されていない場合にレイアウトシフトが発生します。
  • レイアウトの高さを安定させる必要がある場合は、アスペクト比、min-height、またはスライド間で統一した高さを使って領域を確保します。固定高によって、テキスト量の増加、言語切り替え、拡大表示時にコンテンツが切れないようにします。
  • 高さが異なるスライドでは、周辺コンテンツを予期せず移動させないことを前提に、現在のスライドに合わせて領域の高さを変更できます。長い見出しや翻訳版を含む実際のコンテンツで高さを確認します。

アニメーションとスクロール

  • 移動のアニメーションにはtransformを、適切な場合はopacityを使用します。leftやmarginの変更はレイアウト再計算を発生させる場合があるためです。最終的な選択はプロファイリングで確認します。CSSプロパティだけで滑らかさが保証されるわけではありません。
  • will-change: transformをすべてのスライドに初期設定しません。パフォーマンス上の問題が測定で確認された場合に限り使用します。多くのレイヤーにリソースを常時確保すると、逆にパフォーマンスが低下する場合があります。
  • ネイティブの横スクロールを使うスライダーにはCSS Scroll Snapを使用できます。ブラウザー標準のスクロール動作と、タッチや支援技術との操作性を保ちやすくなります。対象ブラウザーで動作を確認します。

画像の読み込み

  • 初期表示時に見える画像や、重要な最初のスライドの画像にはloading="lazy"を指定しません。初期ビューポート外にある画像には遅延読み込みを使用できます。
  • loading="lazy"は「1〜2枚前から」のような距離を指定する属性ではありません。読み込み開始のタイミングは、ビューポートとの距離などに基づいてブラウザーが決定します。次のスライドをより早く読み込む場合は、計測結果とネットワーク負荷を根拠に別途判断します。
  • 画像にはwidthとheight、または既知のアスペクト比を指定します。読み込み前に領域を確保し、レイアウトシフトのリスクを減らします。
  • srcsetとsizesを使う場合は、スライド内で画像が表示されるレスポンシブ幅に合わせます。必要がないのにサムネイル用として原寸画像を読み込まないようにします。

検証

  • 端で停止する方式と循環方式のそれぞれで「前へ」「次へ」ボタンの状態が正しく、disabledが支援技術に伝わり、見た目でも判別できます。
  • 矢印とドットはナビゲーションが不要な場合に限って非表示になります。初期化後とコンテナ・コンテンツのサイズ変更後にオーバーフローを確認します。
  • キーボード、ポインター、タッチで操作でき、フォーカスが失われたり予期せず移動したりしません。
  • コンテナ、スライド、矢印、ドットのアクセシブルな名前が明確です。現在位置は選択したセマンティクスで伝わり、自動切り替えを停止できます。
  • ドット数がsnap位置の数と一致し、ドラッグ、位置選択、サイズ変更の後も状態が同期します。ターゲットサイズまたは間隔がWCAG 2.2 SC 2.5.8に適合することを確認します。
  • 手動・自動それぞれの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コンポーネントの構造

    各Reactコンポーネントがどのような外観であるべきか、私の考え

    421

    13 分。

  • SST - タブに関する統一規格

    SST規格は、タブのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています

    62

    11 分。

  • SSF-U - フルスクリーン表示の統一規格

    SSF-U規格は、フルスクリーン表示のセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています

    248

    3 分。

  • SSA - アコーディオンの統一標準

    SSA規格は、アコーディオンのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を定めています

    258

    4 分。

  • ウェブサイトにおける悪い慣行

    ウェブデザインにおける致命的なミスの分析。スライダー、自動再生、重いページがコンバージョン率やGoogleでの検索順位を低下させる理由

    232

    2 分。

  • SSP - ページネーションの統一規格

    SSP規格は、ページネーションのセマンティクス、アクセシビリティ、および動作ロジックに関する要件を規定しています

    240

    3 分。

  • すべての記事

ご連絡ください