DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

An image lightbox from scratch: a centered overlay is easy — the real work is scroll-lock, focus-trap, and returning focus

A lightbox is the pattern you meet on every gallery, product page and image feed — click a thumbnail, the photo blows up into a fullscreen overlay over a dimmed page. I built a complete one with no library: prev/next with wraparound, a 3 / 8 counter, zoom, keyboard nav, and the accessibility work most home-made modals skip. The layout took ten minutes. Everything worth writing about is the behaviour around the image. Here it is.

The overlay is one fixed flex layer

The whole thing is a single position:fixed element pinned to all four edges with a high z-index, a translucent dark background that dims the page, and a backdrop-filter:blur. Make it a flex container centering on both axes and the image lands dead-center at any size with zero math. Cap it with viewport units so a huge photo still fits, and start it hidden — an .open class flips display:none to flex.

.lb { position:fixed; inset:0; z-index:100; display:none;
      align-items:center; justify-content:center;
      background:rgba(15,23,42,.92); backdrop-filter:blur(4px); }
.lb.open { display:flex; }
.lb-img { max-width:90vw; max-height:76vh; object-fit:contain; }
Enter fullscreen mode Exit fullscreen mode

Wraparound navigation is one modulo

Navigation is a single go(step) function, and the trick that makes it wrap without any if branches is modular arithmetic. The + n before the modulo keeps the result non-negative so stepping back from index 0 lands on the last image instead of going negative.

function go(step){
  const n = GALLERY.length;
  idx = (idx + step + n) % n;   // +n so -1 from index 0 wraps to the last
  render();
}
Enter fullscreen mode Exit fullscreen mode

The arrow keys call the same go() the on-screen buttons call, so mouse and keyboard can never drift out of sync — there is exactly one navigation path, entered two ways.

Backdrop-vs-image: identity, not coordinates

People expect a click on the dark area to close, but a click on the image not to. The clean way to tell them apart is checking where the click landed, not tracking coordinates: if e.target is the overlay itself, it was the backdrop, so close; a click on the image is a different target, handled separately, with stopPropagation so it never bubbles up to the close handler.

lb.addEventListener("click", e => { if (e.target === lb) close(); });
lbImg.addEventListener("click", e => { e.stopPropagation(); setZoom(!zoomed); });
Enter fullscreen mode Exit fullscreen mode

The three things a demo skips

This is where a lightbox becomes a real component rather than a toy.

Scroll-lock. With the overlay up, scrolling scrolls the page behind it — the background creeps, which reads as a bug. The core fix is one line: document.body.style.overflow = "hidden" on open, "" on close. (On desktop, hiding the scrollbar can shift layout by its width; the polished version measures innerWidth - documentElement.clientWidth and pads by it — but the one-liner is the idea.)

Focus trap. While the overlay is open the page behind is inert, but the browser still lets Tab walk into the hidden thumbnails, so a keyboard user gets lost. Collect the dialog's focusable buttons; when Tab moves off the last, loop to the first, and Shift+Tab off the first loops to the last.

function trapTab(e){
  const f = lb.querySelectorAll("button:not([disabled])");
  const first = f[0], last = f[f.length - 1];
  if (e.shiftKey && document.activeElement === first){ e.preventDefault(); last.focus(); }
  else if (!e.shiftKey && document.activeElement === last){ e.preventDefault(); first.focus(); }
}
Enter fullscreen mode Exit fullscreen mode

Return focus. The single most important line lives in close(). When I open, I save the thumbnail that triggered it into lastTrigger; on close I call lastTrigger.focus() to send focus back to the exact thumbnail the user came from. Without it, focus falls to the top of the document and a keyboard user has to tab all the way back down.

function close(){
  lb.classList.remove("open");
  document.body.style.overflow = "";        // restore scroll
  setZoom(false);
  if (lastTrigger) lastTrigger.focus();      // RETURN focus to the trigger
}
Enter fullscreen mode Exit fullscreen mode

Add role="dialog", aria-modal="true", real aria-labels and one visually-hidden aria-live region announcing "Alpine Sunrise, image 1 of 8", and you have an accessible lightbox. The eight images are self-contained SVG gradients built as data-URIs, so there are no external hosts at all.

Open one and watch the live read-out flip scroll from free to locked:
https://dev48v.infy.uk/design/day42-image-lightbox.html

Top comments (0)