【Next.js】画像スライダー・カルーセルの作り方を紹介|CSS と Embla

公開日: 2026年9月12日 · 読了目安 13 分

Next.js で画像スライダー(カルーセル)を作る方法を、ライブラリなしの CSS スクロールスナップと Embla Carousel の2通りのコードで解説。前へ・次へボタン、ドット、自動再生の停止、next/image での表示速度まで扱います。

画像スライダーは CSS のスクロールスナップから始める

Next.js の LP や商品ページに、写真を横に送る画像スライダーを置きたい。ライブラリを入れるべきか、自分で書くべきかで手が止まっている。

そんな人に向けた記事です。

画像スライダー(カルーセル)は、まず CSS のスクロールスナップで作るのがおすすめです。横スクロールに snap を付けるだけで、スワイプで1枚ずつ止まる動きが手に入ります。

ループや慣性のあるドラッグが必要になったら、そのときに Embla Carousel などのライブラリへ移ります。

この記事で分かること:

  • ライブラリなしで作る、CSS スクロールスナップの画像スライダー

  • 前へ・次へボタンとドットの付け方

  • 自動再生と、その止め方

  • Embla Carousel でループするカルーセルを作るコード

  • next/image で1枚目を速く表示する設定

カルーセルにするべきかを先に決める

カルーセルは、見えている1枚以外が隠れる部品です。2枚目以降は見られない前提で考えた方が安全です。

写真が3枚までなら、グリッドで並べた方が全部見てもらえます。

カルーセルが向いているのは、次のような場面です。

  • 4枚以上の写真を、同じ大きさの枠に収めたい

  • 商品の写真を、ユーザーが自分のペースで送って見たい

  • スマホで縦に長くしたくない

一方、スクロールに合わせて作品が切り替わる演出は、カルーセルとは別の部品です。そちらは https://design-layer.com/ja/articles/nextjs-scroll-gallery-guide で解説しています。

使いにくい画像スライダーになる3つの理由

見た目が整っていても、操作でつまずくスライダーは少なくありません。よくある原因は次の3つです。

  • 前へ・次へのボタンが写真に埋もれて見えない、またはキーボードで押せない

  • スマホで指のスワイプが効かず、小さなボタンしか操作手段がない

  • 写真ごとに高さが違い、送るたびにページ全体が上下にずれる

CSS のスクロールスナップは、2つ目の問題を最初から解決します。横スクロールはブラウザの機能なので、スワイプもトラックパッドもそのまま動きます。

残りの2つは、ボタンの作り方と枠の比率の固定で防ぎます。

CSS スクロールスナップで画像スライダーを作る

横に並べた写真の列には、横スクロールを許す overflow-x-auto と、止まる位置をそろえる snap-x snap-mandatory を付けます。

各写真には、中央で止める snap-center を付けます。これだけで、スワイプすると1枚ずつ中央で止まります。

ボタンとドットのために、今何枚目かを状態として覚えておきます。スクロールの位置を幅で割れば、何枚目かが分かります。

components/ImageCarousel.tsx
"use client";

  import Image from "next/image";
  import { useRef, useState } from "react";

  const slides = [
    { src: "/carousel/01.jpg", alt: "木の机に置かれた白いマグカップ" },
    { src: "/carousel/02.jpg", alt: "マグカップを横から見た写真" },
    { src: "/carousel/03.jpg", alt: "マグカップの持ち手の拡大" },
    { src: "/carousel/04.jpg", alt: "色違いのマグカップが3つ並んだ写真" },
  ];

  export function ImageCarousel() {
    const trackRef = useRef<HTMLDivElement>(null);
    const [index, setIndex] = useState(0);

    const scrollToIndex = (i: number) => {
      const track = trackRef.current;
      if (!track) return;
      const next = Math.max(0, Math.min(slides.length - 1, i));
      track.scrollTo({ left: next * track.clientWidth, behavior: "smooth" });
    };

    const onScroll = () => {
      const track = trackRef.current;
      if (!track) return;
      setIndex(Math.round(track.scrollLeft / track.clientWidth));
    };

    return (
      <section
        aria-roledescription="carousel"
        aria-label="商品の写真"
        className="mx-auto max-w-4xl px-6 py-24"
      >
        <div
          ref={trackRef}
          onScroll={onScroll}
          className="flex snap-x snap-mandatory overflow-x-auto rounded-2xl [scrollbar-width:none]"
        >
          {slides.map((slide, i) => (
            <div
              key={slide.src}
              role="group"
              aria-roledescription="slide"
              aria-label={`${i + 1} / ${slides.length}`}
              className="relative aspect-[3/2] w-full shrink-0 snap-center"
            >
              <Image
                src={slide.src}
                alt={slide.alt}
                fill
                sizes="(min-width: 896px) 848px, 100vw"
                className="object-cover"
                priority={i === 0}
              />
            </div>
          ))}
        </div>

        <div className="mt-4 flex items-center justify-between">
          <button
            type="button"
            onClick={() => scrollToIndex(index - 1)}
            disabled={index === 0}
            aria-label="前の写真"
            className="grid h-10 w-10 place-items-center rounded-full border border-zinc-300 disabled:opacity-30"
          >
            ←
          </button>

          <div className="flex gap-2">
            {slides.map((slide, i) => (
              <button
                key={slide.src}
                type="button"
                aria-label={`${i + 1} 枚目を表示`}
                aria-current={i === index}
                onClick={() => scrollToIndex(i)}
                className={`h-2 w-2 rounded-full transition-colors ${i === index ? "bg-zinc-900" : "bg-zinc-300"}`}
              />
            ))}
          </div>

          <button
            type="button"
            onClick={() => scrollToIndex(index + 1)}
            disabled={index === slides.length - 1}
            aria-label="次の写真"
            className="grid h-10 w-10 place-items-center rounded-full border border-zinc-300 disabled:opacity-30"
          >
            →
          </button>
        </div>
      </section>
    );
  }

