Image Lightbox — Free HTML CSS JS Gallery Snippet
Image Lightbox · Modals · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="gallery">
<div class="grid">
<div class="thumb" data-idx="0" style="background:linear-gradient(135deg,#6366f1,#a78bfa)">
<span class="thumb-label">Mountain</span>
</div>
<div class="thumb" data-idx="1" style="background:linear-gradient(135deg,#ec4899,#f97316)">
<span class="thumb-label">Sunset</span>
</div>
<div class="thumb" data-idx="2" style="background:linear-gradient(135deg,#10b981,#0ea5e9)">
<span class="thumb-label">Forest</span>
</div>
<div class="thumb" data-idx="3" style="background:linear-gradient(135deg,#f59e0b,#ef4444)">
<span class="thumb-label">Desert</span>
</div>
<div class="thumb" data-idx="4" style="background:linear-gradient(135deg,#8b5cf6,#ec4899)">
<span class="thumb-label">Ocean</span>
</div>
<div class="thumb" data-idx="5" style="background:linear-gradient(135deg,#0ea5e9,#10b981)">
<span class="thumb-label">City</span>
</div>
</div>
<p class="hint">Click any image to open</p>
</div>
<!-- Lightbox overlay -->
<div class="lightbox" id="lb" role="dialog" aria-modal="true" aria-label="Image viewer">
<button class="lb-close" id="lb-close" aria-label="Close">✕</button>
<button class="lb-prev" id="lb-prev" aria-label="Previous">‹</button>
<button class="lb-next" id="lb-next" aria-label="Next">›</button>
<div class="lb-img" id="lb-img"></div>
<div class="lb-footer">
<span class="lb-caption" id="lb-cap"></span>
<span class="lb-counter" id="lb-count"></span>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px 24px; }
.gallery { width: 100%; max-width: 560px; display: flex; flex-direction: column; gap: 16px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.thumb { border-radius: 12px; height: 110px; cursor: pointer; display: flex; align-items: flex-end; padding: 10px; overflow: hidden; position: relative; transition: transform 0.15s, box-shadow 0.15s; }
.thumb:hover { transform: scale(1.03); box-shadow: 0 8px 24px rgba(0,0,0,0.15); }
.thumb-label { font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.9); text-transform: uppercase; letter-spacing: 0.6px; position: relative; z-index: 1; }
.thumb::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.35), transparent); }
.hint { font-size: 12px; color: #94a3b8; text-align: center; }
/* ── Lightbox ── */
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,0.92); display: none; align-items: center; justify-content: center; z-index: 1000; animation: lb-in 0.2s ease; }
.lightbox.open { display: flex; }
@keyframes lb-in { from { opacity: 0; } to { opacity: 1; } }
.lb-img { width: min(600px, 88vw); height: min(420px, 70vh); border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 22px; font-weight: 700; color: rgba(255,255,255,0.8); box-shadow: 0 24px 80px rgba(0,0,0,0.6); transition: background 0.25s; }
.lb-close { position: absolute; top: 20px; right: 20px; background: rgba(255,255,255,0.1); border: none; color: #fff; font-size: 18px; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; transition: background 0.15s; display: flex; align-items: center; justify-content: center; }
.lb-close:hover { background: rgba(255,255,255,0.2); }
.lb-prev, .lb-next { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,0.1); border: none; color: #fff; font-size: 28px; width: 48px; height: 48px; border-radius: 50%; cursor: pointer; transition: background 0.15s; display: flex; align-items: center; justify-content: center; line-height: 1; }
.lb-prev:hover, .lb-next:hover { background: rgba(255,255,255,0.25); }
.lb-prev { left: 20px; }
.lb-next { right: 20px; }
.lb-footer { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); display: flex; gap: 24px; align-items: center; }
.lb-caption { font-size: 14px; color: rgba(255,255,255,0.8); font-weight: 600; }
.lb-counter { font-size: 12px; color: rgba(255,255,255,0.4); }const images = [
{ label: 'Mountain', bg: 'linear-gradient(135deg,#6366f1,#a78bfa)' },
{ label: 'Sunset', bg: 'linear-gradient(135deg,#ec4899,#f97316)' },
{ label: 'Forest', bg: 'linear-gradient(135deg,#10b981,#0ea5e9)' },
{ label: 'Desert', bg: 'linear-gradient(135deg,#f59e0b,#ef4444)' },
{ label: 'Ocean', bg: 'linear-gradient(135deg,#8b5cf6,#ec4899)' },
{ label: 'City', bg: 'linear-gradient(135deg,#0ea5e9,#10b981)' },
];
let cur = 0;
function openLightbox(idx) {
cur = idx;
show();
document.getElementById('lb').classList.add('open');
document.addEventListener('keydown', onKey);
}
function closeLightbox() {
document.getElementById('lb').classList.remove('open');
document.removeEventListener('keydown', onKey);
}
function nav(dir) {
cur = (cur + dir + images.length) % images.length;
show();
}
function show() {
const img = images[cur];
const el = document.getElementById('lb-img');
el.style.background = img.bg;
el.textContent = img.label;
document.getElementById('lb-cap').textContent = img.label;
document.getElementById('lb-count').textContent = (cur + 1) + ' / ' + images.length;
}
function onKey(e) {
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') nav(-1);
if (e.key === 'ArrowRight') nav(1);
}
document.querySelectorAll('.thumb').forEach(el => {
el.addEventListener('click', () => openLightbox(+el.dataset.idx));
});
document.getElementById('lb-close').addEventListener('click', closeLightbox);
document.getElementById('lb-prev').addEventListener('click', () => nav(-1));
document.getElementById('lb-next').addEventListener('click', () => nav(1));
document.getElementById('lb').addEventListener('click', e => {
if (e.target === e.currentTarget) closeLightbox();
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Image Lightbox — Thumbnail Grid, Fullscreen Overlay, Keyboard Navigation & Click-Outside Close

An image lightbox opens a fullscreen overlay displaying a larger version of a clicked thumbnail, with navigation controls to move between images without closing the overlay. It is one of the most-used UI patterns on photography portfolios, e-commerce product galleries, and media-rich web pages. This snippet gives you a complete implementation: a 3-column responsive thumbnail grid, a fade-in fullscreen overlay, previous/next navigation, keyboard arrow key support, ESC to close, and click-outside-overlay close — all in plain HTML, CSS, and vanilla JavaScript.
How the overlay works
The lightbox is a position: fixed div that covers the full viewport (inset: 0). Its default display is none. Clicking a thumbnail calls open(idx) which sets the current index, calls show() to populate the image display, and adds the .open class — which switches display to flex. A @keyframes animation fades opacity from 0 to 1 on open. Closing removes .open, reverting display to none.
The image display
The snippet uses gradient placeholder blocks instead of real images so the lightbox works immediately without image files. In production, replace the gradient divs with img elements — set src from the images array. The .lb-img div uses width: min(600px, 88vw) and height: min(420px, 70vh) — the min() function caps the size for large screens while allowing it to shrink on mobile viewports.
Navigation
nav(dir) adds dir to the current index and wraps at both ends using modulo arithmetic: (cur + dir + images.length) % images.length. This ensures -1 from index 0 wraps to the last image and +1 from the last wraps to 0. The arrow buttons call nav(-1) and nav(1). The keyboard handler maps ArrowLeft and ArrowRight to the same calls.
Keyboard and click-outside close
The keyboard listener (document.addEventListener("keydown", onKey)) is added only when the lightbox opens and removed when it closes — preventing memory leaks. Click-outside is detected by checking e.target === e.currentTarget on the overlay div — clicking the overlay background fires the event on the overlay element itself, not on a child.
Replacing with real images
Update the images array: { label: "Caption text", src: "photo.jpg" }. In show(), set el.src = img.src on an img element inside .lb-img instead of setting background.
Step by step
How to Use
- 1Click any thumbnail to open the lightboxThe overlay fades in showing the selected image. Use the ‹ and › arrow buttons or keyboard arrow keys to navigate. Press ESC or click outside the image or click ✕ to close.
- 2Replace gradients with real imagesIn the images array, add a src property: { label: "Caption", src: "photo.jpg", bg: "" }. In the show() function, replace el.style.background = img.bg with an img tag: el.innerHTML = "<img src='"+img.src+"' style='max-width:100%;max-height:100%;border-radius:14px;' alt='"+img.label+"'>".
- 3Update the thumbnail gridReplace each .thumb div with an img tag or a real image background-image. Keep the onclick="open(N)" attribute on each thumbnail, incrementing N from 0. Keep the .thumb-label overlay text for caption display.
- 4Add more imagesAdd entries to the images array and add matching .thumb divs in the HTML with the correct onclick index. The navigation wraps automatically via modulo arithmetic.
- 5Change the overlay background opacityUpdate the background rgba on .lightbox from rgba(0,0,0,0.92) to your preferred darkness. For a blurred background (instead of black), add backdrop-filter: blur(20px) and reduce the rgba opacity to 0.5.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component using useState for the open state and current index, or "Tailwind" for a React + Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The .lightbox div has an event listener: e.target === e.currentTarget. When the user clicks the dark overlay background, the click event fires on the .lightbox element itself (since nothing else is under the cursor). e.target is .lightbox and e.currentTarget is also .lightbox — they match, so close() fires. When the user clicks the image, the close button, or the nav buttons, e.target is that child element, not .lightbox — the condition is false and the lightbox stays open.
Add touchstart and touchend listeners to the .lb-img element. In touchstart, record startX = e.touches[0].clientX. In touchend, compute the delta: if (e.changedTouches[0].clientX - startX < -50) nav(1) for left swipe (next); if delta > 50, nav(-1) for right swipe (prev). A threshold of 50px prevents accidental swipes from tap jitter.
In the show() function, instead of setting the src immediately, set a loading state and use new Image() to preload: const preload = new Image(); preload.onload = () => { el.src = img.src; }; preload.src = img.src. Show a skeleton or spinner in .lb-img while the image loads. Also preload the next image in the background: const nextImg = images[(cur + 1) % images.length]; new Image().src = nextImg.src.
Click "JSX" to download. Use useState(null) for the open image index — null means closed. Conditionally render the lightbox: {openIndex !== null && <Lightbox .../>}. Pass onClose, onPrev, and onNext props. Add useEffect to attach and clean up the keydown listener when openIndex is not null: useEffect(() => { if (openIndex === null) return; const handler = e => {...}; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); }, [openIndex]).