Image Gallery with Modal with HTML, CSS & JavaScript
20 DAYS 20 PROJECT CHALLENGE
Day #14
Project Overview
The Image Gallery with Modal is a beginner-friendly project built using HTML, CSS, and JavaScript. It displays multiple images in a responsive grid layout and allows users to click any image to view it in a larger modal or lightbox. Users can navigate through the images using Previous and Next buttons or keyboard arrow keys. The caption is shown below the image, and an intersection observer is used to make images load faster.
This project is useful for beginners who want to practice DOM manipulation, event delegation, keyboard events, modal state management, accessibility features, and responsive CSS layouts while creating an interactive image gallery.
Key Features
- Responsive Image Grid: The gallery displays multiple image thumbnails in a responsive CSS Grid layout. The number of columns adjusts to the available screen width.
- Image Modal/Lightbox: Users can click on an image thumbnail to open a larger version of the image in a modal/lightbox so that users can see images on the same page without having to leave the current page.
- Image Captions: Each image has a caption that describes what the image is. These captions appear in the gallery as well as in the lightbox/modal.
- Keyboard Navigation: Users can use the Right Arrow and Left Arrow keys on their keyboard to view the previous image or next image in the gallery. This makes it easier and quicker for the user to navigate through the gallery.
- Click Outside Image to Close: The lightbox/modal closes if the user clicks on the dark area outside the image. This provides an alternative way for users to close the image modal.
- Image Hover Effects: Thumbnails have hover and focus effects so that the images slightly enlarge and brighten when a user interacts with them.
- Lazy Loading: The thumbnail images have the loading=”lazy” attribute applied to them. This delays the image download until the image is needed, which can improve performance.
- Preload Near Images: JavaScript preloads the images that come before and after the current image so that it is easy and quick for the user to navigate through the gallery.
- Touch Swipe: Users can swipe on an image with touch devices to navigate through the gallery.
- Easy Image Customization: The images are placed in a JavaScript array, making it easy for users to replace the image URLs and captions with their images.
- Responsive: The gallery and modal adjust to different screen sizes, allowing the project to work comfortably on desktops, tablets, and mobile devices.
What You'll Learn
- Understand how to create a responsive image gallery using CSS Grid.
- Learn how to dynamically create HTML elements using JavaScript.
- Understand how event delegation works.
- Learn how
closest()can identify clicked elements. - Understand how keyboard events can control an application.
- Practice adding and updating HTML attributes dynamically.
- Understand how image preloading works.
- Learn how lazy loading can improve image loading performance.
- Understand how CSS transitions create image hover effects.
- Learn how touch events can support swipe navigation.
- Practice creating an accessible and responsive web interface.
HTML Code
HTML creates the main structure of the Image Gallery with Modal. The complete application is placed inside the .card container, which contains the project heading, image gallery area, modal structure and #gallery element acts as the main container for the image thumbnails. Instead of manually adding every image in the HTML, JavaScript creates the thumbnails dynamically from the image data array.
The #lightbox element creates the modal that appears when an image is selected. Inside the modal, the .lightbox-overlay provides the dark background and allows users to close the modal by clicking outside the image and the .lightbox-panel contains the main image and navigation controls, #lbImage element displays the selected image, while #lbTitle displays its caption. The Previous, Next, and Close buttons allow users to navigate through images or close the lightbox. The script.js file is linked at the bottom of the <body> so JavaScript can create the gallery, handle image clicks, control the modal, and manage keyboard navigation.
<!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
The CSS code controls the appearance and layout of the Image Gallery and its modal. The .gallery class uses CSS Grid with repeat(auto-fit, minmax(...)) to create a responsive thumbnail layout that automatically adjusts based on the available screen width. Each thumbnail uses the .thumb class. The images inside these thumbnails use object-fit: cover so they maintain a consistent size while filling the available space. Hover and focus effects use CSS transforms to slightly enlarge and brighten an image.
The .lightbox class creates the full-screen modal. It uses position: fixed and covers the complete screen when opened. The aria-hidden attribute is also connected with CSS so the modal is displayed only when JavaScript changes its value and the .lightbox-overlay creates the dark background behind the image, while .lightbox-panel positions the larger image and navigation controls. The .lb-figure img selector limits the image size so it fits properly within the available screen area.
: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
JavaScript provides the main functionality of the Image Gallery with Modal. First, the IMAGES array stores information about every image, including its source URL, thumbnail, and title, the renderGallery() function loops through the image data and dynamically creates a button for every image. Each button contains an image and caption and is added to the #gallery container. The loading = 'lazy' property is used to delay image loading until required.
The gallery uses event delegation to detect which thumbnail has been clicked. JavaScript finds the closest .thumb element and passes its index to the openLightbox() function and the openLightbox() function displays the selected image, opens the modal, disables background scrolling, moves keyboard focus to the lightbox, and preloads nearby images. The showNext() and showPrev() functions change the current image and update the navigation buttons. JavaScript code also listens for keyboard events. The Escape key closes the modal, while the Left and Right Arrow keys move through the gallery. A basic focus trap keeps keyboard navigation inside the modal while it is open.
// 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.