Reactで、横にめくれる画像のカルーセルを作る方法

​​React​CSS,CSS Scroll Snap,Playwright,TypeScript,アクセシビリティ

画像を横に並べた枠に overflow-x: auto と scroll-snap-type: x mandatory を付けると、指でめくったときに画像の切れ目でぴったり止まるカルーセルになります。めくる動きはブラウザのスクロールに任せるので、React 側で持つのは「いま何枚目か」の state と、前へ・次へのボタンだけです。

ライブラリを使わずに ImageCarousel という部品を作り、指でめくったとき、ボタンで送ったとき、画面の幅が変わったときの動きを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。指の操作は Chrome DevTools Protocol の Input.dispatchTouchEvent で再現しています。実機のスマートフォン、Firefox と Safari、勢いよくはじいたあとの慣性のスクロールは試していません。

カルーセルを指で120pxめくると1枚目に戻り、240pxめくると2枚目で止まり、次へボタンを押すと3枚目に送られる
指で2回めくり、次へボタンを1回押したときの動き。下の「1 / 5」の数字は、変わった瞬間だけピンクになる(色は GIF 用に付けたもの)

スクロールする枠に、画像を横に並べる

ImageCarousel は、カルーセルの名前(label)と、画像の一覧(slides)を受け取ります。画像は1枚ずつ div で包み、スクロールする枠(.carousel-track)の中に横に並べます。

tsx
import { useRef, useState } from 'react';

type Slide = { src: string; alt: string };

type Props = {
  label: string;
  slides: Slide[];
};

export function ImageCarousel({ label, slides }: Props) {
  const [index, setIndex] = useState(0);
  const trackRef = useRef<HTMLDivElement>(null);
  const last = slides.length - 1;

  // スクロールの位置から、いま見えているスライドの番号を求める
  const handleScroll = () => {
    const track = trackRef.current;
    if (!track) return;
    setIndex(Math.round(track.scrollLeft / track.clientWidth));
  };

  // scrollIntoView ではなく、枠の中だけをスクロールする
  const goTo = (i: number) => {
    const track = trackRef.current;
    if (!track) return;
    track.scrollTo({ left: i * track.clientWidth, behavior: 'smooth' });
  };

  return (
    <section className="carousel" aria-roledescription="carousel" aria-label={label}>
      <div className="carousel-track" ref={trackRef} onScroll={handleScroll} tabIndex={0}>
        {slides.map((slide, i) => (
          <div
            key={slide.src}
            className="carousel-slide"
            role="group"
            aria-roledescription="slide"
            aria-label={`${i + 1} / ${slides.length}`}
          >
            <img src={slide.src} alt={slide.alt} loading="lazy" draggable={false} />
          </div>
        ))}
      </div>
      <div className="carousel-controls">
        <button type="button" onClick={() => goTo(index - 1)} disabled={index === 0} aria-label="前の画像">
          ‹
        </button>
        <span className="carousel-count" aria-hidden="true">
          {index + 1} / {slides.length}
        </span>
        <button type="button" onClick={() => goTo(index + 1)} disabled={index === last} aria-label="次の画像">
          ›
        </button>
      </div>
    </section>
  );
}

CSS は次のとおりです。枠を横並びの flex にして、横にスクロールできるようにします。1枚の幅は flex: 0 0 100% で枠と同じにしています。

css
.carousel {
  max-width: 400px;
}
.carousel-track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
}
.carousel-slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
}
.carousel-slide img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}
.carousel-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  margin-top: 8px;
}
.carousel-controls button {
  width: 44px;
  height: 44px;
  font-size: 24px;
}

使う側は、次のように書きます。

tsx
const slides = [
  { src: 'images/slide-1.svg', alt: '1枚目の画像' },
  { src: 'images/slide-2.svg', alt: '2枚目の画像' },
  // …
];

<ImageCarousel label="作例の写真" slides={slides} />

検証では、同じ形の一覧を Array.from で5枚分作って渡しました。

scrollbar-width: none は、スクロールバーを出さずに、スクロールはできるままにする指定です。この説明には、スクロールバーを隠すとアクセシビリティを損なうので none は避けること、ほかにスクロールする手段を用意しないと、スクロールしにくくなったり、できなくなったりすることも書かれています。ImageCarousel では、前へ・次へのボタンと、枠にフォーカスしての矢印キー(どちらも後で確かめます)でめくれるようにしたうえで、スクロールバーを隠しています。draggable={false} は、マウスで画像をつかんだときに、ブラウザが画像のドラッグを始めないようにするためのものです(draggable 属性の説明)。この2つの効き目は、検証では確かめていません。

scroll-snap で、めくった先の画像の切れ目に止める

scroll-snap-type は、スクロールする枠に、どの向きにどれだけ強く止まるかを指定するプロパティです。x mandatory にすると横向きで、スクロールが終わったときに必ずどこかの止まる位置に合わせます。止まる位置は、中の要素に付けた scroll-snap-align で決まります。start なので、画像の左の端が枠の左の端にそろう位置です。値の意味は、scroll-snap-type の説明と、Scroll Snap の基本の解説にあります。

