ScreenshotNeo

BlogHow-to

How to Build a Carousel in Next.js: 3 Methods

Build a Next.js carousel with a custom React component, CSS scroll snap, or Embla. Compare the tradeoffs, use accessible controls, and choose the right approach.

By the ScreenshotNeo team4 October 202611 min read

For a simple swipeable row of cards in Next.js, start with native horizontal scrolling and CSS scroll snap. Use a small custom React component when you need tailored controls and state. Choose a library such as Embla when drag behavior, snap navigation, or grouped slides justify a dependency.

This guide implements all three methods for the App Router, explains the tradeoffs, and covers keyboard and screen-reader considerations. Next.js pages are React components exported from files such as app/page.tsx; interactive state belongs in a Client Component. See the Next.js layouts and pages documentation.

Need Starting point What you take responsibility for
A row people can swipe or scroll Overflow with CSS scroll snap Layout, snap behavior, and any extra controls
A deliberately small interaction with custom controls React state Slide sizing, navigation, keyboard behavior, announcements, and edge cases
Drag interactions, snap controls, or grouped slides Embla or another evaluated library Accessible markup and controls, version maintenance, and integration

This is a behavior-based recommendation, not a speed or bundle-size ranking. Measure your own application if performance comparisons matter.

2. Method one: native scrolling with CSS scroll snap

This approach uses browser scrolling for touch and trackpad input. CSS aligns slides to snap points when scrolling settles. It is a good default when users should browse a horizontal row without a complex carousel engine.

Build the component

Create app/components/StoryScroller.tsx:

type Story = {
  id: string;
  title: string;
  description: string;
};

const stories: Story[] = [
  { id: "one", title: "Plan", description: "Choose the story and its key image." },
  { id: "two", title: "Build", description: "Create cards that work at every width." },
  { id: "three", title: "Review", description: "Check scrolling, focus, and announcements." },
];

export function StoryScroller() {
  return (
    <section aria-label="Project stories">
      <div className="story-scroller">
        {stories.map((story) => (
          <article className="story-slide" key={story.id}>
            <h2>{story.title}</h2>
            <p>{story.description}</p>
          </article>
        ))}
      </div>
    </section>
  );
}

Put the styles in app/globals.css:

.story-scroller {
  display: grid;
  grid-auto-columns: minmax(min(82vw, 22rem), 1fr);
  grid-auto-flow: column;
  gap: 1rem;
  overflow-x: auto;
  overscroll-behavior-inline: contain;
  padding: 0.5rem 0.25rem 1rem;
  scroll-padding-inline: 0.25rem;
  scroll-snap-type: x proximity;
}

.story-slide {
  min-height: 12rem;
  padding: 1.25rem;
  border: 1px solid #d7dce2;
  border-radius: 0.75rem;
  background: white;
  scroll-snap-align: start;
}

.story-scroller:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 3px;
}

@media (prefers-reduced-motion: no-preference) {
  .story-scroller {
    scroll-behavior: smooth;
  }
}

Use it from a page, for example app/page.tsx:

import { StoryScroller } from "./components/StoryScroller";

export default function Page() {
  return (
    <main>
      <h1>Project stories</h1>
      <StoryScroller />
    </main>
  );
}

The component is server-renderable because it has no state or browser event handlers. The browser supplies native scrolling. The cards remain in the document rather than being conditionally replaced.

Snap choices and edge cases

  • scroll-snap-type: x proximity encourages alignment without forcing every scroll to a snap point. Use x mandatory only when every stop should align and each slide’s content can still be fully scrolled into view.
  • MDN warns against mandatory snapping when content inside a child can overflow its parent: users may be unable to scroll that content fully into view. Test long text, zoom, and narrow screens.
  • scroll-snap-align: start aligns the leading edge; center can suit a centered-card design. Match scroll-padding to any visual inset or fixed controls.
  • Keep a visible scrollbar unless the interaction is obvious through other cues. If you hide it, provide clear affordances and verify keyboard access to the scroll container.
  • For RTL layouts, test direction, scroll padding, and any custom navigation in the browsers you support.

See MDN’s CSS Scroll Snap guide. MDN also documents newer CSS carousel features such as generated scroll buttons and markers; those features are separate from this established overflow-and-snap pattern. Check current support before relying on them: MDN CSS carousels.

Use state when you want explicit previous and next buttons, one selected slide at a time, and behavior tailored to your interface. The example disables buttons at the ends rather than silently wrapping. It also announces the selected position in a polite live region.

Implement a client component

Create app/components/StateCarousel.tsx:

"use client";

import { useState } from "react";

type Slide = { id: string; title: string; description: string };

const slides: Slide[] = [
  { id: "one", title: "Plan", description: "Choose the story and its key image." },
  { id: "two", title: "Build", description: "Create cards that work at every width." },
  { id: "three", title: "Review", description: "Check scrolling, focus, and announcements." },
];

