Bootstrap Masonry Image Gallery — Free HTML CSS JS Snippet
Bootstrap Masonry Image Gallery · Media · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="container py-5">
<div class="btn-group mb-4" role="group" id="bsmgFilter">
<button type="button" class="btn btn-outline-dark active" data-cat="all">All</button>
<button type="button" class="btn btn-outline-dark" data-cat="nature">Nature</button>
<button type="button" class="btn btn-outline-dark" data-cat="city">City</button>
<button type="button" class="btn btn-outline-dark" data-cat="people">People</button>
</div>
<div class="bsmg-masonry" id="bsmgGrid">
<img src="https://picsum.photos/id/1015/500/650" data-cat="nature" class="bsmg-img" alt="Mountain river">
<img src="https://picsum.photos/id/1011/500/400" data-cat="people" class="bsmg-img" alt="Portrait">
<img src="https://picsum.photos/id/1016/500/750" data-cat="city" class="bsmg-img" alt="City skyline">
<img src="https://picsum.photos/id/1025/500/500" data-cat="people" class="bsmg-img" alt="Dog portrait">
<img src="https://picsum.photos/id/1043/500/620" data-cat="nature" class="bsmg-img" alt="Field">
<img src="https://picsum.photos/id/1031/500/450" data-cat="city" class="bsmg-img" alt="Street">
<img src="https://picsum.photos/id/1035/500/700" data-cat="nature" class="bsmg-img" alt="Forest">
<img src="https://picsum.photos/id/1027/500/500" data-cat="people" class="bsmg-img" alt="Crowd">
<img src="https://picsum.photos/id/1044/500/580" data-cat="city" class="bsmg-img" alt="Bridge">
</div>
</div>
<div class="modal fade" id="bsmgModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content bg-dark">
<div class="modal-body p-0 text-center position-relative">
<img id="bsmgModalImg" src="" class="img-fluid w-100" alt="Enlarged view">
<button class="btn btn-light bsmg-nav bsmg-prev" id="bsmgPrev">«</button>
<button class="btn btn-light bsmg-nav bsmg-next" id="bsmgNext">»</button>
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-2" data-bs-dismiss="modal"></button>
</div>
</div>
</div>
</div>.bsmg-masonry { column-count: 3; column-gap: 0.75rem; }
.bsmg-img { width: 100%; display: block; margin-bottom: 0.75rem; border-radius: 8px; cursor: pointer; break-inside: avoid; }
.bsmg-img.bsmg-hidden { display: none; }
@media (max-width: 768px) { .bsmg-masonry { column-count: 2; } }
@media (max-width: 480px) { .bsmg-masonry { column-count: 1; } }
.bsmg-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
opacity: 0.85;
}
.bsmg-prev { left: 12px; }
.bsmg-next { right: 12px; }const filterButtons = Array.from(document.querySelectorAll('#bsmgFilter button'));
const allImages = Array.from(document.querySelectorAll('.bsmg-img'));
let currentSet = allImages;
let currentIndex = 0;
const modalEl = document.getElementById('bsmgModal');
const modal = new bootstrap.Modal(modalEl);
const modalImg = document.getElementById('bsmgModalImg');
function visibleImages() {
return allImages.filter(img => !img.classList.contains('bsmg-hidden'));
}
function applyFilter(cat) {
allImages.forEach(img => {
const matches = cat === 'all' || img.dataset.cat === cat;
img.classList.toggle('bsmg-hidden', !matches);
});
}
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
filterButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyFilter(btn.dataset.cat);
});
});
function openModal(img) {
// The Prev/Next set is always recomputed from whatever is currently
// visible, so cycling only ever moves within the active filtered set.
currentSet = visibleImages();
currentIndex = currentSet.indexOf(img);
modalImg.src = img.src;
modal.show();
}
function showAt(index) {
const len = currentSet.length;
currentIndex = (index + len) % len;
modalImg.src = currentSet[currentIndex].src;
}
allImages.forEach(img => {
img.addEventListener('click', () => openModal(img));
});
document.getElementById('bsmgPrev').addEventListener('click', () => showAt(currentIndex - 1));
document.getElementById('bsmgNext').addEventListener('click', () => showAt(currentIndex + 1));
modalEl.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') showAt(currentIndex - 1);
if (e.key === 'ArrowRight') showAt(currentIndex + 1);
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Bootstrap Masonry Image Gallery — HTML, CSS & JavaScript

