ScreenshotNeo

BlogHow-to

How to Build a Sliding Image Gallery for a Website

Build an accessible sliding image gallery with semantic HTML, CSS Scroll Snap, JavaScript controls, responsive images, testing, and troubleshooting.

By the ScreenshotNeo team1 October 20267 min read

Direct answer: Build the gallery as a labeled section containing a list of images, make the list horizontally scrollable with CSS Scroll Snap, and add JavaScript only for previous/next buttons, slide state, and announcements. This keeps touch and trackpad scrolling native while preserving keyboard and screen-reader access.

The complete example below is framework-free. Replace the sample image URLs and captions with your own assets.

  • Scrollable list plus Scroll Snap: the simplest option for touch-friendly galleries. The browser handles momentum and users can swipe or scroll naturally. See MDN’s CSS Scroll Snap documentation.
  • JavaScript controls: add previous/next buttons or thumbnail pickers when users need an explicit way to move one slide at a time.
  • Declarative CSS carousel features: CSS Overflow 5 carousel APIs are progressive enhancement. Chrome documents availability from Chrome 135; verify every browser you support before depending on them (Chrome for Developers).

For most production sites, start with the first option and layer controls on top. Avoid auto-rotation unless it serves a clear purpose. W3C guidance requires a pause/restart control and stopping movement when focus or the pointer enters the carousel (WAI Carousels Tutorial, ARIA Authoring Practices Guide).

2. Complete runnable example

HTML

<section class='gallery' aria-labelledby='gallery-title'>
  <div class='gallery__header'>
    <h2 id='gallery-title'>Studio projects</h2>
    <div class='gallery__controls'>
      <button type='button' class='gallery__prev' aria-label='Previous image'>‹</button>
      <button type='button' class='gallery__next' aria-label='Next image'>›</button>
    </div>
  </div>
  <div class='gallery__viewport'>
    <ul class='gallery__track' tabindex='0'>
      <li class='gallery__slide' aria-label='1 of 3'>
        <figure><img src='images/forest.webp' alt='Sunlight between tall forest trees' width='1200' height='800' loading='eager' decoding='async'><figcaption>Forest light</figcaption></figure>
      </li>
      <li class='gallery__slide' aria-label='2 of 3'>
        <figure><img src='images/coast.webp' alt='Waves breaking along a rocky coast' width='1200' height='800' loading='lazy' decoding='async'><figcaption>Coastal study</figcaption></figure>
      </li>
      <li class='gallery__slide' aria-label='3 of 3'>
        <figure><img src='images/city.webp' alt='City buildings reflected in a wet street' width='1200' height='800' loading='lazy' decoding='async'><figcaption>After the rain</figcaption></figure>
      </li>
    </ul>
  </div>
  <p class='gallery__status' aria-live='polite'>Image 1 of 3: Forest light</p>
</section>

CSS

