Next.js Image Carousel and Slider: CSS Scroll Snap vs. Embla

Published: September 12, 2026 · 6 min read

Build an image carousel (slider) in Next.js with library-free CSS scroll snap or Embla Carousel: prev/next buttons, dots, pausable autoplay, and fast next/image.

Start a Next.js image slider with CSS scroll snap

You want a photo slider on a Next.js landing page or product page, and you are stuck between installing a library and writing it yourself. This guide is for that.

Start your image carousel (slider) with CSS scroll snap. Add snap to a horizontal scroller and you get swipe-to-next, one photo at a time, for free. Move to a library like Embla Carousel only when you need looping or momentum dragging.

What you will get from this article:

  • A library-free image slider with CSS scroll snap

  • Prev/next buttons and dots

  • Autoplay, and how to let people stop it

  • A looping carousel with Embla Carousel

  • next/image settings that show the first photo fast

Decide whether you need an image carousel at all

A carousel hides everything except the photo in view. Assume slides after the first may never be seen. With three photos or fewer, a grid shows them all.

A carousel fits when:

  • You want four or more photos in one same-size frame

  • Shoppers should flip through product photos at their own pace

  • You do not want the mobile page to get much taller

Photos that change as you scroll are a different component, not a carousel. See https://design-layer.com/en/articles/nextjs-scroll-gallery-guide

Three reasons image sliders frustrate people

Plenty of sliders look fine and still trip people up. Usually it is one of these.

  • Prev/next buttons disappear against the photo, or cannot be pressed with a keyboard

  • Swiping does not work on phones, leaving tiny buttons as the only control

  • Photos have different heights, so the whole page jumps on every slide

CSS scroll snap solves the second one out of the box. Horizontal scrolling is a browser feature, so swipes and trackpads just work. Button design and a fixed aspect ratio handle the other two.

Build an image slider with CSS scroll snap

Give the row of photos overflow-x-auto (allow horizontal scrolling) and snap-x snap-mandatory (always stop on a photo), and give each photo snap-center (stop it in the middle). Swipes now stop one photo at a time, centered.

Keep track of the current slide for the buttons and dots. Scroll position divided by width gives the index.

