ScreenshotNeo

BlogHow-to

How to Build a Website Image Viewer

Build an accessible image viewer with semantic HTML, responsive CSS, keyboard controls, lightbox navigation, and a no-JavaScript fallback.

By the ScreenshotNeo team1 October 202611 min read

A website image viewer combines a collection of thumbnails with a larger selected image. The most reliable design starts with ordinary links and semantic HTML, then adds JavaScript for selection, a lightbox, previous and next controls, keyboard support, announcements, and focus management. If JavaScript fails, each thumbnail should still open its full-size image.

This guide builds a progressive-enhancement viewer from scratch. It covers a responsive gallery, an accessible lightbox dialog, keyboard and screen-reader behavior, reduced motion, loading and failure states, responsive image choices, troubleshooting, and production considerations.

1. Choose the viewer pattern

Use the simplest pattern that fits the job:

Pattern Use it when Trade-offs
Static grid Visitors need to scan many images or compare them. Lowest interaction cost; the full image can open as a normal link.
Gallery with selected image One image deserves most of the space while thumbnails provide quick selection. Requires synchronized selected state and a useful fallback.
Lightbox Visitors need an enlarged image without leaving the page. Requires modal focus handling, Escape support, and careful mobile behavior.
Auto-rotating carousel Rotation communicates a sequence and is genuinely useful. Harder to discover and can distract users. Provide pause and stop controls.

Manual navigation is the safest default. W3C WAI says users must be able to pause carousel movement because it can be too fast or distracting, and all carousel functionality must be keyboard operable. See the WAI carousel guidance.

2. Define image data and semantic HTML

Give every image a source, a useful alternative description, and a larger source when one exists. Keep each full-size image as a real link so the gallery remains useful without JavaScript.

<section class="image-viewer" aria-labelledby="gallery-title">
  <h1 id="gallery-title">National park photographs</h1>

  <div class="viewer-stage">
    <figure>
      <img
        id="main-image"
        src="images/trail-1-1200.jpg"
        alt="A mountain trail winding through pine trees"
        width="1200"
        height="800"
      />
      <figcaption id="image-caption">Mountain trail</figcaption>
    </figure>
    <p id="image-status" class="visually-hidden" aria-live="polite" aria-atomic="true">
      Image 1 of 4: Mountain trail
    </p>
    <div class="viewer-controls">
      <button type="button" id="previous" aria-label="Previous image">Previous</button>
      <button type="button" id="open-lightbox">Open larger image</button>
      <button type="button" id="next" aria-label="Next image">Next</button>
    </div>
  </div>

  <ul class="thumbnail-list" aria-label="Choose an image">
    <li>
      <a class="thumbnail is-selected" href="images/trail-1-2400.jpg" data-index="0" aria-current="true">
        <img src="images/trail-1-240.jpg" alt="Mountain trail" width="240" height="160" loading="eager" />
      </a>
    </li>
    <li>
      <a class="thumbnail" href="images/lake-2-2400.jpg" data-index="1">
        <img src="images/lake-2-240.jpg" alt="Blue lake below a rocky ridge" width="240" height="160" loading="lazy" />
      </a>
    </li>
    <li>
      <a class="thumbnail" href="images/forest-3-2400.jpg" data-index="2">
        <img src="images/forest-3-240.jpg" alt="Sunlight between tall forest trees" width="240" height="160" loading="lazy" />
      </a>
    </li>
    <li>
      <a class="thumbnail" href="images/waterfall-4-2400.jpg" data-index="3">
        <img src="images/waterfall-4-240.jpg" alt="Waterfall flowing over dark rock" width="240" height="160" loading="lazy" />
      </a>
    </li>
  </ul>
</section>

<dialog id="lightbox" aria-labelledby="lightbox-title">
  <div class="lightbox-content">
    <h2 id="lightbox-title" class="visually-hidden">Larger image</h2>
    <button type="button" id="close-lightbox" aria-label="Close larger image">Close</button>
    <button type="button" id="lightbox-previous" aria-label="Previous image">Previous</button>
    <img id="lightbox-image" alt="" />
    <button type="button" id="lightbox-next" aria-label="Next image">Next</button>
    <p id="lightbox-caption"></p>
  </div>
