How to Create an Image Gallery for Your Website (HTML, CSS & JavaScript)
Learn how to build a responsive image gallery from scratch using HTML, CSS, and JavaScript. Includes lightbox functionality, lazy loading, and tips for hosting gallery images.
Quick Takeaways
- •What We're Building
- •Step 1: HTML Structure
- •Key Design Decisions
- •Step 2: CSS Grid Layout
An image gallery is one of the most common features on the web. Portfolio sites, photography blogs, e-commerce stores, documentation sites, and personal blogs all need a way to display collections of images in an organized, visually appealing layout.
This tutorial walks you through building a production-ready image gallery from scratch — no framework required. You'll learn responsive grid layouts, lightbox modals, lazy loading, and how to host your gallery images for optimal performance.
What We're Building
By the end of this tutorial, you'll have a gallery that:
- Displays images in a responsive grid that adapts to any screen size
- Opens a full-screen lightbox when clicking an image
- Supports keyboard navigation (arrow keys, Escape)
- Lazy-loads images for fast initial page load
- Works on all modern browsers without dependencies
Step 1: HTML Structure
Start with a clean, semantic HTML structure. Each gallery item wraps a thumbnail image with a link to the full-size version:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Image Gallery</title>
<link rel="stylesheet" href="gallery.css">
</head>
<body>
<div class="gallery-container">
<h1>Photo Gallery</h1>
<div class="gallery-grid" id="gallery">
<div class="gallery-item">
<img src="thumb-1.webp"
data-full="full-1.webp"
alt="Mountain landscape at sunset"
loading="lazy"
width="400" height="300">
</div>
<div class="gallery-item">
<img src="thumb-2.webp"
data-full="full-2.webp"
alt="Ocean waves on rocky shore"
loading="lazy"
width="400" height="300">
</div>
<!-- Add more gallery items -->
</div>
</div>
<!-- Lightbox modal -->
<div class="lightbox" id="lightbox" role="dialog" aria-label="Image viewer">
<button class="lightbox-close" aria-label="Close">×</button>
<button class="lightbox-prev" aria-label="Previous">‹</button>
<button class="lightbox-next" aria-label="Next">›</button>
<img class="lightbox-img" id="lightbox-img" alt="">
<div class="lightbox-caption" id="lightbox-caption"></div>
</div>
<script src="gallery.js"></script>
</body>
</html>
Key Design Decisions
- Separate thumbnail and full-size URLs: The
srcloads a small thumbnail for fast grid display. Thedata-fullattribute stores the full-resolution URL for the lightbox. - Native lazy loading:
loading="lazy"defers loading of off-screen images — free performance improvement. - Width and height attributes: Prevents Cumulative Layout Shift (CLS) by reserving space before images load.
- Accessibility: All images have descriptive alt text. The lightbox has ARIA attributes and keyboard support.
Step 2: CSS Grid Layout
CSS Grid makes responsive image galleries simple. No media queries needed for the basic grid — auto-fill and minmax handle everything:
/* gallery.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #fff;
min-height: 100vh;
}
.gallery-container {
max-width: 1400px;
margin: 0 auto;
padding: 40px 20px;
}
.gallery-container h1 {
text-align: center;
margin-bottom: 40px;
font-size: 2rem;
font-weight: 300;
}
/* Responsive grid: auto-fills columns between 280px and 1fr */
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.gallery-item {
position: relative;
overflow: hidden;
border-radius: 8px;
cursor: pointer;
aspect-ratio: 4/3;
background: #1a1a1a;
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.gallery-item:hover img {
transform: scale(1.05);
opacity: 0.9;
}
How It Works
repeat(auto-fill, minmax(280px, 1fr)): Creates as many columns as will fit, each at least 280px wide and up to equal fractions of available space. On a 1400px container, you get 4 columns. On a 600px phone, you get 2 columns. No breakpoints needed.aspect-ratio: 4/3: Enforces a consistent aspect ratio for all gallery items, creating a clean grid even with differently-proportioned source images.object-fit: cover: Fills the aspect-ratio container and crops as needed, rather than stretching or letterboxing.
Step 3: Lightbox CSS
/* Lightbox overlay */
.lightbox {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
align-items: center;
justify-content: center;
}
.lightbox.active {
display: flex;
}
.lightbox-img {
max-width: 90vw;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.lightbox-close,
.lightbox-prev,
.lightbox-next {
position: absolute;
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 2rem;
padding: 16px;
opacity: 0.7;
transition: opacity 0.2s;
z-index: 10;
}
.lightbox-close:hover,
.lightbox-prev:hover,
.lightbox-next:hover {
opacity: 1;
}
.lightbox-close { top: 10px; right: 20px; font-size: 2.5rem; }
.lightbox-prev { left: 20px; top: 50%; transform: translateY(-50%); font-size: 3rem; }
.lightbox-next { right: 20px; top: 50%; transform: translateY(-50%); font-size: 3rem; }
.lightbox-caption {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
color: #ccc;
font-size: 0.9rem;
max-width: 600px;
text-align: center;
}
Step 4: JavaScript Functionality
// gallery.js
(function() {
const gallery = document.getElementById('gallery');
const lightbox = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightbox-img');
const lightboxCaption = document.getElementById('lightbox-caption');
const items = gallery.querySelectorAll('.gallery-item img');
let currentIndex = 0;
// Open lightbox
gallery.addEventListener('click', (e) => {
const img = e.target.closest('.gallery-item img');
if (!img) return;
currentIndex = Array.from(items).indexOf(img);
showImage(currentIndex);
lightbox.classList.add('active');
document.body.style.overflow = 'hidden';
});
// Close lightbox
lightbox.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox) closeLightbox();
});
// Navigation
lightbox.querySelector('.lightbox-prev').addEventListener('click', () => navigate(-1));
lightbox.querySelector('.lightbox-next').addEventListener('click', () => navigate(1));
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (!lightbox.classList.contains('active')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') navigate(-1);
if (e.key === 'ArrowRight') navigate(1);
});
function showImage(index) {
const img = items[index];
lightboxImg.src = img.dataset.full || img.src;
lightboxImg.alt = img.alt;
lightboxCaption.textContent = img.alt;
}
function navigate(direction) {
currentIndex = (currentIndex + direction + items.length) % items.length;
showImage(currentIndex);
}
function closeLightbox() {
lightbox.classList.remove('active');
document.body.style.overflow = '';
}
})();
Features Explained
- Event delegation: Instead of attaching click handlers to every image, we listen on the gallery container and check if the clicked element is an image. This is more performant with many images.
- Circular navigation: Arrow keys and buttons wrap around — pressing "next" on the last image goes to the first.
- Scroll lock: When the lightbox is open,
overflow: hiddenon body prevents background scrolling. - Escape to close: Standard UX pattern that users expect.
Step 5: Advanced Enhancements
Masonry Layout (Pinterest-Style)
If your images have different aspect ratios and you want a masonry (Pinterest-style) layout, CSS columns provide a pure-CSS solution:
/* Replace the grid rules with columns */
.gallery-grid {
columns: 4 280px;
column-gap: 12px;
}
.gallery-item {
break-inside: avoid;
margin-bottom: 12px;
aspect-ratio: auto; /* Remove fixed ratio for masonry */
}
This stacks items into columns without fixed row heights, allowing each image to display at its natural aspect ratio while maintaining a clean columnar layout.
Infinite Scroll / Load More
For galleries with hundreds of images, load them in batches:
// Intersection Observer for infinite scroll
const sentinel = document.createElement('div');
sentinel.className = 'scroll-sentinel';
gallery.after(sentinel);
let page = 1;
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting) {
page++;
const newImages = await fetch(`/api/gallery?page=${page}`).then(r => r.json());
newImages.forEach(img => {
const item = document.createElement('div');
item.className = 'gallery-item';
item.innerHTML = `<img src="${img.thumb}" data-full="${img.full}" alt="${img.alt}" loading="lazy" width="400" height="300">`;
gallery.appendChild(item);
});
}
});
observer.observe(sentinel);
Category Filtering
Add data attributes to gallery items and filter with JavaScript:
<!-- Add category buttons -->
<div class="gallery-filters">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="nature">Nature</button>
<button class="filter-btn" data-filter="architecture">Architecture</button>
<button class="filter-btn" data-filter="portraits">Portraits</button>
</div>
<!-- Add data-category to items -->
<div class="gallery-item" data-category="nature">...</div>
// Filter functionality
document.querySelector('.gallery-filters').addEventListener('click', (e) => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
const filter = btn.dataset.filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.gallery-item').forEach(item => {
if (filter === 'all' || item.dataset.category === filter) {
item.style.display = '';
} else {
item.style.display = 'none';
}
});
});
Hosting Gallery Images
For a gallery to perform well, image hosting is critical. You need:
- Two versions per image: A thumbnail (400-600px wide, compressed) for the grid, and a full-resolution version for the lightbox
- CDN delivery: Images served from servers close to your visitors
- Permanent direct links: URLs that work reliably as
srcattributes - HTTPS: Required for mixed-content security on modern browsers
Workflow with ImgLink
- Prepare thumbnails: Resize images to 600px wide and compress for the grid
- Prepare full-size: Keep originals at full resolution (or resize to a reasonable max like 2400px wide)
- Upload both versions to ImgLink
- Use the thumbnail URL in
srcand the full-size URL indata-full
Every image gets a permanent direct link served via Cloudflare CDN. No hot-linking restrictions, no expiration — your gallery images will work indefinitely.
Performance Optimization
Image Sizing
| Version | Width | Format | Quality | Typical Size |
|---|---|---|---|---|
| Grid thumbnail | 600px | WebP | 80 | 30-60 KB |
| Lightbox full | 2400px | WebP | 85 | 200-400 KB |
Loading Strategy
- Initial load: Only the first 8-12 thumbnails load eagerly. The rest use
loading="lazy". - Lightbox images: Loaded on-demand when the user clicks a thumbnail. No wasted bandwidth.
- Preload adjacent: When the lightbox is open, preload the next and previous full-size images for instant navigation.
Accessibility Checklist
- Every
<img>has a descriptivealtattribute - The lightbox has
role="dialog"andaria-label - Close, previous, and next buttons have
aria-labelattributes - Keyboard navigation works (arrows, Escape, Tab)
- Focus is trapped within the lightbox when open
- Images have sufficient color contrast against their background
SEO for Image Galleries
Image galleries are excellent for SEO when properly optimized:
- Descriptive alt text: Every image should have unique, descriptive alt text — not "image-1" but "Golden Gate Bridge at sunset from Baker Beach"
- Image sitemap: Submit an image sitemap to Google Search Console listing all gallery images
- Schema markup: Add
ImageGallerystructured data for rich results - Page context: Surround the gallery with relevant text content — a gallery page with only images and no text context has limited ranking potential
- File names: Use descriptive file names (
golden-gate-sunset.webpnotIMG_4521.webp)
For more on image SEO, read our comprehensive guide on optimizing images for Google Search.
Apply This Workflow on ImgLink
ImgLink is built for the exact workflow covered in this guide: fast uploads, permanent direct links, Cloudflare CDN delivery, and no-signup sharing when you need to move quickly. If you want to turn the advice above into a repeatable publishing system, start with one canonical hosted image URL and reuse it across docs, posts, forums, and social channels.
Recommended Next Steps
Use these related resources to keep building the same workflow across adjacent image-hosting topics:
Need permanent image hosting?
Upload images with permanent direct links, fast CDN delivery, and no signup required. Use ImgLink for the workflows this guide discusses.
Comments
Related Posts
How to Resize Images Online for Free (No Software Needed)
Resize images to any dimension instantly in your browser. No software downloads, no signups, no watermarks. Perfect for social media, thumbnails, and web optimization.
Free Image Hosting for Discord, Reddit & Social Media
How to host and share images on Discord, Reddit, and social media platforms with free direct links that embed and display perfectly.
WebP vs JPEG vs PNG: Which Image Format Should You Use?
A practical comparison of WebP, JPEG, and PNG formats. Learn which one to use for photos, graphics, screenshots, and web optimization.