A static grid of portfolio items looks clean on day one and dated by launch day. Visitors want to filter by category without a full page reload, and you want the layout to reflow gracefully rather than leave ugly gaps. The good news: combining Bootstrap 5’s grid with Isotope.js gives you a fully filterable portfolio in under 100 lines of HTML and a handful of JavaScript calls — no framework overhead, no build step required. This guide walks through every layer, from markup to filter buttons to smooth animations, so you can ship a production-quality filterable portfolio grid today.
Key Takeaways
- Structure portfolio items with Bootstrap 5 columns and
data-filterattributes for clean, semantic markup. - Initialise Isotope after the DOM is ready to avoid layout calculation errors with hidden elements.
- Use
imagesLoadedalongside Isotope to prevent grid gaps caused by images that load after layout calculation. - Filter buttons need only a single delegated click handler and one CSS class swap for active state.
- CSS transitions on
.portfolio-itemhandle the fade-and-scale animation without a separate animation library. - The Canvas HTML Template ships a pre-built Isotope portfolio you can adapt instead of starting from scratch.
Why Isotope Is Still the Right Choice for Bootstrap 5 Portfolio Filters
CSS-only filter techniques — toggling display:none or using the :checked hack — collapse the grid rather than reflow it. Isotope.js calculates item positions in JavaScript and applies CSS transforms, so items animate smoothly into their new positions without gaps. It also supports masonry layouts, fit-rows, and custom sort functions, all of which become valuable as a portfolio grows.
Isotope is maintained, MIT-licensed, and weighs roughly 30 KB minified. Paired with imagesLoaded (another lightweight helper from the same author), it handles the one edge case that trips up most implementations: images that finish loading after Isotope has already calculated grid positions.
If you are already reading about performance patterns, the post on how to lazy-load a Bootstrap 5 image gallery for speed pairs naturally with this technique — lazy loading thumbnails and Isotope filtering are not mutually exclusive, but they do require a specific initialisation order covered later in this article.

