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; }
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();
}
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); });
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(); }
}
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
}
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)