A masonry layout needs images of different heights to flow into balanced columns without leftover gaps, and the simplest way to get that without a JavaScript layout library is CSS multi-column layout: .bsmg-masonry sets column-count: 3 and column-gap: 0.75rem, and each .bsmg-img gets break-inside: avoid so the browser's own column-balancing algorithm never slices an image in half across a column break. Because column layout — unlike CSS Grid — flows items down each column before wrapping to the next, images of varying aspect ratios naturally interlock instead of leaving the ragged same-height rows a plain row/col grid would produce.
Category filtering works the same way as a typical Bootstrap btn-group filter: each image carries its category in a data-cat attribute, and clicking a filter button calls applyFilter(cat), which toggles a bsmg-hidden class (display: none) on every image that doesn't match, while also moving Bootstrap's active class onto only the clicked button. Because column layout reflows automatically as elements are hidden, filtering the gallery re-balances the remaining images into fresh columns with no manual re-layout code needed.
The lightbox is a real Bootstrap modal component, instantiated once with new bootstrap.Modal(modalEl) and reused for every image rather than creating a new modal instance per click. The detail that makes the Prev/Next cycling correct is in openModal(img): it calls visibleImages() — which filters allImages down to whatever currently lacks bsmg-hidden — and stores that as currentSet at the moment the modal opens, then finds the clicked image's position in it with indexOf. This is the specific edge case the requirements call out: if the currently applied filter is "Nature", Prev/Next inside the modal must only step through the nature photos, not the full nine-image gallery, and recomputing currentSet fresh on every open (rather than caching it once globally) means switching filters and reopening the modal always cycles the correct, currently-filtered subset.
showAt(index) wraps the index with (index + len) % len, so clicking Prev on the first image or Next on the last one loops around to the opposite end instead of getting stuck, and the same navigation is also wired to the Left/Right arrow keys while the modal is open.
The modal's dark bg-dark content area and btn-close-white close button are deliberate choices rather than defaults: a lightbox showing a photograph reads much better against a near-black background than Bootstrap's default white modal, since a white surround competes visually with the photo's own edges and colors, and the inverted close-button variant keeps that control legible against the dark background instead of disappearing into it.
Step by step
How to Use
- 1Load the snippetNine images of varying heights appear arranged into three flowing masonry columns with no gaps.
- 2Click "Nature"The grid re-balances to show only the nature photos, flowing into columns without leftover empty space.
- 3Click any visible imageA real Bootstrap modal opens showing that image enlarged, with Prev/Next arrow buttons.
- 4Click Next repeatedlyThe modal cycles only through the nature photos currently shown, wrapping back to the first after the last.
- 5Close the modal and click "All"The full nine-image gallery reappears, re-flowed into masonry columns.
- 6Reopen an image and use arrow keysLeft and Right arrow keys navigate the modal the same way the Prev/Next buttons do.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Yes. Keep the image list and active category in component state, derive the visible array with a filter before rendering, and store the modal open state and current index in the same state object; the CSS column-count masonry and modal markup need no changes, only the class-toggling logic moves into conditional class bindings.
CSS multi-column layout (column-count) automatically distributes block-level children down each column in source order, and because images have naturally varying heights, this produces the balanced, gap-free masonry look without any manual position calculation.
openModal() recomputes visibleImages() — the current non-hidden image set — at the exact moment the modal opens and stores it as currentSet, so all subsequent Prev/Next clicks index into that filtered array rather than the full unfiltered gallery.
The break-inside: avoid CSS rule on each .bsmg-img tells the browser's column-balancing algorithm to move a whole image to the next column rather than splitting it across a column boundary.
Yes — replace the btn-group and modal with Tailwind's own button group styling and a hand-built dialog (or Bootstrap's bundled JS just for the modal component), while keeping the column-count masonry CSS and the filtering/navigation JavaScript exactly as is, since neither depends on Bootstrap-specific behavior beyond the Modal API.
Yes — add another img.bsmg-img element with a new data-cat value and a matching filter button with the same data-cat value; both the masonry layout and the filtering logic read that attribute directly, requiring no other code changes.