スライダーの枠の比率を固定してページのずれを防ぐ

各スライドに aspect-[3/2] を付け、写真は fill と object-cover で枠いっぱいに表示します。元の写真の比率がばらばらでも、枠の高さは変わりません。

送るたびにページが上下にずれる問題は、これで起きなくなります。

前へ・次へボタンは写真の外に置く

前へ・次へのボタンを写真の上に重ねると、明るい写真では見えなくなります。このコードでは、写真の下にボタンとドットを並べています。

写真の上に置きたいときは、白い丸の背景と影を付けます。どの写真の上でも見分けられるようにするためです。

最初と最後の1枚では、ボタンに disabled を付けて薄く表示します。押しても何も起きないボタンを残すより、押せないことが分かる方が親切です。

カルーセルのドットは今の位置を aria-current で伝える

ドットは見た目だけでなく、読み上げソフトにも今何枚目かを伝えます。aria-current を今の1枚に付け、aria-label に「3 枚目を表示」のような文言を入れます。

カルーセルを自動再生するなら停止ボタンを先に作る

画像スライダーを自動で送りたいという要望はよくあります。ただ、読んでいる途中で写真が変わると、ユーザーは操作を奪われたと感じます。

自動再生を付けるなら、次の3つを必ず揃えます。

  • 停止・再生のボタンを置く

  • マウスを乗せている間と、キーボードのフォーカスがある間は止める

  • 端末で動きを減らす設定をしている人には、最初から自動再生しない

商品写真なら、自動で送るよりユーザーが自分で送る方が、じっくり見てもらえます。自動再生は、ブランドの雰囲気を見せる写真など、どこで止めても問題ない場面に絞ります。

components/ImageCarousel.tsx(自動再生の追加分)
const [playing, setPlaying] = useState(true);
  const [hovered, setHovered] = useState(false);

  useEffect(() => {
    if (!playing || hovered) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;

    const id = window.setInterval(() => {
      const track = trackRef.current;
      if (!track) return;
      const current = Math.round(track.scrollLeft / track.clientWidth);
      const next = (current + 1) % slides.length;
      track.scrollTo({ left: next * track.clientWidth, behavior: "smooth" });
    }, 5000);

    return () => window.clearInterval(id);
  }, [playing, hovered]);

  // section に付ける
  // onMouseEnter={() => setHovered(true)}
  // onMouseLeave={() => setHovered(false)}
  // onFocus={() => setHovered(true)}
  // onBlur={() => setHovered(false)}

  // ボタンの並びに足す
  <button
    type="button"
    onClick={() => setPlaying((p) => !p)}
    className="rounded-full border border-zinc-300 px-4 py-2 text-sm"
  >
    {playing ? "自動再生を止める" : "自動再生する"}
  </button>

スライダーの送る間隔は 5 秒以上にする

写真に短い説明文が付いている場合、3 秒では読み終わりません。5 秒以上を目安にし、説明文が長いならさらに延ばします。

なお、ファイル先頭の import に useEffect を足すのを忘れないでください。

ループするカルーセルは Embla Carousel で作る

次のような動きが必要になったら、ライブラリを使った方が早く確実です。

  • 最後の1枚から最初に戻るループ

  • 指を離した後も少し滑る慣性

  • サムネイルとの連動

Embla Carousel は見た目を持たない軽いライブラリで、Tailwind CSS で自由に見た目を付けられます。shadcn/ui の Carousel も Embla を土台にしています。

インストールは npm i embla-carousel-react です。外側の箱に ref を渡し、中に flex の列を置く、という形を守ります。