指の操作を再現して、幅400pxのカルーセルを左へめくりました。指を20回に分けて動かし、1回ごとの間を16msにしたときと、150msにしたときで比べています。

text
scroll-snap あり(ImageCarousel): 16msごとに左へ80px動かして離す → scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる
scroll-snap あり(ImageCarousel): 16msごとに左へ160px動かして離す → scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる
scroll-snap あり(ImageCarousel): 16msごとに左へ240px動かして離す → scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる
scroll-snap あり(ImageCarousel): 150msごとに左へ80px動かして離す → scrollLeft=0 表示=1/5 前へ=押せない 次へ=押せる
scroll-snap あり(ImageCarousel): 150msごとに左へ160px動かして離す → scrollLeft=0 表示=1/5 前へ=押せない 次へ=押せる
scroll-snap あり(ImageCarousel): 150msごとに左へ240px動かして離す → scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる
scroll-snap-type: none の版: 16msごとに左へ80px動かして離す → scrollLeft=68 表示=1/5 前へ=押せない 次へ=押せる
scroll-snap-type: none の版: 16msごとに左へ160px動かして離す → scrollLeft=156 表示=1/5 前へ=押せない 次へ=押せる
scroll-snap-type: none の版: 16msごとに左へ240px動かして離す → scrollLeft=241 表示=2/5 前へ=押せる 次へ=押せる
scroll-snap-type: none の版: 150msごとに左へ80px動かして離す → scrollLeft=65 表示=1/5 前へ=押せない 次へ=押せる
scroll-snap-type: none の版: 150msごとに左へ160px動かして離す → scrollLeft=145 表示=1/5 前へ=押せない 次へ=押せる
scroll-snap-type: none の版: 150msごとに左へ240px動かして離す → scrollLeft=225 表示=2/5 前へ=押せる 次へ=押せる

scroll-snap がある ImageCarousel では、どの場合も scrollLeft が0か400になりました。画像の途中で止まることはありません。素早く動かしたときは80pxでも2枚目へ進み、ゆっくり動かしたときは、半分(200px)に届かない80pxと160pxでは1枚目に戻りました。上の GIF の1回目と2回目も、ゆっくり120pxと240px動かしたものです。scroll-snap-type の説明にあるとおり、止まる位置へどう動くかはこのプロパティでは決めず、ブラウザに任されています。

scroll-snap-type: none にした版では、速さにかかわらず指を離したところで止まり、2枚の画像にまたがったまま(scrollLeft が68や156など)になりました。

何枚目かは、スクロールの位置から求める

指でめくったときも、ボタンで送ったときも、枠はスクロールします。そこで、何枚目かの state は、枠の onScroll で更新しています。1枚の幅は枠の幅(clientWidth)と同じなので、scrollLeft を枠の幅で割って四捨五入すれば、見えている画像の番号になります。

前へ・次へのボタンは、その番号から1つずらした位置へ、枠を scrollTo() でスクロールさせるだけです。behavior: 'smooth' を付けると、なめらかに動きます。最初と最後の画像では、disabled でボタンを押せなくしています。

次へボタンを4回、前へボタンを1回押した結果です。scrollLeft が400ずつ動き、5枚目では次へボタンが押せなくなりました。

text
最初: scrollLeft=0 表示=1/5 前へ=押せない 次へ=押せる
次へ 1回目: scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる
次へ 2回目: scrollLeft=800 表示=3/5 前へ=押せる 次へ=押せる
次へ 3回目: scrollLeft=1200 表示=4/5 前へ=押せる 次へ=押せる
次へ 4回目: scrollLeft=1600 表示=5/5 前へ=押せる 次へ=押せない
前へ 1回: scrollLeft=1200 表示=4/5 前へ=押せる 次へ=押せる

scrollIntoView だと、ページまで縦に動く

ボタンで送るときは、次の画像の要素で scrollIntoView() を呼ぶ書き方も思いつきます。ただ、scrollIntoView() の説明にあるとおり、このメソッドは要素が見えるように、外側のスクロールできる枠をまとめて動かします。何も指定しないと、縦の位置は block: "start" になり、要素の上の端が画面の上の端にそろいます。

上と下に高さ1200pxの内容を置き、画像の上半分ほどだけが画面の下の端に見えている状態で、2枚目へ送りました。スクロールの止まった位置だけを比べるため、どれも behavior: 'instant' で動かしています。

text
slide.scrollIntoView(): ページの scrollY 610 → 1200、カルーセルの scrollLeft=400
slide.scrollIntoView({ block: 'nearest', inline: 'start' }): ページの scrollY 610 → 725、カルーセルの scrollLeft=400
track.scrollTo({ left })(ImageCarousel): ページの scrollY 610 → 610、カルーセルの scrollLeft=400