Building the HTML Structure
The grid needs two elements: a filter button group and the portfolio container. Each portfolio item carries a data-filter attribute matching the category slugs used by the buttons.
<!-- Filter buttons -->
<ul class="portfolio-filter" id="portfolio-filter">
<li class="active" data-filter="*">All</li>
<li data-filter=".branding">Branding</li>
<li data-filter=".web">Web</li>
<li data-filter=".motion">Motion</li>
</ul>
<!-- Portfolio grid -->
<div class="row g-4" id="portfolio-grid">
<div class="col-md-4 portfolio-item branding">
<div class="portfolio-card">
<img src="assets/img/portfolio/brand-01.jpg" alt="Brand identity project" loading="lazy">
<div class="portfolio-overlay">
<h5>Brand Identity</h5>
<span>Branding</span>
</div>
</div>
</div>
<div class="col-md-4 portfolio-item web">
<div class="portfolio-card">
<img src="assets/img/portfolio/web-01.jpg" alt="E-commerce website redesign" loading="lazy">
<div class="portfolio-overlay">
<h5>E-commerce Redesign</h5>
<span>Web</span>
</div>
</div>
</div>
<div class="col-md-4 portfolio-item motion branding">
<div class="portfolio-card">
<img src="assets/img/portfolio/motion-01.jpg" alt="Animated logo reveal" loading="lazy">
<div class="portfolio-overlay">
<h5>Logo Animation</h5>
<span>Motion · Branding</span>
</div>
</div>
</div>
</div>Two things to note: items can carry multiple category classes (see the motion branding item), and the loading="lazy" attribute is included on every image even though you will also use imagesLoaded — both can coexist.
CSS: Grid, Cards, and Transition Animations
Bootstrap 5’s .row and .col-* classes handle the base layout. Isotope overrides positioning with inline transforms, so the column classes act mainly as width declarations.
<style>
/ Filter buttons /
.portfolio-filter {
list-style: none;
padding: 0;
display: flex;
gap: .5rem;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.portfolio-filter li {
cursor: pointer;
padding: .4rem 1rem;
border: 1px solid #dee2e6;
border-radius: 2rem;
font-size: .875rem;
transition: background-color .2s, color .2s;
}
.portfolio-filter li.active,
.portfolio-filter li:hover {
background-color: var(--cnvs-themecolor, #1abc9c);
border-color: var(--cnvs-themecolor, #1abc9c);
color: #fff;
}
/ Card and overlay /
.portfolio-card {
position: relative;
overflow: hidden;
border-radius: .5rem;
}
.portfolio-card img {
width: 100%;
display: block;
transition: transform .4s ease;
}
.portfolio-card:hover img { transform: scale(1.06); }
.portfolio-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,.55);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
opacity: 0;
transition: opacity .3s ease;
}
.portfolio-card:hover .portfolio-overlay { opacity: 1; }
/ Isotope transition /
.portfolio-item {
transition: opacity .35s ease, transform .35s ease;
}
</style>The --cnvs-themecolor CSS custom property is the same variable the Canvas HTML Template exposes for its global accent colour, so active filter buttons will automatically match whatever brand colour you have set.

Initialising Isotope with imagesLoaded
Load Isotope and imagesLoaded from a CDN, then initialise after all images in the container have loaded. This single step eliminates the most common cause of grid gaps.
<!-- Dependencies -->
<script src="https://cdn.jsdelivr.net/npm/isotope-layout@3/dist/isotope.pkgd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/imagesloaded@5/imagesloaded.pkgd.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var grid = document.getElementById('portfolio-grid');
// Wait for all images before initialising Isotope
imagesLoaded(grid, function () {
var iso = new Isotope(grid, {
itemSelector: '.portfolio-item',
layoutMode: 'fitRows', // swap to 'masonry' for unequal heights
percentPosition: true
});
// Filter button clicks
var filterButtons = document.querySelectorAll('#portfolio-filter li');
filterButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
// Update active class
filterButtons.forEach(function (b) { b.classList.remove('active'); });
this.classList.add('active');
// Apply filter
var filterValue = this.getAttribute('data-filter');
iso.arrange({ filter: filterValue });
});
});
});
});
</script>Key decisions in this block: layoutMode: 'fitRows' keeps equal-height rows intact; switch to 'masonry' if your images have varying aspect ratios. percentPosition: true ensures item widths are calculated as percentages so the grid remains responsive at every breakpoint.
Linking Directly to a Filtered Category via URL Hash
A common client request is “link the Branding button from the homepage”. Read window.location.hash on page load and trigger the matching filter automatically.
<script>
// Inside the imagesLoaded callback, after iso is defined:
var hash = window.location.hash; // e.g. "#branding"
if (hash) {
var target = document.querySelector(
'#portfolio-filter li[data-filter=".' + hash.replace('#','') + '"]'
);
if (target) target.click();
}
</script>This pattern requires no extra library and plays nicely with browser back/forward navigation when combined with a hashchange listener.
Accessibility Considerations for Filter Controls
An unordered list of <li> elements is convenient but not inherently keyboard-navigable as a button group. A few targeted attributes close the gap without rewriting the component.
- Add
role="button"andtabindex="0"to each<li>so keyboard users can tab to them. - Bind a
keydownlistener that fires the click handler on Enter or Space. - Set
aria-pressed="true"on the active filter and"false"on all others; update on each click. - Add
aria-live="polite"to the grid container so screen readers announce when the item count changes.
These four changes move the component from functional to genuinely accessible with minimal code additions. If you are building out a full agency site with multiple interactive sections, the patterns described in 15 free Bootstrap 5 UI patterns every developer should know complement this filterable grid approach well.
Using Canvas Template’s Built-In Portfolio Components
If you are working inside the Canvas HTML Template, the portfolio pages already include Isotope initialisation inside functions.bundle.js. The markup convention uses data-filter-value attributes on grid items and data-filter on buttons, wired to a Canvas-specific initialisation block. You can adopt that convention directly rather than writing a custom init, which means your portfolio inherits Canvas’s global animation settings and theme colour without extra configuration.
Canvas also provides a dedicated portfolio demo with lightbox integration, lazy loading, and hover overlays — all toggled via data attributes rather than custom CSS. For performance-focused builds, pairing that with the guidance in lazy-loading a Bootstrap 5 image gallery keeps your Largest Contentful Paint score healthy even with dozens of full-bleed thumbnails.
Frequently Asked Questions
Isotope does not support CSS Grid layout mode — it relies on absolute positioning and transforms. Use Bootstrap’s .row/.col-* classes for width, but be aware that Isotope will override position on the items themselves. The column classes still apply the correct padding gutters, so the visual result is consistent.
fitRows places items left to right and starts a new row only when there is no horizontal space remaining, keeping rows at a uniform height. Masonry packs items into the shortest column each time, producing a Pinterest-style layout with varied row heights. Use fitRows for equal-aspect-ratio thumbnails and masonry for mixed content.
After Isotope is initialised, query the DOM for items matching each filter class and inject a <span> with the count: document.querySelectorAll('.portfolio-item.branding').length. Update the count after each filter call using Isotope’s arrangeComplete event if you want it to reflect the currently visible items.
It can if you rely on loading="lazy" alone. Images that load after Isotope calculates positions create gaps because their dimensions were unknown at init time. The fix is to always wrap your Isotope initialisation inside an imagesLoaded callback, which waits for all images — including lazily loaded ones that have entered the viewport — before running the layout calculation.
Isotope applies opacity: 0 and moves hidden items off-screen using transforms. Add a transition rule to your .portfolio-item selector covering both opacity and transform, as shown in the CSS section above. Isotope’s own transition duration can be controlled via the transitionDuration option in the init object, for example transitionDuration: '0.4s'.
Looking for a production-ready Bootstrap 5 HTML template? Browse Canvas Template demos and find the perfect starting point for your next project.
If you’re building with the Canvas HTML Template and want to ship production-ready Bootstrap 5 layouts faster, try Canvas Builder free — the visual builder that exports clean Canvas-ready markup in minutes.
Skip the setup — build it free
Spin up a complete Bootstrap 5 site, blog included, with Canvas Builder. No coding, no cost.
Canvas Team
Tutorials and tips for building beautiful Bootstrap 5 websites with the Canvas HTML Template and Canvas Builder.
More from the Canvas Blog