.gallery { max-width: 72rem; margin: 2rem auto; padding: 0 1rem; }
.gallery__header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.gallery__controls { display: flex; gap: .5rem; }
.gallery__controls button { min-width: 2.75rem; min-height: 2.75rem; border: 1px solid #777; border-radius: .4rem; background: #fff; font-size: 1.5rem; cursor: pointer; }
.gallery__controls button:focus-visible, .gallery__track:focus-visible { outline: 3px solid #1769e0; outline-offset: 3px; }
.gallery__track { display: grid; grid-auto-flow: column; grid-auto-columns: 100%; gap: 1rem; margin: 0; padding: 0 0 1rem; list-style: none; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: thin; }
.gallery__slide { min-width: 0; scroll-snap-align: start; }
.gallery__slide figure { margin: 0; }
.gallery__slide img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; border-radius: .5rem; }
.gallery__slide figcaption { margin-top: .5rem; }
.gallery__status { margin: .5rem 0 0; }
@media (min-width: 48rem) { .gallery__track { grid-auto-columns: calc((100% - 2rem) / 3); } }
@media (prefers-reduced-motion: reduce) { .gallery__track { scroll-behavior: auto !important; } }
@media (prefers-reduced-motion: no-preference) { .gallery__track { scroll-behavior: smooth; } }

JavaScript for controls and state

const gallery = document.querySelector('.gallery');
const track = gallery.querySelector('.gallery__track');
const slides = [...gallery.querySelectorAll('.gallery__slide')];
const previous = gallery.querySelector('.gallery__prev');
const next = gallery.querySelector('.gallery__next');
const status = gallery.querySelector('.gallery__status');
let current = 0;

function setCurrent(index, announce = true) {
  current = Math.max(0, Math.min(index, slides.length - 1));
  const slide = slides[current];
  slide.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
  previous.disabled = current === 0;
  next.disabled = current === slides.length - 1;
  if (announce) status.textContent = `Image ${current + 1} of ${slides.length}: ${slide.querySelector('figcaption')?.textContent ?? ''}`;
}
previous.addEventListener('click', () => setCurrent(current - 1));
next.addEventListener('click', () => setCurrent(current + 1));
const observer = new IntersectionObserver(entries => {
  const visible = entries.filter(entry => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
  if (!visible) return;
  const index = slides.indexOf(visible.target);
  if (index !== current) setCurrent(index, true);
}, { root: track, threshold: 0.6 });
slides.forEach(slide => observer.observe(slide));
setCurrent(0, false);

The list receives focus so keyboard users can scroll it with arrow keys, Page Up/Down, Home, and End. The buttons provide a predictable one-slide action. The live region announces changes without moving focus unexpectedly.

3. Make the markup accessible

  1. Wrap the gallery in a labeled section and connect it to a visible heading with aria-labelledby.
  2. Use a real ul/li collection. Give informative images meaningful alt text; use alt='' only for decoration.
  3. Use native button elements with clear names and visible focus styles.
  4. Expose the current position through visible text and an aria-live='polite' status. If you add dots or thumbnails, mark the active picker with aria-current='true'.
  5. Do not expose a confusing set of off-screen slides to assistive technology. Follow the slide labeling guidance in the APG pattern.

W3C summarizes the reason for a pause control: “Users must be able to pause carousel movement because it can be too fast or distracting, making text hard to read.”

4. Optional auto-rotation

If slides rotate, put a Pause button before the moving content. Stop the timer on focus and pointer hover, require an explicit action to restart after focus enters, and respect prefers-reduced-motion.

const pause = document.querySelector('#gallery-pause');
let timer = null;
function start() { if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) timer = setInterval(() => setCurrent((current + 1) % slides.length), 5000); }
function stop() { clearInterval(timer); timer = null; }
pause?.addEventListener('click', () => { if (timer) { stop(); pause.textContent = 'Play'; } else { start(); pause.textContent = 'Pause'; } });
gallery.addEventListener('mouseenter', stop); gallery.addEventListener('mouseleave', start); gallery.addEventListener('focusin', stop); start();

5. Images and responsive sizing

  • Reserve space with width/height attributes or aspect-ratio to prevent layout shift.
  • Use srcset and sizes for responsive image delivery.
  • Load the first visible image eagerly and later slides lazily.
  • Compress images and serve WebP or AVIF with a fallback where appropriate.
  • Use object-fit: cover only when cropping is acceptable; use contain for diagrams and products.

6. Testing checklist

  • Tab to every button and activate it with Enter and Space.
  • Use only a keyboard to reach each slide and verify focus indicators.
  • Swipe on a phone, use a trackpad, and resize across breakpoints.
  • Test with a screen reader: region name, slide count, caption, and changes should be clear.
  • Enable reduced motion and confirm there is no automatic movement.
  • Throttle the network and verify lazy images have dimensions and an error fallback.

7. Troubleshooting

Symptom Cause Fix
Slides do not snap The scrolling element lacks overflow or snap settings. Apply overflow-x:auto and scroll-snap-type:x mandatory to the track and scroll-snap-align to slides.
Buttons move the page Slide width does not match the scroll increment. Use grid-auto-columns:100% or scroll directly to the target slide.
Images jump while loading No intrinsic dimensions. Add width, height, and aspect-ratio.
Screen reader repeats slides Every slide is announced without a current status. Use one labeled region, concise slide labels, and one polite live region.
Focus disappears Focus is moved during scrolling or controls are not native buttons. Keep focus on the activated control or track and use :focus-visible.
Rotation is distracting No pause control or timer continues during interaction. Add Pause, stop on focus/hover, and disable for reduced motion.

8. Performance, reliability, and cost

Keep slide markup small, size images to their rendered width, and avoid decoding large off-screen originals. CSS scrolling is inexpensive because the browser owns the gesture. Use visibility observation instead of a scroll handler on every pixel. For remote images, cache stable assets and handle timeouts or missing images so one failure does not block the page.

Or skip the browser setup

If you need screenshots of gallery states, responsive breakpoints, or remote pages for documentation, ScreenshotNeo returns PNG, JPEG, WebP, or PDF from one request. It supports full-page capture with lazy images, CSS element capture, device presets, custom viewports, dark mode, retina scale, custom CSS and JavaScript, clicks before capture, selector/delay/network-idle waits, request blocking, custom headers/cookies/user agents, timezone and geolocation, resizing, caching, signed links, async jobs with signed webhooks, bulk capture of up to 100 URLs, and a usage API. See the ScreenshotNeo API docs.

curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/gallery -o gallery.webp
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://example.com/gallery'}, timeout=90)
r.raise_for_status()
open('gallery.webp', 'wb').write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/gallery' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);

Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and billing status. An MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

9. FAQ

No. A semantic list with horizontal overflow and Scroll Snap is enough when native scrolling meets the design. Add JavaScript for explicit controls, pickers, or announcements.

Can I show multiple cards per view?

Yes. Set a smaller grid-auto-columns value at wider breakpoints and scroll to each slide. Keep snap points and button labels clear.

Is smooth scrolling always accessible?

No. Disable smooth behavior for users who request reduced motion.

Give each slide an id, read a URL fragment on load, and call scrollIntoView after mounting.

What if an image fails?

Keep the caption and dimensions, show a concise fallback, and provide a retry or alternate source. Do not collapse the slide while a user is interacting.