export function StateCarousel() {
  const [index, setIndex] = useState(0);
  const slide = slides[index];

  function moveTo(nextIndex: number) {
    setIndex(Math.max(0, Math.min(nextIndex, slides.length - 1)));
  }

  return (
    <section aria-roledescription="carousel" aria-label="Project stories">
      <div aria-live="polite" aria-atomic="true">
        <article
          aria-roledescription="slide"
          aria-label={`${index + 1} of ${slides.length}`}
        >
          <h2>{slide.title}</h2>
          <p>{slide.description}</p>
        </article>
      </div>
      <div className="carousel-controls">
        <button type="button" onClick={() => moveTo(index - 1)} disabled={index === 0}>
          Previous
        </button>
        <button type="button" onClick={() => moveTo(index + 1)} disabled={index === slides.length - 1}>
          Next
        </button>
      </div>
      <p>Slide {index + 1} of {slides.length}</p>
    </section>
  );
}

Render it from a server page as before:

import { StateCarousel } from "./components/StateCarousel";

export default function Page() {
  return (
    <main>
      <h1>Featured stories</h1>
      <StateCarousel />
    </main>
  );
}

The "use client" directive marks the stateful component as a Client Component. Keep data and static page structure on the server where practical, and pass serializable props if the carousel needs page-specific content.

Behavior to decide before shipping

  • Ends: disable controls, wrap to the other end, or omit a direction. Make the choice apparent and consistent.
  • Keyboard: native buttons work with keyboard input. If adding arrow-key shortcuts, scope them to the carousel and do not intercept keys while users type in inputs.
  • Focus: do not move focus unexpectedly when a button changes slides. If a slide is removed or replaced, ensure focus does not land in hidden or nonexistent content.
  • Announcements: communicate meaningful changes to screen-reader users. A live region can help, but test its actual behavior with your supported assistive technologies and avoid duplicate announcements from multiple regions.
  • Autoplay: do not add it by default. If slides move automatically, users need a way to pause or stop movement, and it must not restart unexpectedly after interaction.
  • Responsive size: decide whether the carousel shows one full slide or a partial next slide. Keep controls reachable and avoid clipping focused content.

A single-slide state carousel replaces visible content, unlike the scroller where all cards remain available by scrolling. That can reduce discoverability if there are no clear controls or position cues.

4. Method three: use Embla

Embla is useful when you want drag interaction and controlled snap navigation without writing the engine yourself. The supplied React documentation is for Embla v9.0.0-rc03 and points stable users to its v8 documentation. The example below targets Embla v8; use the docs matching the version you install and check its current API before copying into a project.

npm install embla-carousel-react

Create app/components/EmblaCarousel.tsx:

"use client";

import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useState } from "react";

type Slide = { id: string; title: string; description: string };

const slides: Slide[] = [
  { id: "one", title: "Plan", description: "Choose the story and its key image." },
  { id: "two", title: "Build", description: "Create cards that work at every width." },
  { id: "three", title: "Review", description: "Check scrolling, focus, and announcements." },
];

export function EmblaCarousel() {
  const [viewportRef, emblaApi] = useEmblaCarousel({ align: "start" });
  const [selected, setSelected] = useState(0);
  const [snapCount, setSnapCount] = useState(0);

  const updateSelection = useCallback(() => {
    if (!emblaApi) return;
    setSelected(emblaApi.selectedScrollSnap());
  }, [emblaApi]);

  useEffect(() => {
    if (!emblaApi) return;
    setSnapCount(emblaApi.scrollSnapList().length);
    updateSelection();
    emblaApi.on("select", updateSelection);
    return () => {
      emblaApi.off("select", updateSelection);
    };
  }, [emblaApi, updateSelection]);

  return (
    <section aria-roledescription="carousel" aria-label="Project stories">
      <div className="embla">
        <div className="embla__viewport" ref={viewportRef}>
          <div className="embla__container">
            {slides.map((slide, index) => (
              <article
                className="embla__slide"
                key={slide.id}
                aria-roledescription="slide"
                aria-label={`${index + 1} of ${slides.length}`}
              >
                <h2>{slide.title}</h2>
                <p>{slide.description}</p>
              </article>
            ))}
          </div>
        </div>
      </div>
      <div className="carousel-controls">
        
        
      

Slide {selected + 1} of {snapCount}

); }

Style the viewport and slides in app/globals.css:

.embla__viewport { overflow: hidden; }
.embla__container { display: flex; touch-action: pan-y pinch-zoom; }
.embla__slide {
  flex: 0 0 85%;
  min-width: 0;
  margin-right: 1rem;
  padding: 1.25rem;
  border: 1px solid #d7dce2;
  border-radius: 0.75rem;
  background: white;
}
.carousel-controls { display: flex; gap: 0.75rem; margin-top: 1rem; }
.carousel-controls button:focus-visible { outline: 3px solid #2563eb; }
@media (min-width: 48rem) {
  .embla__slide { flex-basis: 48%; }
}

Embla recommends keeping navigation controls outside the draggable viewport to avoid drag conflicts. Its React wrapper handles cleanup when the component unmounts. It exposes previous and next snap navigation and the selected snap; check version-matched documentation for drag and grouping options. See Embla React setup and its options reference.

5. Accessibility checklist

  • Give the carousel a clear accessible name and use meaningful slide content and structure.
  • Use real buttons for previous, next, and pause controls. Keep every function operable by keyboard. W3C WAI says: “All functionality, including navigating between carousel items, must be operable by keyboard.” See the WAI Carousels Tutorial.
  • Make the current position understandable. When a control changes or hides content, provide an appropriate announcement without making screen readers repeat excessive content.
  • Keep focus predictable; changing a slide should not unexpectedly move keyboard focus.
  • Any automatic movement needs a pause or stop mechanism. Avoid making essential information available only through automatic rotation.
  • Check contrast, visible focus, zoom, reduced-motion preferences, touch targets, and text that wraps or overflows.

ARIA labels do not make an inaccessible interaction accessible by themselves. Test with keyboard-only navigation and the screen readers your audience uses. WAI also discusses focus management and communicating changes to assistive technology in its tutorial.

6. Performance, reliability, and cost

  • Rendering: the native scroller needs no carousel state or engine. A custom state component adds interaction logic. A library adds a dependency and client-side integration. Actual bundle and runtime effects depend on versions and application usage; measure your build if they matter.
  • Images: large slide images often dominate loading and memory. Use appropriately sized responsive assets, dimensions or aspect ratios to prevent layout shifts, and lazy loading for content outside the initial view where suitable. Ensure the first visible slide has a useful loading priority.
  • Many slides: render only the content needed for the experience. Very large galleries may need pagination or virtualization, but virtualization changes which content is present and needs careful accessibility review.
  • Layout changes: image loading, font swaps, and responsive resizing can alter snap positions. Test after content loads, at zoom, and when the viewport changes.
  • Reliability: native scrolling depends on browser CSS support; libraries require version-compatible integration and cleanup; custom state requires correct boundary and focus handling. Test the actual browsers and devices you support.
  • Cost: these are implementation choices, not hosted screenshot services. No comparative numeric cost or performance result is established here. Account for engineering maintenance and any library policy separately.

7. Troubleshooting

Symptom Likely cause Fix
Cards wrap onto multiple rows The scroller is not laid out in a single horizontal flow Use grid-auto-flow: column with explicit auto columns, or a flex row with non-shrinking slides.
Scroll snap never engages There is no horizontal overflow, or snap is set on the wrong element Confirm the container actually overflows and has scroll-snap-type; put scroll-snap-align on each slide.
Some content cannot be reached Mandatory snapping combined with overflowing slide content Try proximity, allow the child to scroll, or redesign the slide so its full content fits. MDN cautions about this case.
Next.js reports that state or event handlers need a Client Component Interactive state was placed in a Server Component Add "use client" at the top of the component that uses state or browser events.
Embla controls do nothing The API is not initialized yet or the installed version does not match the example Disable controls until the API exists and consult docs for the installed major version.
Embla drags but navigation buttons interfere Controls are inside the draggable viewport Place controls outside the viewport.
Screen readers announce too much or nothing Multiple live regions, hidden slides, or unannounced content changes Use one concise announcement strategy, test with assistive technology, and ensure inactive content and focus behavior are intentional.
Focused content is clipped Overflow clipping or slide sizing cuts off outlines/content Add internal spacing, ensure focus rings fit, and test keyboard focus at the first and last slides.

8. Or skip the browser setup

If you need screenshots of your carousel page for review, documentation, or an automated workflow, ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF. Its capture can accept cookie banners and remove 60+ known consent platforms, newsletter popups, and chat widgets before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers identify the page verdict and billing status.

For a quick screenshot, use cURL:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Or Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)

Or Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

Replace the example URL with your deployed carousel page. Find the request options and response details in the ScreenshotNeo API documentation. ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for AI agents. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up for 1,000 free screenshots a month, with no card required.

9. Frequently asked questions

No. Start with user-controlled navigation or scrolling. If you add automatic movement, include a pause or stop control and make sure it does not disrupt reading or focus.

Should every slide be a separate route?

Usually not for a small presentation of related cards. Use links or routes when each item represents a distinct destination or needs its own shareable URL.

Static content and the native scroller can be rendered by a Server Component. Components using React state or browser event handlers need a Client Component boundary.

Which method should I migrate to later?

Begin with the interaction users need now. If native scrolling later lacks required controls, or custom state grows into drag and grouping logic, evaluate a library and migrate while preserving slide semantics and accessibility.