どの書き方でも、カルーセルは2枚目へ進みました。しかし scrollIntoView() ではページも縦に590px動き、カルーセルが画面の上の端まで引き上げられました。block: 'nearest' にしても、画像の全体が見えるところまで115px動いています。枠の scrollTo() なら、ページの位置は変わりません。読んでいる場所が勝手に動かないように、ImageCarousel では枠の scrollTo() を使っています。

キーボードでは、枠にフォーカスして矢印キーでめくる

枠に tabIndex={0} を付けているので、Tab で枠にフォーカスできます。フォーカスしたまま左右の矢印キーを押すと、ブラウザが枠を横にスクロールし、scroll-snap で次の画像の切れ目に止まりました。

text
Tab でフォーカスした要素: carousel-track
ArrowRight: scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる
ArrowRight: scrollLeft=800 表示=3/5 前へ=押せる 次へ=押せる
ArrowLeft: scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる
End: scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる
Home: scrollLeft=400 表示=2/5 前へ=押せる 次へ=押せる

End と Home では、横には動きませんでした。最初や最後の画像へ一度に飛ばしたいときは、onKeyDown などで処理を足す必要があります(この処理は試していません)。

役割や名前は、WAI-ARIA Authoring Practices Guide のカルーセルのパターンに合わせました。カルーセル全体を包む要素は region か group にし、aria-roledescription を carousel にします。1枚ずつの要素は role="group" で、aria-roledescription を slide にします。画像ごとに付けられる名前がないときは、「3 of 10」のような番号と全体の数を名前にしてよいとされています。ImageCarousel では「1 / 5」のように付けました。

Playwright で取り出したアクセシビリティツリーでは、aria-label を付けた section が region になっていました。

text
- region "作例の写真":
  - group "1 / 5":
    - img "1枚目の画像"
  - group "2 / 5":
    - img "2枚目の画像"
  - group "3 / 5":
    - img "3枚目の画像"
  - group "4 / 5":
    - img "4枚目の画像"
  - group "5 / 5":
    - img "5枚目の画像"
  - button "前の画像" [disabled]: ‹
  - button "次の画像": ›

ボタンの記号「‹」「›」は、読み上げでは意味が伝わりにくいので、aria-label で「前の画像」「次の画像」という名前にしています。「1 / 5」の表示は、各画像の名前と重なるので aria-hidden="true" にしました。スクリーンリーダーでの読み上げは試していません。

画面の幅が変わっても、同じ画像のまま止まる

スマートフォンを横に倒したときなどは、枠の幅が変わります。3枚目を表示しているところで、画面の幅を400pxから320pxに変えました。この検証では、カルーセルが画面いっぱいに広がるように max-width を外しています。

text
scroll-snap あり(ImageCarousel): 幅400px scrollLeft=800 表示=3/5 前へ=押せる 次へ=押せる → 幅320px scrollLeft=640 表示=3/5 前へ=押せる 次へ=押せる
scroll-snap-type: none の版: 幅400px scrollLeft=800 表示=3/5 前へ=押せる 次へ=押せる → 幅320px scrollLeft=800 表示=3/5 前へ=押せる 次へ=押せる

scroll-snap がある ImageCarousel では、scrollLeft が新しい幅の2枚分(640)になり、3枚目のまま止まっていました。scroll-snap-type の説明にも、中身の大きさが変わったときは、前に止まっていた要素に止まり直すと書かれています。

scroll-snap-type: none の版では scrollLeft が800のまま残り、3枚目と4枚目が160pxずつ見える状態になりました。表示は「3 / 5」のままでした。onScroll が呼ばれていれば、800÷320=2.5を四捨五入して3になるので、表示は「4 / 5」に変わるはずです。表示が変わっていないので、onScroll は呼ばれていないと分かります。

loading="lazy" で、遠い画像の読み込みを後回しにする

画像には loading="lazy" を付けています。img 要素の説明によれば、必要になると見込まれるまで、画像の読み込みを遅らせる指定です。

画像を8枚にして、ページを開いた直後に読み込まれた画像を数えました。

text
loading="lazy"(ImageCarousel): loading=lazy 開いた直後に読み込んだ画像: 1,2,3,4,5
loading 属性なしの版: loading=null 開いた直後に読み込んだ画像: 1,2,3,4,5,6,7,8

loading="lazy" では、見えている1枚目だけでなく、5枚目まで読み込まれました。見えていない画像も、近くにあるものはブラウザが先に読み込むようです。6枚目から8枚目は、開いた直後には読み込まれませんでした。img 要素の説明にも、必要になると見込まれるまで読み込みを遅らせるとあるだけで、何枚目まで先に読み込むかは書かれていません。

同じ説明には、遅れて読み込む画像には width と height を書くことがとくに大切だともあります。読み込む前の画像は大きさが0になり、表示されている範囲に入らないと読み込まれないためです。ImageCarousel では、CSS の width: 100% と aspect-ratio: 16 / 9 で、読み込む前から画像の場所の大きさを決めています。

​Posted by 86