Lazy Loading Mini Project — Build a Lazy-Loaded Image Gallery
In this tutorial, you will learn about Lazy Loading Mini Project. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a complete lazy-loaded image gallery that demonstrates native loading, Intersection Observer, dynamic imports, accessibility, and performance optimization.
What You'll Learn
By the end of this project, you'll build a production-ready image gallery that lazy loads images with multiple strategies, manages focus for accessibility, supports keyboard navigation, measures performance, and degrades gracefully for users with JavaScript disabled.
Why It Matters
Image galleries are the most common use case for lazy loading. A well-built gallery improves page load speed, reduces bandwidth, and provides a smooth user experience. This project combines every lazy loading technique you've learned — native attributes, Intersection Observer, dynamic imports, accessibility, and performance monitoring — into one practical application.
Real-World Use
An e-commerce product gallery with 50+ high-resolution images implements lazy loading so only visible thumbnails load on page load. As the user scrolls, new images load just before entering the viewport. Clicking a thumbnail dynamically imports a lightbox module. The gallery works with keyboard navigation, announces loading to screen readers, and reports performance metrics. Initial page load is 200KB instead of 15MB.
Gallery Architecture
graph LR
A[Image Gallery] --> B[Thumbnail Grid
native loading=lazy]
A --> C[Lightbox
Dynamic import]
A --> D[Performance Monitor
Intersection Observer]
B --> E[Visible images
Load immediately]
B --> F[Below-fold images
loading=lazy + IO]
C --> G[Click handler
Dynamic import() ]
C --> H[Focus management
ARIA live regions]
D --> I[Bytes loaded
Time to interactive]
D --> J[LCP improvement
Report]
style A fill:#4a90d9,color:#fff
style C fill:#f39c12,color:#fff
Project Structure
image-gallery/
├── index.html # Gallery page with HTML structure
├── css/
│ ├── gallery.css # Gallery styles
│ └── lightbox.css # Lazy loaded lightbox styles
├── js/
│ ├── gallery.js # Main gallery logic
│ ├── lightbox.js # Lightbox module (dynamically imported)
│ └── performance.js # Performance measurement utilities
└── images/
└── (50+ images in various sizes)
Step 1: HTML Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lazy Loaded Image Gallery — Mini Project</title>
<link rel="stylesheet" href="css/gallery.css">
<!-- Preload first visible images for fast initial render -->
<link rel="preload" href="images/photo-01-thumb.webp" as="image">
<link rel="preload" href="images/photo-02-thumb.webp" as="image">
<link rel="preload" href="images/photo-03-thumb.webp" as="image">
<link rel="preload" href="images/photo-04-thumb.webp" as="image">
</head>
<body>
<header>
<h1>Lazy Loaded Image Gallery</h1>
<p>50 high-resolution photos — only visible images load initially.</p>
<!-- Performance stats displayed in real-time -->
<div id="performance-stats" aria-live="polite" role="status">
<span>Images loaded: <span id="loaded-count">0</span>/50</span>
<span>Data loaded: <span id="data-loaded">0 KB</span></span>
</div>
</header>
<!-- Skip link for keyboard users -->
<a href="#gallery" class="skip-link">Skip to gallery</a>
<!-- Image gallery grid -->
<div class="gallery-grid" id="gallery" role="list" aria-label="Image gallery">
<!-- Images generated by JavaScript from data -->
</div>
<!-- Lightbox container (hidden until activated) -->
<div id="lightbox" class="lightbox" role="dialog" aria-modal="true" aria-label="Image viewer" hidden>
<div class="lightbox-overlay"></div>
<div class="lightbox-content">
<button class="lightbox-close" aria-label="Close image viewer">×</button>
<button class="lightbox-prev" aria-label="Previous image">‹</button>
<div class="lightbox-image-container">
<img id="lightbox-image" src="" alt="">
<div class="lightbox-loader" role="status">Loading image...</div>
</div>
<button class="lightbox-next" aria-label="Next image">›</button>
<div class="lightbox-info">
<span id="lightbox-caption"></span>
<span id="lightbox-counter"></span>
</div>
</div>
</div>
<!-- Screen reader announcer -->
<div id="sr-announcer" aria-live="polite" class="sr-only"></div>
<!-- Noscript fallback -->
<noscript>
<div class="noscript-warning">
<p>JavaScript is disabled. All images are loaded without lazy loading.</p>
</div>
<style>
.gallery-grid img { display: inline-block; }
</style>
</noscript>
<script src="js/gallery.js" defer></script>
</body>
</html>
Step 2: CSS — gallery.css
/* css/gallery.css — Gallery styles */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
color: #1a1a2e;
background: #f5f5f5;
padding: 20px;
}
header {
max-width: 1200px;
margin: 0 auto 30px;
}
h1 { font-size: 2rem; margin-bottom: 8px; }
#performance-stats {
display: flex;
gap: 24px;
padding: 12px 16px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
font-size: 0.9rem;
}
.skip-link {
position: absolute;
left: -9999px;
z-index: 1000;
background: #1a1a2e;
color: #fff;
padding: 8px 16px;
border-radius: 4px;
}
.skip-link:focus { left: 20px; top: 20px; }
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
max-width: 1200px;
margin: 0 auto;
}
.gallery-item {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #e0e0e0;
aspect-ratio: 4 / 3;
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s ease;
}
/* Lazy loading placeholder */
.gallery-item img:not([src]) {
opacity: 0;
}
.gallery-item img.loaded {
opacity: 1;
}
.gallery-item .placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0, #f0f0f0);
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.gallery-item img { transition: none; }
}
.lightbox {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox[hidden] { display: none; }
.lightbox-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
}
.lightbox-content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 16px;
max-width: 90vw;
max-height: 90vh;
}
.lightbox-image-container {
position: relative;
max-width: 80vw;
max-height: 80vh;
}
.lightbox-image-container img {
max-width: 100%;
max-height: 80vh;
border-radius: 4px;
}
.lightbox-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 1.2rem;
}
.lightbox-close,
.lightbox-prev,
.lightbox-next {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: none;
padding: 12px 16px;
cursor: pointer;
font-size: 1.5rem;
border-radius: 4px;
transition: background 0.2s;
}
.lightbox-close:hover,
.lightbox-prev:hover,
.lightbox-next:hover { background: rgba(255, 255, 255, 0.4); }
.lightbox-close:focus-visible,
.lightbox-prev:focus-visible,
.lightbox-next:focus-visible {
outline: 3px solid #4a90d9;
outline-offset: 2px;
}
.lightbox-info {
position: absolute;
bottom: -40px;
left: 0;
right: 0;
text-align: center;
color: #fff;
display: flex;
justify-content: space-between;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
}
Step 3: JavaScript — gallery.js
// js/gallery.js — Main gallery logic
class ImageGallery {
constructor(config) {
this.config = {
gridSelector: '#gallery',
imageCount: 50,
thumbBase: 'images/photo-',
fullBase: 'images/photo-',
batchSize: 4,
...config
};
this.grid = document.querySelector(this.config.gridSelector);
this.currentIndex = 0;
this.images = [];
this.loadedCount = 0;
this.observer = null;
this.lightbox = null;
this.init();
}
init() {
this.generateImageData();
this.renderInitialBatch();
this.setupIntersectionObserver();
this.setupKeyboardNavigation();
this.updateStats();
}
generateImageData() {
this.images = Array.from({ length: this.config.imageCount }, (_, i) => ({
id: i + 1,
thumbSrc: `${this.config.thumbBase}${String(i + 1).padStart(2, '0')}-thumb.webp`,
fullSrc: `${this.config.fullBase}${String(i + 1).padStart(2, '0')}-full.webp`,
alt: `Photo ${i + 1} from the gallery`,
caption: `Gallery Photo ${i + 1}`
}));
}
renderInitialBatch() {
// Render first batch with actual src (load immediately)
const initialImages = this.images.slice(0, this.config.batchSize);
initialImages.forEach((imgData, i) => {
const item = this.createGalleryItem(imgData, true);
this.grid.appendChild(item);
});
// Create placeholder items for remaining images
const remaining = this.images.slice(this.config.batchSize);
remaining.forEach(imgData => {
const item = this.createGalleryItem(imgData, false);
this.grid.appendChild(item);
});
}
createGalleryItem(imgData, immediate) {
const item = document.createElement('div');
item.className = 'gallery-item';
item.role = 'listitem';
item.dataset.index = imgData.id - 1;
// Placeholder background
const placeholder = document.createElement('div');
placeholder.className = 'placeholder';
item.appendChild(placeholder);
// Image element
const img = document.createElement('img');
img.alt = imgData.alt;
img.width = 400;
img.height = 300;
img.classList.add('lazy-image');
if (immediate) {
img.src = imgData.thumbSrc;
img.loading = 'eager';
img.classList.add('loaded');
this.loadedCount++;
} else {
img.dataset.src = imgData.thumbSrc;
img.loading = 'lazy';
}
img.dataset.fullSrc = imgData.fullSrc;
img.dataset.caption = imgData.caption;
item.appendChild(img);
// Click to open lightbox
item.addEventListener('click', () => this.openLightbox(imgData.id - 1));
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.openLightbox(imgData.id - 1);
}
});
item.setAttribute('tabindex', '0');
return item;
}
setupIntersectionObserver() {
this.observer = new IntersectionObserver(
(entries) => this.handleIntersection(entries),
{ rootMargin: '200px 0px' }
);
// Observe all lazy images
this.grid.querySelectorAll('.lazy-image[data-src]').forEach(img => {
this.observer.observe(img);
});
}
handleIntersection(entries) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
this.loadImage(img);
this.observer.unobserve(img);
}
});
}
loadImage(img) {
if (!img.dataset.src) return;
const src = img.dataset.src;
delete img.dataset.src;
const tempImg = new Image();
tempImg.onload = () => {
img.src = src;
img.classList.add('loaded');
this.loadedCount++;
this.updateStats();
this.announceScreenReader(`Image ${this.loadedCount} of ${this.images.length} loaded`);
};
tempImg.onerror = () => {
console.error(`Failed to load: ${src}`);
img.alt = 'Image failed to load';
};
tempImg.src = src;
}
async openLightbox(index) {
this.currentIndex = index;
// Dynamically import lightbox module
try {
const module = await import('./lightbox.js');
this.lightbox = module.Lightbox;
this.lightbox.open(this.images, this.currentIndex);
this.announceScreenReader(`Lightbox opened. Photo ${index + 1} of ${this.images.length}`);
} catch (error) {
console.error('Failed to load lightbox:', error);
this.announceScreenReader('Lightbox failed to open. Please try again.', 'assertive');
}
}
setupKeyboardNavigation() {
document.addEventListener('keydown', (e) => {
if (!this.lightbox || !this.lightbox.isOpen) return;
switch (e.key) {
case 'ArrowLeft':
this.navigateLightbox(-1);
break;
case 'ArrowRight':
this.navigateLightbox(1);
break;
case 'Escape':
this.closeLightbox();
break;
}
});
}
navigateLightbox(direction) {
const newIndex = this.currentIndex + direction;
if (newIndex >= 0 && newIndex < this.images.length) {
this.currentIndex = newIndex;
this.lightbox.showImage(this.images[this.currentIndex], this.currentIndex);
this.announceScreenReader(`Photo ${this.currentIndex + 1} of ${this.images.length}`);
}
}
closeLightbox() {
if (this.lightbox) {
this.lightbox.close();
this.lightbox = null;
this.announceScreenReader('Lightbox closed');
}
}
updateStats() {
document.getElementById('loaded-count').textContent = this.loadedCount;
// Estimate data loaded (300KB per image as rough estimate)
const estimatedKB = this.loadedCount * 300;
const display = estimatedKB > 1024
? `${(estimatedKB / 1024).toFixed(1)} MB`
: `${estimatedKB} KB`;
document.getElementById('data-loaded').textContent = display;
}
announceScreenReader(message, priority = 'polite') {
const announcer = document.getElementById('sr-announcer');
if (!announcer) return;
announcer.setAttribute('aria-live', priority);
announcer.textContent = '';
requestAnimationFrame(() => {
announcer.textContent = message;
});
}
}
// Initialize gallery when page loads
document.addEventListener('DOMContentLoaded', () => {
const gallery = new ImageGallery();
});
Step 4: JavaScript — lightbox.js (Dynamic Import)
// js/lightbox.js — Lightbox module (dynamically imported)
export const Lightbox = {
isOpen: false,
currentIndex: 0,
images: [],
open(images, index) {
this.images = images;
this.currentIndex = index;
const lightboxElement = document.getElementById('lightbox');
lightboxElement.hidden = false;
document.body.style.overflow = 'hidden';
this.showImage(images[index], index);
this.isOpen = true;
// Focus close button for keyboard users
setTimeout(() => {
lightboxElement.querySelector('.lightbox-close').focus();
}, 100);
},
showImage(imageData, index) {
const img = document.getElementById('lightbox-image');
const loader = document.querySelector('.lightbox-loader');
const caption = document.getElementById('lightbox-caption');
const counter = document.getElementById('lightbox-counter');
// Show loader
loader.hidden = false;
img.style.opacity = '0';
// Lazy load full resolution image
const fullImg = new Image();
fullImg.onload = () => {
img.src = imageData.fullSrc;
img.alt = imageData.alt;
img.style.opacity = '1';
loader.hidden = true;
};
fullImg.onerror = () => {
img.src = imageData.thumbSrc;
img.alt = `${imageData.alt} (full resolution unavailable)`;
loader.hidden = true;
};
fullImg.src = imageData.fullSrc;
caption.textContent = imageData.caption;
counter.textContent = `${index + 1} / ${this.images.length}`;
},
close() {
const lightboxElement = document.getElementById('lightbox');
lightboxElement.hidden = true;
document.body.style.overflow = '';
this.isOpen = false;
// Return focus to the gallery item
const gridItem = document.querySelector(
`.gallery-item[data-index="${this.currentIndex}"]`
);
if (gridItem) gridItem.focus();
}
};
Step 5: Performance Measurement
// js/performance.js — Performance measurement utilities
class GalleryPerformanceMonitor {
constructor() {
this.metrics = {
loadTime: 0,
lcp: 0,
totalImages: 0,
lazyLoadedImages: 0,
bytesSaved: 0
};
this.init();
}
init() {
// Measure initial load time
if (performance.timing) {
this.metrics.loadTime = performance.timing.domContentLoadedEventEnd -
performance.timing.navigationStart;
}
// Measure LCP
if (PerformanceObserver) {
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length > 0) {
this.metrics.lcp = entries[entries.length - 1].renderTime;
}
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
}
// Count lazy loaded images
document.addEventListener('DOMContentLoaded', () => {
this.metrics.totalImages = document.querySelectorAll('.gallery-item').length;
});
}
trackImageLoad() {
this.metrics.lazyLoadedImages++;
}
getReport() {
// Estimate bytes saved (each lazy loaded image = 300KB deferred)
const immediateCount = 4; // First batch loaded immediately
const deferredCount = this.metrics.totalImages - immediateCount;
const loadedOnInteraction = this.metrics.lazyLoadedImages;
return {
totalImages: this.metrics.totalImages,
immediateLoad: immediateCount,
deferred: deferredCount,
loadedOnDemand: loadedOnInteraction,
estimatedBytesDeferred: `${(deferredCount * 300).toLocaleString()} KB`,
lcp: this.metrics.lcp ? `${this.metrics.lcp.toFixed(0)}ms` : 'N/A',
initialLoadWeight: `${(immediateCount * 300).toLocaleString()} KB`,
fullLoadWeight: `${(this.metrics.totalImages * 300).toLocaleString()} KB`,
savingsPercent: deferredCount > 0
? `${Math.round((deferredCount / this.metrics.totalImages) * 100)}%`
: '0%'
};
}
displayReport() {
const report = this.getReport();
console.log('=== Gallery Performance Report ===');
console.table(report);
// Display in DOM
const statsDiv = document.getElementById('performance-stats');
if (statsDiv) {
const reportHTML = `
<span>LCP: ${report.lcp}</span>
<span>Deferred: ${report.estimatedBytesDeferred}</span>
<span>Saved: ${report.savingsPercent}</span>
`;
statsDiv.innerHTML = reportHTML;
}
return report;
}
}
export default GalleryPerformanceMonitor;
Testing Checklist
- All 50 images load as the user scrolls
- Lightbox opens with dynamic import
- Keyboard navigation works (Tab, Enter, Escape, Arrow keys)
- Screen reader announces image loading and lightbox state
- prefers-reduced-motion disables transitions
- Noscript fallback shows all images without JS
- Performance report shows deferred bytes and LCP improvement
- Focus returns to gallery item when lightbox closes
Common Mistakes
- Loading all images eagerly. The first batch should be small (4-6 images). Loading 20 images eagerly defeats the purpose of lazy loading.
- No loading state for lightbox. High-resolution images take time to load. Always show a loading indicator in the lightbox.
- Focus trapped in lightbox. When the lightbox closes, focus should return to the triggering element. Without this, keyboard users lose their place.
- Missing ARIA attributes. The grid, lightbox, and loading indicators need proper roles and live regions for screen reader compatibility.
- Not handling errors. Failed image loads should show a fallback or error state, not a broken image icon.
Practice Questions
- Why does the gallery load 4 images immediately and the rest lazily?
- How does the dynamic import for the lightbox improve performance?
- Why is focus management important when the lightbox opens and closes?
- How does the Intersection Observer rootMargin affect perceived loading speed?
- How would you add pagination (load next 20 images on button click)?
Challenge: Extend the gallery with a search and filter feature that dynamically imports a filtering module, adds URL-based navigation (so each image has a unique URL like /gallery?photo=12), and implements infinite scroll with a loading threshold of 100px.
FAQ
Mini Project Complete
You've built a production-ready lazy loaded image gallery. Deploy it to Netlify or GitHub Pages and test with:
- Chrome DevToolsk "DevTools" >}} Network panel (verify images load on scroll)
- Lighthouse performance audit (target 95+ performance score)
- Screen reader (NVDA or VoiceOver)
- Keyboard-only navigation
- prefers-reduced-motion enabled
What's Next
You've completed the Lazy Loading tutorial series. Start the Internationalization (i18n) series to learn how to build multilingual web applications that serve users in any language.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro