ScreenshotNeo

BlogHow-to

How to Build an Image Slider Component in Next.js

Build an accessible Next.js image slider with next/image, keyboard controls, responsive sizing, optional autoplay, and production troubleshooting.

By the ScreenshotNeo team1 October 20268 min read

The simplest reliable approach is a focused Client Component. Keep slide data separate from interaction state, track the active index with useState, render images with next/image, and use native buttons for navigation. Start with manual controls; automatic rotation adds pause, focus, hover, and announcement requirements.

What you will build

  • A reusable <ImageSlider> Client Component.
  • Responsive images rendered through next/image.
  • Previous, next, and direct slide controls.
  • Accessible carousel names and button labels.
  • An optional autoplay pattern that can be paused safely.

1. Create the slide data

Each slide should have a stable identifier, an image source, meaningful alternative text, and optional caption. Static imports let Next.js know image dimensions at build time:

import heroOne from '@/public/hero-one.jpg';
import heroTwo from '@/public/hero-two.jpg';

export const slides = [
  {
    id: 'one',
    src: heroOne,
    alt: 'A hiker standing beside a mountain lake at sunrise',
    caption: 'Sunrise at the mountain lake'
  },
  {
    id: 'two',
    src: heroTwo,
    alt: 'A forest trail covered with autumn leaves',
    caption: 'Autumn trail'
  }
];

For remote URLs, provide width and height yourself because the build cannot inspect the remote file. Add the host to images.remotePatterns in next.config.js. See the Next.js Image documentation.

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.example.com',
        pathname: '/photos/**'
      }
    ]
  }
};

module.exports = nextConfig;

2. Build the manual slider Client Component

State and click handlers belong behind a Client Component boundary. Put 'use client' at the top of the entry file; imported child files do not each need the directive. A Server Component page can render this client entry point. This follows the Server and Client Components guidance.

'use client';

import { useState } from 'react';
import Image from 'next/image';

export default function ImageSlider({ slides, title = 'Featured images' }) {
  const [activeIndex, setActiveIndex] = useState(0);

  if (!slides?.length) return null;

  const activeSlide = slides[activeIndex];
  const previous = () =>
    setActiveIndex((index) => (index - 1 + slides.length) % slides.length);
  const next = () =>
    setActiveIndex((index) => (index + 1) % slides.length);

  return (
    <section
      aria-labelledby="image-slider-title"
      aria-roledescription="carousel"
      className="slider"
    >
      <h2 id="image-slider-title" className="sr-only">{title}</h2>

      <div
        className="slider__viewport"
        role="group"
        aria-roledescription="slide"
        aria-label={`${activeIndex + 1} of ${slides.length}`}
      >
        <Image
          src={activeSlide.src}
          alt={activeSlide.alt}
          width={activeSlide.width ?? 1600}
          height={activeSlide.height ?? 1000}
          sizes="(max-width: 768px) 100vw, 768px"
          className="slider__image"
          priority={activeIndex === 0}
        />
        {activeSlide.caption && (
          <p className="slider__caption">{activeSlide.caption}</p>
        )}
      </div>

      <div className="slider__controls">
        <button type="button" onClick={previous} aria-label="Previous slide">
          Previous
        </button>
        <button type="button" onClick={next} aria-label="Next slide">
          Next
        </button>
      </div>

      <div className="slider__dots" aria-label="Choose a slide">
        {slides.map((slide, index) => (
          <button
            key={slide.id}
            type="button"
            onClick={() => setActiveIndex(index)}
            aria-label={`Go to slide ${index + 1}`}
            aria-current={index === activeIndex ? 'true' : undefined}
          >
            {index + 1}
          </button>
        ))}
      </div>
    </section>
  );
}

The modulo calculation wraps from the first slide to the last and vice versa. Remove that wraparound if reaching an end should disable the corresponding button instead.

3. Add layout CSS

.slider {
  max-width: 48rem;
  margin-inline: auto;
}

.slider__viewport {
  position: relative;
  overflow: hidden;
  aspect-ratio: 16 / 10;
  background: #eee;
}

.slider__image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.slider__caption {
  position: absolute;
  inset: auto 0 0;
  margin: 0;
  padding: .75rem 1rem;
  color: white;
  background: rgb(0 0 0 / 65%);
}