</dialog>

The thumbnail is a link because its destination is the large image. The aria-current state identifies the selected item. Informative images need concise text alternatives; decorative images should use alt="". A thumbnail that acts as a control should describe the image or destination. See WAI Images Tutorial.

3. Add responsive CSS

:root {
  color-scheme: light dark;
  --viewer-gap: 1rem;
  --focus-color: #0b63ce;
}

.image-viewer {
  max-width: 70rem;
  margin-inline: auto;
  padding: 1rem;
}

.viewer-stage {
  display: grid;
  gap: var(--viewer-gap);
}

.viewer-stage figure {
  margin: 0;
}

#main-image {
  display: block;
  width: 100%;
  height: auto;
  max-height: 75vh;
  object-fit: contain;
  background: #eee;
}

.viewer-controls {
  display: flex;
  flex-wrap: wrap;
  gap: .75rem;
  align-items: center;
}

button,
.thumbnail {
  min-block-size: 2.75rem;
}

button {
  cursor: pointer;
  padding: .65rem .9rem;
  border: 1px solid currentColor;
  border-radius: .35rem;
  background: Canvas;
  color: CanvasText;
}

.thumbnail-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
  gap: .75rem;
  list-style: none;
  margin: 1rem 0 0;
  padding: 0;
}

.thumbnail {
  display: block;
  border: .2rem solid transparent;
}

.thumbnail img {
  display: block;
  width: 100%;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}

.thumbnail[aria-current="true"] {
  border-color: var(--focus-color);
}

button:focus-visible,
.thumbnail:focus-visible {
  outline: .2rem solid var(--focus-color);
  outline-offset: .2rem;
}

dialog {
  width: min(95vw, 90rem);
  max-width: none;
  padding: 1rem;
  border: 0;
  background: Canvas;
  color: CanvasText;
}

dialog::backdrop {
  background: rgb(0 0 0 / .8);
}

.lightbox-content {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  gap: 1rem;
  align-items: center;
}

#lightbox-image {
  grid-column: 2;
  max-width: 100%;
  max-height: 80vh;
  width: auto;
  height: auto;
  justify-self: center;
}

#close-lightbox,
#lightbox-caption {
  grid-column: 1 / -1;
}

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

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    scroll-behavior: auto !important;
    transition-duration: .01ms !important;
    animation-duration: .01ms !important;
  }
}

@media (max-width: 42rem) {
  .lightbox-content {
    grid-template-columns: 1fr 1fr;
  }

  #lightbox-image {
    grid-column: 1 / -1;
    grid-row: 2;
  }

  #lightbox-previous { grid-column: 1; grid-row: 3; }
  #lightbox-next { grid-column: 2; grid-row: 3; }
}

Explicit width and height values reserve space before an image loads and reduce layout movement. max-width: 100% keeps the image inside narrow viewports. Keep focus indicators visible and make controls large enough for touch input.

4. Implement selection, navigation, and the lightbox

const images = [
  { full: 'images/trail-1-2400.jpg', alt: 'A mountain trail winding through pine trees', caption: 'Mountain trail' },
  { full: 'images/lake-2-2400.jpg', alt: 'Blue lake below a rocky ridge', caption: 'Blue lake' },
  { full: 'images/forest-3-2400.jpg', alt: 'Sunlight between tall forest trees', caption: 'Forest light' },
  { full: 'images/waterfall-4-2400.jpg', alt: 'Waterfall flowing over dark rock', caption: 'Waterfall' }
];

let selectedIndex = 0;
let opener = null;

const mainImage = document.querySelector('#main-image');
const imageCaption = document.querySelector('#image-caption');
const imageStatus = document.querySelector('#image-status');
const thumbnails = [...document.querySelectorAll('.thumbnail')];
const lightbox = document.querySelector('#lightbox');
const lightboxImage = document.querySelector('#lightbox-image');
const lightboxCaption = document.querySelector('#lightbox-caption');