components/ImageCarousel.tsx
"use client";

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

  const slides = [
    { src: "/carousel/01.jpg", alt: "White mug on a wooden desk" },
    { src: "/carousel/02.jpg", alt: "The mug seen from the side" },
    { src: "/carousel/03.jpg", alt: "Close-up of the mug handle" },
    { src: "/carousel/04.jpg", alt: "Three mugs in different colors" },
  ];

  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="Product photos"
        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="Previous photo"
            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={`Show photo ${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="Next photo"
            className="grid h-10 w-10 place-items-center rounded-full border border-zinc-300 disabled:opacity-30"
          >
            →
          </button>
        </div>
      </section>
    );
  }

Fix the slider's aspect ratio so the page does not jump

Each slide gets aspect-[3/2], and the photo fills it with fill and object-cover. Even if source photos vary, the frame height never changes, so the page stops jumping between slides.

Put previous and next buttons outside the photo

Buttons overlaid on a photo vanish on bright images. This code places buttons and dots under the photo. If you want them on top, give them a white circular background and a shadow so they stand out on any image.

On the first and last slide, the buttons get disabled and fade. A button that clearly cannot be pressed is kinder than one that silently does nothing.

Carousel dots announce the position with aria-current

Dots should tell screen readers which slide is showing, not just sighted users. Put aria-current on the current one and an aria-label like “Show photo 3.”

Autoplay carousel: build the stop button first

Autoplaying sliders are a common request. But when the photo changes mid-read, users feel control was taken away. If you add autoplay, include all three of these.

  • A pause/play button

  • Pausing while the pointer hovers or keyboard focus is inside

  • No autoplay at all for users who reduce motion in their OS

For product photos, letting shoppers advance slides themselves gets them looked at longer. Save autoplay for mood photos where stopping anywhere is fine.

components/ImageCarousel.tsx (autoplay additions)
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]);

  // Add to the section
  // onMouseEnter={() => setHovered(true)}
  // onMouseLeave={() => setHovered(false)}
  // onFocus={() => setHovered(true)}
  // onBlur={() => setHovered(false)}

  // Add to the controls
  <button
    type="button"
    onClick={() => setPlaying((p) => !p)}
    className="rounded-full border border-zinc-300 px-4 py-2 text-sm"
  >
    {playing ? "Pause autoplay" : "Play autoplay"}
  </button>

Advance the slider every 5 seconds or slower

If photos carry short captions, 3 seconds is not enough to read them. Use 5 seconds or more, longer with longer captions. Remember to add useEffect to the import at the top of the file.

Build a looping carousel with Embla Carousel

Looping from the last slide back to the first, momentum after release, syncing with thumbnails: once you need these, a library is faster and more reliable. Embla Carousel is a lightweight headless library, so you style it freely with Tailwind CSS. shadcn/ui's Carousel is built on Embla too.

Install with npm i embla-carousel-react. Keep the structure: the ref goes on the outer viewport, with a flex row inside.

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="Portfolio photos" 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="Previous photo" onClick={() => emblaApi?.scrollPrev()}>
            ←
          </button>
          <button type="button" aria-label="Next photo" onClick={() => emblaApi?.scrollNext()}>
            →
          </button>
        </div>
      </section>
    );
  }

Adding dots to an Embla carousel

Listen for changes with emblaApi.on("select", ...), read the position with emblaApi.selectedScrollSnap(), and keep it as the current slide. Render the dots the same way as in the scroll snap example.

Run only the carousel in the browser

Embla uses React hooks, so the file needs "use client" (the marker that says this component runs in the browser) at the top. Keep page.tsx on the server and pass the photo array in as props. The rest of the page stays light because it is not pulled into the client bundle.

Choosing a carousel: CSS scroll snap or Embla

Pick by the behavior you need. When unsure, start with scroll snap; switching later is cheap.

  • CSS scroll snap: one slide at a time, no new dependency, swipe handled by the browser

  • Embla: looping, momentum dragging, thumbnail or autoplay plugins

  • Neither: photos that change as the page scrolls belong in a scroll-linked gallery

Copy a finished carousel or gallery instead

The Works Expand page on DesignLayer: check the live preview on the left, then copy the source with Copy for Cursor on the right
The Works Expand page on DesignLayer: check the live preview on the left, then copy the source with Copy for Cursor on the right

A pinned column of work that swaps as you scroll, or photos that expand on hover, cannot be built from a normal carousel. Watching the real motion first is the quickest way to decide which kind fits.

DesignLayer is a catalog of human-built React + Tailwind motion sections you can live-preview in the browser, then copy. Browsing needs no account and no card. https://design-layer.com/en/components

A free account copies the 10 Free Sample components with no monthly limit on a free account, plus 1 video prompt a month. Free cannot connect MCP. The rest of the catalog is the 30-Day Build Pass ($19 once, no auto-renewal), Lifetime Access ($79 once, first 100 buyers), or Plus ($9/month). After copying, swap only the photo paths and alt text.

Next.js image slider and carousel: common questions

  • In web work they mean nearly the same thing: a component that swaps images horizontally inside a fixed frame. This article treats both names as one.

Summary: building an image slider in Next.js

  • Three photos or fewer: use a grid; four or more in one frame: use a carousel

  • Start with CSS scroll snap and let the browser handle swipes

  • Fix the aspect ratio; keep buttons outside the photo or on a clear background

  • Autoplay needs a pause button, pause on hover/focus, and reduced motion support

  • Move to Embla Carousel when you need looping or momentum

Paste ImageCarousel, swap in your photos, and try it on a phone. For scroll-driven photo changes, see https://design-layer.com/en/articles/nextjs-scroll-gallery-guide and for info on hover over photos, https://design-layer.com/en/articles/nextjs-hover-card-guide

Related guides