Image Gallery with Modal with HTML, CSS & JavaScript
20 DAYS 20 PROJECT CHALLENGE
Day #14
Project Overview
A responsive Image Gallery that displays thumbnails in a grid and opens a larger view in an accessible modal/lightbox. Users can click a thumbnail or press keyboard keys to navigate (← / →), close (Esc), and click outside the image to close. The demo shows how to traverse the DOM, handle event bubbling, and manage modal state. It includes lazy loading of images, captions, and simple keyboard accessibility.
Key Features
- Responsive thumbnail grid (auto-fit).
- Click a thumbnail to open modal / lightbox with a larger image and caption.
- Next / Previous controls in the modal; keyboard shortcuts: ← → to navigate, Esc to close.
- Click outside the image (overlay) or close button to dismiss modal.
- Preload neighbor images for smoother navigation.
- Lazy loading (
loading="lazy") for thumbnails. - Accessible attributes (aria-hidden, roles, focus management).
- Simple image data array — easy to swap to your own images or fetch dynamically.
HTML Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Day 14 — Image Gallery with Modal</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="card" role="main" aria-labelledby="title">
<header>
<div class="logo">IG</div>
<div>
<h1 id="title">Day 14: Image Gallery with Modal</h1>
<p class="lead">A responsive gallery grid with an accessible modal/lightbox, keyboard navigation, and captions.</p>
</div>
</header>
<section class="gallery-wrap">
<div id="gallery" class="gallery" aria-live="polite">
<!-- Thumbnails generated by JS -->
</div>
</section>
<!-- Modal / Lightbox -->
<div id="lightbox" class="lightbox" role="dialog" aria-modal="true" aria-hidden="true" tabindex="-1">
<div class="lightbox-overlay" data-action="close"></div>
<div class="lightbox-panel" role="document" aria-labelledby="lbTitle">
<button class="lb-close" data-action="close" aria-label="Close (Esc)">✕</button>
<button class="lb-prev" data-action="prev" aria-label="Previous (Left arrow)">◀</button>
<figure class="lb-figure">
<img id="lbImage" src="" alt="" />
<figcaption id="lbTitle" class="lb-caption"></figcaption>
</figure>
<button class="lb-next" data-action="next" aria-label="Next (Right arrow)">▶</button>
</div>
</div>
<details style="margin-top:12px">
<summary>How it works (short)</summary>
<p class="small">JS renders thumbnails from an array of image objects. Clicking a thumbnail opens the lightbox with the large image. Event delegation on the gallery handles clicks; the lightbox listens to keyboard and click events to navigate or close. Focus is trapped to the modal while open (basic focus management), and images preload for next/previous.</p>
</details>
</main>
<script src="script.js"></script>
</body>
</html>
CSS Code
:root {
--bg: #071026;
--card: #0b1220;
--accent: #7c3aed;
--muted: #9aa4b2;
--white: #e6eef6;
font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: #002252;
color: var(--white);
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
}
.card {
width: min(1100px, 96%);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01));
border-radius: 12px;
padding: 18px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.03);
}
header {
display: flex;
gap: 12px;
align-items: center;
}
.logo {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), #22c1c3);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
h1 {
margin: 0;
font-size: 18px;
}
.lead {
margin: 4px 0 12px;
color: var(--muted);
font-size: 14px
}
/* Grid gallery */
.gallery-wrap {
margin-top: 12px
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.thumb {
position: relative;
overflow: hidden;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.01), rgba(255, 255, 255, 0.005));
border: 1px solid rgba(255, 255, 255, 0.03);
cursor: pointer;
display: block;
text-decoration: none;
}
.thumb img {
width: 100%;
height: 160px;
object-fit: cover;
display: block;
transition: transform .35s ease, filter .35s ease;
will-change: transform;
}
.thumb:hover img,
.thumb:focus img {
transform: scale(1.06);
filter: brightness(1.06);
}
.thumb .caption {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.0), rgba(0, 0, 0, 0.45));
color: #fff;
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Lightbox modal */
.lightbox {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 60;
}
.lightbox[aria-hidden="false"] {
display: flex;
}
.lightbox-overlay {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.7);
backdrop-filter: blur(4px);
cursor: pointer;
}
.lightbox-panel {
position: relative;
z-index: 62;
max-width: 92%;
max-height: 86%;
width: 920px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.lb-figure {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
max-height: 100%;
}
.lb-figure img {
max-width: 100%;
max-height: 80vh;
border-radius: 8px;
box-shadow: 0 14px 40px rgba(2, 6, 23, 0.6);
display: block;
}
.lb-caption {
margin-top: 8px;
color: var(--muted);
text-align: center;
font-size: 14px
}
/* Controls */
.lb-prev,
.lb-next,
.lb-close {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
color: var(--white);
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
font-size: 16px;
}
.lb-close {
position: absolute;
top: -52px;
right: 0;
transform: translateY(0);
background: transparent;
border: 0;
font-size: 20px;
color: var(--muted);
}
.lb-prev,
.lb-next {
flex: 0 0 auto
}
/* responsive tweaks */
@media (max-width:920px) {
.lb-close {
top: 8px;
right: 8px;
}
.lb-figure img {
max-height: 70vh
}
}
@media (max-width:520px) {
.gallery {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
}
.thumb img {
height: 120px
}
.lightbox-panel {
width: 96%
}
}
/* small helpers */
.small {
font-size: 13px;
color: var(--muted)
} Javascript Code
// Day 14 — Image Gallery with Modal
// Concepts: DOM traversal, event delegation, keyboard events, focus management
// --- Sample image data ---
// Replace these with your own image URLs and captions.
// Use full-size 'src' and thumbnail 'thumb' when you have separate assets.
// For demo, thumbs are same as src (you can scale in CSS).
const IMAGES = [
{ src: 'https://images.unsplash.com/photo-1503023345310-bd7c1de61c7d?q=80&w=1600&auto=format&fit=crop', thumb: '', title: 'Mountain sunrise' },
{ src: 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1600&auto=format&fit=crop', thumb: '', title: 'Forest path' },
{ src: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1600&auto=format&fit=crop', thumb: '', title: 'Ocean waves' },
{ src: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1600&auto=format&fit=crop', thumb: '', title: 'City skyline' },
{ src: 'https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?q=80&w=1600&auto=format&fit=crop', thumb: '', title: 'Desert dunes' },
{ src: 'https://images.unsplash.com/photo-1470770903676-69b98201ea1c?q=80&w=1600&auto=format&fit=crop', thumb: '', title: 'Coffee cup' },
{ src: 'https://images.unsplash.com/photo-1496307042754-b4aa456c4a2d?q=80&w=1600&auto=format&fit=crop', thumb: '', title: 'Night stars' },
{ src: 'https://images.unsplash.com/photo-1453728013993-6d66e9c9123a?q=80&w=1600&auto=format&fit=crop', thumb: '', title: 'Green leaves' }
];
// If thumb is empty, we'll use scaled src (css handles cropping)
// --- DOM references ---
const galleryEl = document.getElementById('gallery');
const lightbox = document.getElementById('lightbox');
const lbImage = document.getElementById('lbImage');
const lbCaption = document.getElementById('lbTitle');
const lbCloseBtn = document.querySelector('.lb-close');
const lbPrevBtn = document.querySelector('.lb-prev');
const lbNextBtn = document.querySelector('.lb-next');
const lbOverlay = document.querySelector('.lightbox-overlay');
let currentIndex = -1;
let lastFocusedEl = null;
// --- Render gallery thumbnails ---
function renderGallery() {
galleryEl.innerHTML = '';
IMAGES.forEach((img, i) => {
const a = document.createElement('button'); // use button for accessibility; behaves like a focusable element
a.className = 'thumb';
a.type = 'button';
a.dataset.index = i;
a.setAttribute('aria-label', `${img.title} — Open image`);
// create image element
const imageEl = document.createElement('img');
imageEl.src = img.thumb || img.src;
imageEl.alt = img.title || `Image ${i+1}`;
imageEl.loading = 'lazy';
// caption overlay
const caption = document.createElement('div');
caption.className = 'caption';
caption.innerHTML = `<span>${img.title || ''}</span><span style="opacity:.85;font-size:12px">View</span>`;
a.appendChild(imageEl);
a.appendChild(caption);
galleryEl.appendChild(a);
});
}
renderGallery();
// --- Event delegation for gallery clicks ---
galleryEl.addEventListener('click', (ev) => {
// find closest .thumb button
const thumb = ev.target.closest('.thumb');
if (!thumb) return;
const idx = Number(thumb.dataset.index);
openLightbox(idx);
});
// keyboard activation (Enter/Space) on focused thumbnail
galleryEl.addEventListener('keydown', (ev) => {
const el = ev.target;
if (el.classList && el.classList.contains('thumb') && (ev.key === 'Enter' || ev.key === ' ')) {
ev.preventDefault();
openLightbox(Number(el.dataset.index));
}
});
// --- Lightbox controls ---
function openLightbox(index) {
if (index < 0 || index >= IMAGES.length) return;
currentIndex = index;
lastFocusedEl = document.activeElement;
// populate image and caption
const data = IMAGES[index];
showImageInLightbox(data);
// show modal
lightbox.setAttribute('aria-hidden', 'false');
// lock scroll on body
document.body.style.overflow = 'hidden';
// focus the lightbox for keyboard handling
lightbox.focus();
// update buttons aria
updateNavButtons();
// preload neighbors
preloadNeighborImages(index);
}
function closeLightbox() {
lightbox.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
// clear image src to free memory
lbImage.src = '';
currentIndex = -1;
// restore focus
if (lastFocusedEl && typeof lastFocusedEl.focus === 'function') lastFocusedEl.focus();
}
// display image
function showImageInLightbox(data) {
lbImage.src = data.src;
lbImage.alt = data.title || '';
lbCaption.textContent = data.title || '';
// handle load errors gracefully
lbImage.onerror = () => {
lbImage.alt = 'Failed to load image';
lbCaption.textContent = 'Failed to load image';
};
}
// navigation
function showNext() {
if (currentIndex < IMAGES.length - 1) {
currentIndex++;
showImageInLightbox(IMAGES[currentIndex]);
updateNavButtons();
preloadNeighborImages(currentIndex);
}
}
function showPrev() {
if (currentIndex > 0) {
currentIndex--;
showImageInLightbox(IMAGES[currentIndex]);
updateNavButtons();
preloadNeighborImages(currentIndex);
}
}
function updateNavButtons() {
lbPrevBtn.disabled = currentIndex <= 0;
lbNextBtn.disabled = currentIndex >= IMAGES.length - 1;
}
// preload neighbors for smooth nav
function preloadNeighborImages(idx) {
[idx - 1, idx + 1].forEach(i => {
if (i >= 0 && i < IMAGES.length) {
const img = new Image();
img.src = IMAGES[i].src;
}
});
}
// --- Event listeners for lightbox actions ---
lbCloseBtn.addEventListener('click', closeLightbox);
lbPrevBtn.addEventListener('click', showPrev);
lbNextBtn.addEventListener('click', showNext);
// clicking overlay closes
lbOverlay.addEventListener('click', (ev) => {
if (ev.target.dataset.action === 'close' || ev.currentTarget) closeLightbox();
});
// keyboard handling while lightbox open
document.addEventListener('keydown', (ev) => {
if (lightbox.getAttribute('aria-hidden') === 'true') return;
if (ev.key === 'Escape') {
ev.preventDefault();
closeLightbox();
} else if (ev.key === 'ArrowRight') {
ev.preventDefault();
showNext();
} else if (ev.key === 'ArrowLeft') {
ev.preventDefault();
showPrev();
} else if (ev.key === 'Tab') {
// basic focus trap: keep focus within lightbox-panel controls
const focusable = Array.from(lightbox.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'))
.filter(n => !n.hasAttribute('disabled'));
if (focusable.length === 0) {
ev.preventDefault();
return;
}
const idx = focusable.indexOf(document.activeElement);
if (ev.shiftKey) {
// backward
if (idx === 0) {
ev.preventDefault();
focusable[focusable.length - 1].focus();
}
} else {
// forward
if (idx === focusable.length - 1) {
ev.preventDefault();
focusable[0].focus();
}
}
}
});
// close on focus loss (optional): when clicking outside is already handled via overlay
// Accessibility: close on backdrop click or pressing close
// --- Optional: support swipe gestures on touch devices for next/prev ---
let touchStartX = 0;
let touchEndX = 0;
const threshold = 40; // minimal px to consider swipe
const panel = document.querySelector('.lightbox-panel');
panel.addEventListener('touchstart', (e) => {
touchStartX = e.changedTouches[0].screenX;
}, {passive:true});
panel.addEventListener('touchend', (e) => {
touchEndX = e.changedTouches[0].screenX;
const dx = touchEndX - touchStartX;
if (Math.abs(dx) > threshold) {
if (dx < 0) showNext(); else showPrev();
}
}, {passive:true});
// --- small enhancement: allow arrow hovering on large screens with click targets (handled by buttons) ---
// --- End of script ---
Related Projects
Day 12 : QR Code Generator
Generates a QR code from text input.
Concepts: Third-party API (QRServer API), fetch().
Day 16 : Scroll Progress Bar
Shows a progress bar at the top as the user scrolls down the page.
Concepts: scroll event, math calculations.
Day 17 : Clipboard Copy Tool
Click a button to copy text to the clipboard.
Concepts: Clipboard API, DOM events.