function setSelected(index) {
  selectedIndex = (index + images.length) % images.length;
  const image = images[selectedIndex];
  mainImage.src = image.full;
  mainImage.alt = image.alt;
  imageCaption.textContent = image.caption;
  imageStatus.textContent = `Image ${selectedIndex + 1} of ${images.length}: ${image.caption}`;

  thumbnails.forEach((thumbnail, i) => {
    const selected = i === selectedIndex;
    thumbnail.classList.toggle('is-selected', selected);
    if (selected) thumbnail.setAttribute('aria-current', 'true');
    else thumbnail.removeAttribute('aria-current');
  });
}

function showLightboxImage() {
  const image = images[selectedIndex];
  lightboxImage.src = image.full;
  lightboxImage.alt = image.alt;
  lightboxCaption.textContent = `${selectedIndex + 1} of ${images.length}: ${image.caption}`;
}

thumbnails.forEach((thumbnail) => {
  thumbnail.addEventListener('click', (event) => {
    event.preventDefault();
    setSelected(Number(thumbnail.dataset.index));
  });
});

document.querySelector('#previous').addEventListener('click', () => setSelected(selectedIndex - 1));
document.querySelector('#next').addEventListener('click', () => setSelected(selectedIndex + 1));

document.querySelector('#open-lightbox').addEventListener('click', (event) => {
  opener = event.currentTarget;
  showLightboxImage();
  lightbox.showModal();
  document.querySelector('#close-lightbox').focus();
});

document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
document.querySelector('#lightbox-previous').addEventListener('click', () => {
  setSelected(selectedIndex - 1);
  showLightboxImage();
});
document.querySelector('#lightbox-next').addEventListener('click', () => {
  setSelected(selectedIndex + 1);
  showLightboxImage();
});

lightbox.addEventListener('close', () => {
  if (opener) opener.focus();
});

lightbox.addEventListener('click', (event) => {
  if (event.target === lightbox) lightbox.close();
});

document.addEventListener('keydown', (event) => {
  if (!lightbox.open) return;
  if (event.key === 'ArrowLeft') {
    setSelected(selectedIndex - 1);
    showLightboxImage();
  }
  if (event.key === 'ArrowRight') {
    setSelected(selectedIndex + 1);
    showLightboxImage();
  }
});

The selected image, caption, thumbnail state, and live status are updated together. The native <dialog> supplies modal behavior and Escape handling in current browsers; the close listener restores focus to the opener. If you use a custom overlay instead, you must implement equivalent focus containment and background inertness. Generic elements do not provide enough semantics for assistive technology; use real buttons and links. See MDN’s semantic HTML accessibility guidance.

5. Keep the no-JavaScript fallback

Each thumbnail link in the markup points directly to a full-size image. Without JavaScript, selecting a thumbnail navigates to that image, which is still a usable result. Do not make the only way to reach the large image depend on a click handler.

6. Add responsive images and loading states

Generate thumbnail and full-size variants rather than downloading a multi-megapixel file for every thumbnail. For a known set of widths, use srcset and sizes:

<img
  src="images/trail-1-1200.jpg"
  srcset="images/trail-1-480.jpg 480w,
          images/trail-1-800.jpg 800w,
          images/trail-1-1200.jpg 1200w"
  sizes="(max-width: 42rem) 100vw, 70rem"
  width="1200"
  height="800"
  alt="A mountain trail winding through pine trees"
/>

Load the first visible image eagerly and defer off-screen thumbnails with loading="lazy". Show a loading state while replacing a large image, and handle error events so a broken URL produces a useful message instead of an empty stage:

mainImage.addEventListener('load', () => {
  mainImage.removeAttribute('aria-busy');
});

mainImage.addEventListener('error', () => {
  imageStatus.textContent = 'This image could not be loaded. Try another image.';
  mainImage.removeAttribute('src');
});

Reserve the display area’s aspect ratio when images have different dimensions. Decide whether object-fit: contain (show the whole image) or cover (fill the box and crop) matches the content.

7. Accessibility checklist

  • Give the gallery region a visible heading or an accessible label.
  • Use concise alt text for informative images and empty alt text for decorative images.
  • Use real button elements for previous, next, close, and open controls.
  • Keep every thumbnail keyboard reachable and expose the selected state with aria-current or the pattern appropriate to your widget.
  • Announce changes through a polite, atomic live region such as “Image 2 of 4: Blue lake.”
  • Close the lightbox with a visible button and Escape.
  • Move focus into the dialog when it opens and return it to the opener when it closes.
  • Do not trap users in an auto-rotating carousel; provide pause or stop controls if rotation is used.
  • Respect prefers-reduced-motion, zoom, high-contrast settings, and narrow screens.