.slider__controls,
.slider__dots {
  display: flex;
  gap: .5rem;
  margin-top: .75rem;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Use object-fit: cover when every slide needs the same frame and cropping is acceptable. Use contain when the whole image must remain visible. With fill, the parent must be positioned and have an explicit height or aspect ratio.

4. Render it from a page

import ImageSlider from '@/components/ImageSlider';
import { slides } from '@/lib/slides';

export default function GalleryPage() {
  return (
    <main>
      <ImageSlider slides={slides} title="Trip gallery" />
    </main>
  );
}

5. Add keyboard and screen-reader behavior

Use real <button> elements so Enter and Space work without custom keyboard code. Give the carousel a visible heading connected with aria-labelledby, or an aria-label when no visible heading exists. The W3C carousel pattern recommends a named region or group, aria-roledescription="carousel", and native buttons for rotation, previous, and next controls.

If changing slides conveys information that users need, communicate the change with a carefully tested live-region strategy. Keep announcements concise and verify them with the screen readers used by your audience. The W3C carousels tutorial requires keyboard operation, a way to pause movement, and communicated slide changes.

6. Optional autoplay with a pause control

Manual navigation is easier to understand and maintain. If rotation is useful, make it user-controlled and stop it whenever focus enters or the pointer hovers over the carousel. Do not restart automatically after a user has focused the carousel.

import { useEffect, useState } from 'react';

function useCarouselRotation(length, delay = 5000) {
  const [index, setIndex] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [suspended, setSuspended] = useState(false);

  useEffect(() => {
    if (!playing || suspended || length < 2) return;
    const timer = window.setInterval(() => {
      setIndex((current) => (current + 1) % length);
    }, delay);
    return () => window.clearInterval(timer);
  }, [playing, suspended, length, delay]);

  return { index, setIndex, playing, setPlaying, setSuspended };
}

Place a “Pause” or “Start” button first in the carousel tab order and update its label to describe the action it will take. Set suspended to true on focus and mouse enter; clear it on mouse leave only if the user has not entered with the keyboard and explicitly paused rotation.

Image loading, sizing, and stability

  • next/image lazy-loads by default. Use priority or eager loading only for an image that must appear immediately, such as the first above-the-fold slide.
  • Known width and height reserve the intrinsic ratio and reduce layout movement.
  • For fill, make the parent position: relative and give it a height or aspect-ratio.
  • A blur placeholder requires placeholder="blur" and a blurDataURL; an unnecessarily large data URL adds page weight.
  • Use the sizes attribute so responsive browsers request an appropriate source width.
  • Keep alternative text descriptive. If an image is purely decorative, use an empty alt rather than repeating the caption.

Common variations

Show thumbnails

Map the same slide data into buttons containing small Image elements. Set aria-current on the active thumbnail and keep the full-size image’s alternative text authoritative.

Capture one element or show multiple slides

A single active slide keeps the DOM and accessibility model simple. Rendering every slide at once can help transitions, but hidden slides must not create duplicate focusable controls or confusing announcements. If you animate, respect prefers-reduced-motion.

Use a fixed height instead of an aspect ratio

A fixed height is appropriate when the design requires a fixed frame. An aspect ratio usually adapts better across viewport widths. In either case, reserve space before the image loads.

Troubleshooting

Symptom Cause Fix
“useState can only be used in a Client Component” The file containing state or handlers is treated as a Server Component. Add 'use client' before imports in the component entry file.
Invalid src or unconfigured host A remote image host is not allowed by Next.js. Add the exact protocol, hostname, and path pattern to images.remotePatterns, then restart the dev server.
Image requires width and height Next.js cannot determine dimensions for a remote source. Supply intrinsic dimensions, or use fill with a sized, positioned parent.
Image is cropped object-fit: cover intentionally fills and crops the frame. Use contain, change the aspect ratio, or provide a source with a matching composition.
Layout jumps when slides change The image frame has no reserved dimensions. Set width and height or an explicit parent aspect ratio.
Autoplay keeps running while reading Rotation is not suspended on focus or hover. Stop the timer on focus and pointer entry, and provide an explicit pause/start control.
Screen reader announces too much Every slide or control is exposed as a live update. Announce only the active slide change and keep labels concise.
Buttons do nothing An event handler is attached to a non-button element or receives a stale index. Use native buttons and functional state updates such as setIndex(i => ...).

Performance and reliability checklist

  • Load only the first visible image eagerly; let later images remain lazy.
  • Provide sizes and accurate dimensions.
  • Keep source files reasonably sized and choose a format supported by your deployment.
  • Test slow networks, failed image requests, very long alt text, and a one-slide data set.
  • Test keyboard-only operation, focus visibility, reduced motion, and at least one screen reader.
  • Decide whether wrapping is appropriate; otherwise disable previous/next at the ends.
  • Do not depend on autoplay for discovering essential content.

Or skip the browser setup

If your goal is to generate screenshots of the slider or any URL rather than maintain a browser automation stack, ScreenshotNeo accepts one GET request and returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for the full option set, including full-page and element captures, device presets, custom CSS and JavaScript, waits, request blocking, headers, cookies, geolocation, caching, signed links, asynchronous jobs, bulk capture, and PDF settings.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

1,000 screenshots a month are free with no card. Paid plans start at $5 for 3,000 shots, and every feature is available on every plan. Create a free ScreenshotNeo account.

FAQ

Should a basic slider use a library?

Not necessarily. A focused component is enough for manual navigation. Choose a library when you need advanced gestures, virtualization, or complex transitions that you are prepared to audit for accessibility.

Can the slider stay a Server Component?

The page that places the slider can be a Server Component, but the interactive slider entry point needs the Client Component boundary.

Is autoplay required?

No. Manual controls are a complete slider and avoid the extra pause, focus, hover, and announcement behavior required by rotation.

When should I use fill?

Use it when the layout determines the rendered frame and the parent can provide a stable position and size. Use explicit dimensions when intrinsic image geometry is known and preferable.