ScreenshotNeo

BlogHow-to

Image Viewer in HTML: Build an Accessible Lightbox

Build an accessible HTML image viewer with native dialog, responsive images, keyboard controls, lazy loading, and focus management.

By the ScreenshotNeo team1 October 20268 min read

The simplest robust image viewer uses a thumbnail button to open a native <dialog>. Store the full-size image URL and its accessible description on the button, copy them into the dialog, call showModal(), and return focus to the thumbnail when the viewer closes.

1. Complete accessible image viewer

This example supports a gallery, responsive thumbnails, lazy loading, keyboard dismissal with Escape, an explicit close button, backdrop clicks, and focus restoration.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Accessible image viewer</title>
  <style>
    :root { color-scheme: light dark; }
    body { font-family: system-ui, sans-serif; margin: 0; padding: 2rem; }
    .gallery {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
      gap: 1rem;
      max-width: 70rem;
      margin: 0 auto;
    }
    .thumb {
      display: block;
      border: 0;
      padding: 0;
      background: transparent;
      cursor: zoom-in;
      border-radius: .5rem;
      overflow: hidden;
    }
    .thumb:focus-visible { outline: 3px solid #0b70ff; outline-offset: 3px; }
    .thumb img { display: block; width: 100%; height: auto; }
    dialog {
      width: min(92vw, 1000px);
      max-width: none;
      padding: 0;
      border: 0;
      border-radius: .75rem;
      background: Canvas;
      color: CanvasText;
    }
    dialog::backdrop { background: rgb(0 0 0 / .8); }
    .viewer-content { position: relative; padding: 3rem 1rem 1rem; }
    .viewer-content h2 { margin: 0 0 1rem; font-size: 1.1rem; }
    #viewer-image { display: block; width: 100%; height: auto; max-height: 80vh; object-fit: contain; }
    #viewer-close { position: absolute; top: .75rem; right: .75rem; }
    @media (prefers-reduced-motion: no-preference) {
      dialog[open] { animation: appear .15s ease-out; }
      @keyframes appear { from { opacity: 0; transform: scale(.98); } to { opacity: 1; transform: scale(1); } }
    }
  </style>
</head>
<body>
  <main>
    <h1>Mountain gallery</h1>
    <section class="gallery" aria-label="Mountain photographs">
      <button class="thumb" type="button"
        aria-label="Open snowy mountain at sunrise"
        data-full="images/mountain-1600.jpg"
        data-alt="Snowy mountain at sunrise"
        data-width="1600"
        data-height="1067">
        <img src="images/mountain-320.jpg"
          srcset="images/mountain-640.jpg 640w, images/mountain-960.jpg 960w"
          sizes="(max-width: 700px) 50vw, 320px"
          width="320" height="213" loading="lazy"
          alt="Snowy mountain at sunrise">
      </button>

      <button class="thumb" type="button"
        aria-label="Open forest trail in autumn"
        data-full="images/forest-1600.jpg"
        data-alt="Forest trail in autumn"
        data-width="1600"
        data-height="1067">
        <img src="images/forest-320.jpg"
          srcset="images/forest-640.jpg 640w, images/forest-960.jpg 960w"
          sizes="(max-width: 700px) 50vw, 320px"
          width="320" height="213" loading="lazy"
          alt="Forest trail in autumn">
      </button>
    </section>
  </main>

  <dialog id="viewer" aria-labelledby="viewer-title">
    <div class="viewer-content">
      <h2 id="viewer-title"></h2>
      <button id="viewer-close" type="button" autofocus>Close</button>
      <img id="viewer-image" alt="">
    </div>
  </dialog>

  <script>
    const viewer = document.querySelector('#viewer');
    const viewerImage = document.querySelector('#viewer-image');
    const viewerTitle = document.querySelector('#viewer-title');
    const closeButton = document.querySelector('#viewer-close');
    let opener = null;

    document.querySelectorAll('.thumb').forEach((button) => {
      button.addEventListener('click', () => {
        opener = button;
        viewerTitle.textContent = button.dataset.alt;
        viewerImage.src = button.dataset.full;
        viewerImage.alt = button.dataset.alt;
        viewerImage.width = button.dataset.width;
        viewerImage.height = button.dataset.height;
        viewer.showModal();
        closeButton.focus();
      });
    });

    closeButton.addEventListener('click', () => viewer.close());

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

    viewer.addEventListener('close', () => {
      viewerImage.removeAttribute('src');
      if (opener) opener.focus();
    });
  </script>
</body>
</html>

Save this as index.html, place the referenced image files in an images/ directory, and serve it from a local HTTP server. Opening the file directly usually works for local images, but an HTTP server better matches production behavior.

2. Why the native dialog is a good viewer

HTMLDialogElement.showModal() places the dialog in the browser’s top layer, displays a backdrop, and makes the rest of the document inert while it is open. Native modal dialogs also provide Escape cancellation. A custom overlay must recreate those behaviors, including focus handling and background inertness.

The dialog has a visible heading, a close button, and an image whose alt text is updated for the selected photograph. The close event returns focus to the button that opened the viewer, so keyboard and screen-reader users do not lose their place.

3. Thumbnail and responsive image choices

Meaningful alternative text

Give every image useful alt text. If the image is purely decorative, use alt=""; for a photograph, describe the subject and relevant context. The button’s aria-label should tell users that activating it opens the image at full size.

Reserve the layout space

Set explicit width and height on thumbnails and full-size images. The browser can reserve the correct aspect ratio before the file arrives, reducing layout shift.

Choose an appropriate file

Use srcset and sizes when you have several image widths. The browser can then choose a resource suitable for the rendered thumbnail. loading="lazy" is useful for thumbnails below the initial viewport; it is a loading hint, not a replacement for correctly sized files.

Do not lazy-load the first visible image

For a gallery’s first row, omit loading="lazy" if the images are part of the initial content. Lazy-load images that start offscreen so the browser does not fetch the entire gallery immediately.

For a single image, keep the same structure and script but use one button. The full-size URL can be written directly in a data-full attribute, or generated from a known naming convention. Keep the full-size image separate from the thumbnail so opening the viewer does not require replacing the thumbnail resource.

5. Optional enhancements

Loading and error states

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

viewerImage.addEventListener('error', () => {
  viewerTitle.textContent = 'Image unavailable';
  viewerImage.alt = 'The selected image could not be loaded';
  viewerImage.removeAttribute('src');
});

// Set this immediately after assigning src when you show a loading message.
viewerImage.setAttribute('aria-busy', 'true');

Previous and next controls

For a carousel, collect the thumbnail buttons in an array, track the current index, and replace data-full, data-alt, dimensions, and the dialog heading when the user activates Previous or Next. Keep those controls as real buttons with accessible names. Do not trap focus in a hand-written loop; the modal dialog already manages the modal boundary.

Zoom and pan

If users must inspect fine detail, add a zoom control or use a separate zoomable surface. Keep the image constrained with max-height: 80vh and object-fit: contain so it remains usable on small screens. Do not remove the close button when adding pointer gestures.

Reduced motion

Respect prefers-reduced-motion. The example only animates when the user has not requested reduced motion.

6. Common errors and fixes

Symptom Cause Fix
showModal is not a function The value is not a dialog element, or an outdated browser is being used. Verify document.querySelector('#viewer') returns <dialog>. Check your supported browser matrix and provide a tested fallback if required.
The image opens but the page scrolls behind it The viewer was implemented as a normal div or opened with non-modal behavior. Use <dialog> and showModal(), or implement background inertness and scroll locking yourself.
Focus disappears after closing The opener was not stored or focus was not restored. Save the clicked button before opening and call opener.focus() in the close handler.
Escape does nothing The viewer is not a modal dialog, or a keydown handler prevents default behavior. Open with showModal() and avoid cancelling the dialog’s cancel event unless you provide an equivalent close action.
Thumbnail layout jumps while loading Images have no intrinsic dimensions. Add matching width and height attributes or an equivalent aspect-ratio box.
Every gallery image downloads immediately All images are eager-loaded or oversized. Use loading="lazy" below the fold, and provide srcset/sizes variants.
The full-size image is blurry The viewer is reusing a small thumbnail. Store a separate full-size URL in data-full and set it when the dialog opens.
Clicking outside does not close the viewer The dialog click handler is missing or checks the wrong target. Close only when event.target === viewer; clicks inside the content should remain open.

7. Performance and reliability checklist

  • Generate thumbnail widths that match real layout sizes; do not send a multi-megapixel file for a small grid cell.
  • Keep explicit dimensions on every image to avoid cumulative layout movement.
  • Lazy-load initially offscreen thumbnails, while keeping the first visible row eager when it is part of the main content.
  • Use modern formats such as WebP or AVIF when your delivery pipeline and browser support policy allow them.
  • Preload a full-size image only when analytics or interaction patterns show that it is likely to be opened; otherwise defer it until activation.
  • Handle failed image requests with an understandable message and keep the close control available.
  • Test keyboard-only navigation, Escape, screen-reader labels, touch targets, narrow viewports, and high zoom.
  • Do not assume a pointer or physical keyboard exists; the close button must be visible and operable by touch.

8. Or skip the browser setup

If your goal is to capture the rendered viewer or any other page as an image, ScreenshotNeo provides a website screenshot API. It can accept a URL and return PNG, JPEG, WebP, or PDF. Before capture it accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, 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 tools for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo documentation for authentication and options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/gallery -o shot.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("shot.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}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

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

9. FAQ

Can I use an anchor instead of a button?

Use a button when activation opens a modal and does not navigate. A real button gives keyboard users the expected interaction and avoids adding custom key handling.

Should the full-size image be in the HTML from the start?

Usually no. Keeping the large URL in data and assigning it on activation avoids downloading every large image before a user opens it. Include it eagerly when the image is essential content and must be available immediately.

Do I need JavaScript for a viewer?

A modal viewer that swaps images needs JavaScript. You can provide a no-script fallback by linking each thumbnail to the full-size image URL, so the content remains reachable if scripts fail.

How do I make the viewer shareable?

Use a URL fragment or query parameter for the selected image, update it with the History API, and restore the matching dialog state on page load. Ensure the underlying page still exposes a meaningful title and gallery content.

Is a third-party lightbox required?

No. Native dialog, responsive images, and a small script cover the core viewer. A library is useful when you need maintained zoom, swipe navigation, deep linking, or broad legacy-browser support.