If you have ever wanted to showcase your creative work online without relying on bulky third-party plugins, building a custom interactive portfolio gallery with vanilla JavaScript is one of the most rewarding projects you can tackle. Unlike pre-made gallery widgets, a handcrafted gallery gives you complete control over performance, styling, and behavior. In this step-by-step beginner tutorial, you will build a fully responsive portfolio gallery featuring category filtering, a smooth lightbox viewer, and subtle entrance animations, all using nothing more than HTML, CSS, and plain JavaScript.
Why Build a Portfolio Gallery From Scratch?
Third-party gallery libraries are convenient, but they often ship with hundreds of kilobytes of unused code, restrictive styling, and dependencies that conflict with modern build tools. Writing your own gallery forces you to understand the underlying mechanics of DOM manipulation, event handling, and the CSS transition API. You end up with cleaner markup, faster load times, and a component you can confidently extend with features like lazy loading, drag-to-reorder, or even WebGL transitions later on. More importantly, you own the code, so adapting it for client work, freelance projects, or your own personal brand becomes effortless.
By the end of this guide, you will have a working gallery that filters items by category, opens images in an animated lightbox, and reveals thumbnails with a soft fade-in effect as they enter the viewport.
Setting Up the HTML Structure
Every great gallery starts with semantic markup. For this tutorial, the structure includes a filter bar, a grid container, and a hidden modal that becomes the lightbox when triggered.
<header class="gallery-header">
<h1>Selected Works</h1>
<nav class="filters" data-filter-group>
<button class="filter-btn is-active" data-filter="all">All</button>
<button class="filter-btn" data-filter="branding">Branding</button>
<button class="filter-btn" data-filter="web">Web</button>
<button class="filter-btn" data-filter="print">Print</button>
</nav>
</header>
<main class="gallery-grid" id="gallery">
<figure class="gallery-item" data-category="branding">
<img src="img/project-01.jpg" alt="Brand identity mockup" loading="lazy">
<figcaption>Aurora Brand Identity</figcaption>
</figure>
<!-- more items -->
</main>
<div class="lightbox" id="lightbox" aria-hidden="true">
<button class="lightbox-close" aria-label="Close lightbox">×</button>
<img class="lightbox-image" src="" alt="">
<p class="lightbox-caption"></p>
<button class="lightbox-prev" aria-label="Previous image">←</button>
<button class="lightbox-next" aria-label="Next image">→</button>
</div>
Each gallery item uses a data-category attribute, which is the key to the filtering logic. The loading="lazy" attribute keeps the initial page load fast by deferring off-screen images, a small but impactful performance win.
Styling the Gallery With Modern CSS
Before diving into JavaScript, lay down the visual foundation. CSS Grid is ideal for the thumbnail layout because it handles responsive columns without media query gymnastics.
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.25rem;
padding: 2rem;
}
.gallery-item {
position: relative;
overflow: hidden;
border-radius: 12px;
cursor: zoom-in;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.gallery-item.is-visible {
opacity: 1;
transform: translateY(0);
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
}
.gallery-item:hover img {
transform: scale(1.05);
}
The opacity: 0 and translateY(20px) declarations set the initial state for the entrance animation. Once the JavaScript adds an is-visible class, the item gracefully fades into place.
Designing the Lightbox Overlay
The lightbox needs a dark backdrop, a centered image, and controls that fade in only when the modal is active.
.lightbox {
position: fixed;
inset: 0;
background: rgba(10, 10, 10, 0.92);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.lightbox.is-open {
opacity: 1;
pointer-events: auto;
}
.lightbox-image {
max-width: 90vw;
max-height: 85vh;
border-radius: 8px;
transform: scale(0.94);
transition: transform 0.4s ease;
}
.lightbox.is-open .lightbox-image {
transform: scale(1);
}
Adding Category Filtering With JavaScript
Filtering is the first interactive behavior. The logic listens for clicks on the filter buttons, then hides every item whose data-category does not match the selected filter.
const filterButtons = document.querySelectorAll('.filter-btn');
const galleryItems = document.querySelectorAll('.gallery-item');
filterButtons.forEach(button => {
button.addEventListener('click', () => {
const filter = button.dataset.filter;
filterButtons.forEach(btn => btn.classList.toggle('is-active', btn === button));
galleryItems.forEach(item => {
const matches = filter === 'all' || item.dataset.category === filter;
item.classList.toggle('is-hidden', !matches);
});
});
});
Pair this with a tiny CSS rule, .is-hidden { display: none; }, and you have an instant filtering system. To avoid layout jumps, you can extend this with the FLIP animation technique, measuring positions before and after the DOM update and animating the difference.
Building the Lightbox Behavior
The lightbox needs three things: open on click, close on button or backdrop click, and navigate between images with arrow keys or buttons.
const lightbox = document.getElementById('lightbox');
const lightboxImage = lightbox.querySelector('.lightbox-image');
const lightboxCaption = lightbox.querySelector('.lightbox-caption');
let currentIndex = 0;
let visibleItems = [];
const refreshVisibleItems = () => {
visibleItems = Array.from(galleryItems).filter(item => !item.classList.contains('is-hidden'));
};
const showImage = index => {
const item = visibleItems[index];
if (!item) return;
const img = item.querySelector('img');
lightboxImage.src = img.src;
lightboxImage.alt = img.alt;
lightboxCaption.textContent = item.querySelector('figcaption').textContent;
currentIndex = index;
};
const openLightbox = index => {
refreshVisibleItems();
showImage(index);
lightbox.classList.add('is-open');
lightbox.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
};
const closeLightbox = () => {
lightbox.classList.remove('is-open');
lightbox.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
};
galleryItems.forEach((item, index) => {
item.addEventListener('click', () => openLightbox(index));
});
lightbox.querySelector('.lightbox-close').addEventListener('click', closeLightbox);
lightbox.addEventListener('click', e => {
if (e.target === lightbox) closeLightbox();
});
document.addEventListener('keydown', e => {
if (!lightbox.classList.contains('is-open')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowRight') showImage((currentIndex + 1) % visibleItems.length);
if (e.key === 'ArrowLeft') showImage((currentIndex - 1 + visibleItems.length) % visibleItems.length);
});
This implementation respects keyboard accessibility, prevents background scrolling, and automatically tracks which items are currently visible so that filtering and lightbox navigation stay in sync.
Animating Items Into View With the Intersection Observer
Manually calculating scroll positions is tedious and brittle. The IntersectionObserver API handles this beautifully, firing a callback whenever an element enters or leaves the viewport.
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15 });
galleryItems.forEach(item => observer.observe(item));
Because the observer unobserves each item after its first reveal, performance stays excellent even on galleries with dozens of thumbnails. Combine this with a staggered transition-delay calculated from each item’s index, and you get a delightful cascading entrance effect.
Performance and Accessibility Considerations
Beyond the obvious visual polish, a production-ready gallery should respect a few additional constraints. Always include descriptive alt text for every image, since search engines and screen readers rely on it. Trap focus inside the lightbox while it is open so keyboard users cannot accidentally tab into the background content. Use prefers-reduced-motion media queries to disable or shorten animations for users who request calmer interfaces. Finally, consider replacing GIF-style thumbnails with AVIF or WebP images to drastically reduce payload size without sacrificing visual quality.
Where to Take the Gallery Next
The foundation you just built is intentionally minimal but surprisingly extensible. You could add a masonry layout using CSS columns, integrate a search input that filters by caption text, persist the active filter in the URL hash so visitors can share filtered views, or swap the static thumbnails for short looping video clips. Each of these features builds directly on the same DOM patterns covered above, which is the real value of building from scratch: every new behavior is just a few lines of JavaScript bolted onto a structure you already understand.
Once you finish your first custom interactive portfolio gallery with vanilla JavaScript, you will find yourself reusing the same patterns, filtering logic, lightbox controller, and observer-driven reveal, in dashboards, documentation sites, and product showcases. The investment pays dividends far beyond a single portfolio page.