components/EmblaCarousel.tsx
"use client";

  import Image from "next/image";
  import useEmblaCarousel from "embla-carousel-react";

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

  export function EmblaCarousel({ slides }: { slides: Slide[] }) {
    const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });

    return (
      <section aria-roledescription="carousel" aria-label="作品の写真" className="mx-auto max-w-4xl px-6 py-24">
        <div ref={emblaRef} className="overflow-hidden rounded-2xl">
          <div className="flex">
            {slides.map((slide, i) => (
              <div key={slide.src} className="relative aspect-[3/2] min-w-0 flex-[0_0_100%]">
                <Image
                  src={slide.src}
                  alt={slide.alt}
                  fill
                  sizes="(min-width: 896px) 848px, 100vw"
                  className="object-cover"
                  priority={i === 0}
                />
              </div>
            ))}
          </div>
        </div>

        <div className="mt-4 flex justify-between">
          <button type="button" aria-label="前の写真" onClick={() => emblaApi?.scrollPrev()}>
            ←
          </button>
          <button type="button" aria-label="次の写真" onClick={() => emblaApi?.scrollNext()}>
            →
          </button>
        </div>
      </section>
    );
  }

Embla のカルーセルにドットを付けるには

写真が切り替わったことは、emblaApi.on("select", ...) で受け取ります。

続けて emblaApi.selectedScrollSnap() で今の位置を取り出し、状態として覚えます。

ドットの描画は、CSS スナップの例と同じ形で書けます。

カルーセルだけをブラウザで動く部品にする

Embla は、ブラウザ側で状態を扱う React の仕組み(hooks)を使います。そのため、ファイルの先頭に "use client"(この部品はブラウザで動かす、という印)が必要です。

ページ本体の page.tsx は、サーバーで組み立てる部品のままにします。写真の配列は、部品に渡す値(props)として渡します。

ページ全体をブラウザで動かさないことで、他のセクションの読み込みが重くなりません。

CSS スナップと Embla、カルーセルの選び方

どちらを使うかは、必要な動きで決めます。迷ったら CSS スナップで作り、足りなくなってから移っても遅くありません。

  • CSS スナップが向いている: 1枚ずつ送るだけ、依存を増やしたくない、スワイプはブラウザ任せでよい

  • Embla が向いている: ループさせたい、ドラッグの慣性がほしい、サムネイルや自動再生のプラグインを使いたい

  • どちらでもない: スクロールに合わせて写真が切り替わる演出は、スクロール連動のギャラリーで作る

完成したカルーセル・ギャラリーをコピーして使う方法

DesignLayer の「ワークス・エキスパンダ」の詳細ページ。左でライブプレビューを確かめ、右の「Cursor 用にコピー」でソースをコピーできる
DesignLayer の「ワークス・エキスパンダ」の詳細ページ。左でライブプレビューを確かめ、右の「Cursor 用にコピー」でソースをコピーできる

画面にピン留めされたまま写真が切り替わる作品の列や、ホバーで写真が広がる並びは、通常のカルーセルでは再現できません。

まず実際の動きを見て、どの種類が合うかを決めるのが近道です。

DesignLayer は、人が組んだ React + Tailwind のモーション付きセクションを、ブラウザでライブプレビューしてからコピーできるカタログです。

閲覧はアカウントもカードも不要です。https://design-layer.com/ja/components

無料サンプル10個を無料アカウントで回数制限なくコピーでき、動画プロンプトは月1回です。Free では MCP に接続できません。カタログ全体は30日ビルドパス($19の1回払い、自動更新なし)、Lifetime Access(先着100名の$79の1回払い)、Plus(月額 $9、いつでも解約可)です。コピーした後は、写真のパスと alt だけを差し替えます。

画像スライダー・カルーセルについてよくある質問

  • Web 制作ではほぼ同じ意味で使われます。どちらも、決まった枠の中で画像を横に送って切り替える部品を指します。

    この記事では両方の呼び方を同じものとして扱っています。

まとめ:Next.js で画像スライダーを作る手順

  • 写真が3枚までならグリッド、4枚以上を1つの枠に収めたいときにカルーセルを使う

  • まずは CSS のスクロールスナップで作り、スワイプはブラウザに任せる

  • 枠の比率を固定し、ボタンは写真の外か見分けやすい背景付きにする

  • 自動再生には停止ボタン・ホバー中の停止・動きを減らす設定への対応を揃える

  • ループや慣性が必要になったら Embla Carousel に移る

まずはこの記事の ImageCarousel を貼り、自分の写真に差し替えてスマホで送ってみてください。

スクロールで切り替わる見せ方は https://design-layer.com/ja/articles/nextjs-scroll-gallery-guide 、写真の上にホバーで情報を出すなら https://design-layer.com/ja/articles/nextjs-hover-card-guide が参考になります。

関連ガイド