WAI summarizes the core requirement as: “Images must have text alternatives that describe the information or function represented by them.”

8. Common errors and fixes

Symptom Likely cause Fix
Clicking a thumbnail leaves the page The fallback link is working or the script did not load. Check the script URL and console errors. Keeping the link is intentional.
Caption and image disagree Only the image source was changed. Update source, alt text, caption, selected state, and live status in one function.
Previous or next stops at an edge Index arithmetic does not wrap. Use (index + length) % length, or disable the button and announce the boundary deliberately.
Keyboard focus disappears after closing The opener was not saved or focus was never restored. Store the activating element before opening and call focus() after close.
Screen reader gets no update Status text is not live, or the same text is reused. Use aria-live="polite" aria-atomic="true" and update the complete position and caption.
Modal content is wider than the phone Fixed image dimensions or a grid that cannot shrink. Use max-width: 100%, max-height: 80vh, and a mobile media query.
Layout jumps as images load No intrinsic dimensions or aspect ratio. Set width and height attributes or reserve space with aspect-ratio.
Every thumbnail downloads a large file Thumbnail and full-size URLs are identical. Generate smaller thumbnails and use srcset for responsive selection.
Images fail only in production Case-sensitive paths, a CDN rule, CORS, or mixed content. Inspect the network response, verify exact paths, serve HTTPS, and configure the image host correctly.
Background remains clickable behind a custom modal The overlay is visual only. Prefer native <dialog> or implement modal semantics, focus containment, and inert background behavior.

9. Performance, reliability, and cost

  • Resize images at the source and choose modern formats such as WebP or AVIF where your browser support policy allows.
  • Use a CDN with long-lived immutable URLs for versioned images.
  • Preload only the first main image; preloading every slide increases startup work.
  • Keep thumbnails small, reserve their dimensions, and lazy-load those below the fold.
  • Do not rotate automatically unless the content needs it. Manual controls reduce unexpected network and CPU work.
  • Handle broken images, slow responses, and empty galleries with visible status messages.
  • If a gallery is server-rendered, include the first image and caption in the initial HTML so the page has useful content before JavaScript runs.
  • When analytics or image transformations are billed per request, cache stable URLs and avoid fetching the same full image repeatedly during navigation.

10. Or skip the browser setup

If you need images of web pages rather than a viewer for your own image files, ScreenshotNeo returns a PNG, JPEG, WebP, or PDF from one GET request. Its clean-shot pipeline accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and whether the request was billed.

See the ScreenshotNeo API documentation for all options.

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

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://example.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
await Bun.write('shot.webp', image);

ScreenshotNeo also supports full-page captures with lazy images loaded, CSS element capture, dark mode, device presets and custom viewports, retina scale, custom CSS and JavaScript, clicks, selector or network-idle waits, blocked resource types, headers, cookies, user agents, authorization, timezone and geolocation, transparent backgrounds, resizing, selectable cache TTLs, signed links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs per call, usage reporting, and an OpenAPI specification. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

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

11. FAQ

Use links when each thumbnail has a meaningful full-image destination and you want a no-JavaScript fallback. Use buttons when selection is entirely in place and there is no destination. The example uses links and prevents navigation only after JavaScript is available.

Not by default. A small viewer has fewer dependencies and fewer accessibility behaviors to audit. Add a library only when its current keyboard, focus, labeling, and reduced-motion behavior meets your requirements.

Give each image an identifier in the URL hash or query string, read it during initialization, and call setSelected(). Update the URL with history.replaceState() when selection changes if browser history should not grow for every click.

How do I support captions or metadata?

Store caption, credit, date, and other fields beside each image in the data array. Render them from the same selected state update that changes the image and live announcement.

How do I display a remote website inside this viewer?

Browsers generally cannot capture another website as a local image because of cross-origin and rendering restrictions. Use a server-side screenshot service such as ScreenshotNeo, then display the returned image URL or bytes in your viewer.