A filterable image gallery in JavaScript is one of those UI patterns that looks simple on the surface but hides a lot of small decisions: how to structure your tags, how to animate transitions, how to keep it accessible on mobile, and how to add sorting without pulling in a heavy library.
In this tutorial we will build one from scratch using vanilla JavaScript, CSS Grid and a few modern browser features. No jQuery, no Isotope, no framework. By the end you will have a gallery that filters by category, sorts by date or name, animates smoothly, and works well on phones.
What we are building
- A responsive image grid powered by CSS Grid
- Category filter buttons (with support for multiple tags per image)
- A sort dropdown (newest, oldest, alphabetical)
- Smooth fade and scale transitions when items enter or leave
- A mobile-friendly layout with touch-safe controls
Unlike the classic W3Schools portfolio gallery, this version uses data attributes for tags, supports combined filters, and adds sorting, which most tutorials skip.

Step 1: The HTML structure
We start with a clean semantic structure. Each image card carries its metadata inside data attributes, which is what makes filtering and sorting trivial later.
<div class="gallery-controls">
<div class="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="city">City</button>
<button class="filter-btn" data-filter="people">People</button>
<button class="filter-btn" data-filter="food">Food</button>
</div>
<select id="sort" class="sort-select">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="az">A to Z</option>
<option value="za">Z to A</option>
</select>
</div>
<div class="gallery" id="gallery">
<figure class="item" data-tags="nature" data-date="2026-03-14" data-title="Forest">
<img src="forest.jpg" alt="Forest">
<figcaption>Forest</figcaption>
</figure>
<figure class="item" data-tags="city people" data-date="2026-05-02" data-title="Street">
<img src="street.jpg" alt="Street">
<figcaption>Street</figcaption>
</figure>
<!-- more items -->
</div>
Notice how data-tags can hold multiple values separated by spaces. That is the key to letting one image belong to several categories.
Step 2: The CSS Grid layout
CSS Grid handles the responsive layout with a single line. No media queries required for the basic flow.
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.item {
margin: 0;
overflow: hidden;
border-radius: 12px;
background: #111;
position: relative;
transition: transform .35s ease, opacity .35s ease;
}
.item img {
width: 100%;
height: 220px;
object-fit: cover;
display: block;
}
.item.hide {
opacity: 0;
transform: scale(.85);
pointer-events: none;
position: absolute;
width: 0;
height: 0;
}
.filter-btn {
padding: 8px 16px;
border-radius: 999px;
border: 1px solid #ddd;
background: #fff;
cursor: pointer;
}
.filter-btn.active {
background: #111;
color: #fff;
}
The .hide class is what animates items out. We do not use display: none because it kills transitions. (via https://dev.to)

Step 3: The filtering logic in vanilla JavaScript
Here is the core filter function. It reads the active filter, checks each item’s tag list, and toggles the hide class.
const gallery = document.getElementById('gallery');
const items = Array.from(gallery.querySelectorAll('.item'));
const buttons = document.querySelectorAll('.filter-btn');
let currentFilter = 'all';
function applyFilter(filter) {
currentFilter = filter;
items.forEach(item => {
const tags = item.dataset.tags.split(' ');
const match = filter === 'all' || tags.includes(filter);
item.classList.toggle('hide', !match);
});
}
buttons.forEach(btn => {
btn.addEventListener('click', () => {
buttons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyFilter(btn.dataset.filter);
});
});
Step 4: Adding the sort feature
Sorting is where most tutorials stop short. We simply reorder the DOM nodes based on the selected criteria.
const sortSelect = document.getElementById('sort');
function applySort(mode) {
const sorted = [...items].sort((a, b) => {
if (mode === 'newest') return b.dataset.date.localeCompare(a.dataset.date);
if (mode === 'oldest') return a.dataset.date.localeCompare(b.dataset.date);
if (mode === 'az') return a.dataset.title.localeCompare(b.dataset.title);
if (mode === 'za') return b.dataset.title.localeCompare(a.dataset.title);
});
sorted.forEach(node => gallery.appendChild(node));
}
sortSelect.addEventListener('change', e => applySort(e.target.value));
Because CSS Grid respects source order, re-appending the nodes is enough. No manual positioning needed.
Step 5: Smooth transitions with the FLIP technique
If you want the items to animate to their new positions when sorting, use the FLIP pattern (First, Last, Invert, Play):
- First: record each item’s current bounding rect.
- Last: reorder the DOM, then read the new rects.
- Invert: apply a transform that moves each item back to where it started.
- Play: remove the transform on the next frame so CSS transitions animate it to place.
function flipSort(mode) {
const firstRects = new Map(items.map(i => [i, i.getBoundingClientRect()]));
applySort(mode);
requestAnimationFrame(() => {
items.forEach(item => {
const first = firstRects.get(item);
const last = item.getBoundingClientRect();
const dx = first.left - last.left;
const dy = first.top - last.top;
item.style.transform = `translate(${dx}px, ${dy}px)`;
item.style.transition = 'none';
requestAnimationFrame(() => {
item.style.transform = '';
item.style.transition = 'transform .4s ease';
});
});
});
}

Step 6: Mobile-friendly interactions
Phones need a bit of extra care. Here are the tweaks that make a real difference:
- Make filter buttons at least 44 by 44 pixels to meet touch target guidelines.
- Allow horizontal scroll on the filter bar with
overflow-x: autoandscroll-snap-type: x mandatory. - Use
loading="lazy"on images to save bandwidth. - Add
decoding="async"so the browser can decode images off the main thread.
.filters {
display: flex;
gap: 8px;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 8px;
}
.filter-btn { scroll-snap-align: start; min-height: 44px; }
Comparison with common approaches
| Approach | Weight | Multi-tag | Sort | Animation |
|---|---|---|---|---|
| W3Schools portfolio | Light | Limited | No | Basic |
| Isotope.js | Heavy | Yes | Yes | Advanced |
| This tutorial | Zero deps | Yes | Yes | FLIP smooth |

Bonus: combining filter and sort
Because filtering and sorting are separate functions that both operate on the same node list, combining them is a one-liner:
function refresh() {
applyFilter(currentFilter);
applySort(sortSelect.value);
}
Call refresh() whenever the user changes either control.
Accessibility checklist
- Use real
<button>elements for filters, not divs. - Add
aria-pressedon the active filter button. - Provide meaningful
alttext on every image. - Ensure focus outlines remain visible on keyboard navigation.
FAQ
Do I need a library like Isotope or MixItUp?
No. For most portfolios and product grids, vanilla JavaScript with CSS Grid is enough and ships zero kilobytes of dependencies. mozilla.org has a solid rundown on this.
How do I support multiple filters at once?
Turn filter buttons into toggles and keep an array of active filters. Then in applyFilter check whether the item’s tags include every selected filter.
Can I load images dynamically from an API?
Yes. Fetch the JSON, build the figure elements with the same data-tags, data-date and data-title attributes, append them to the gallery, and refresh your items array.
Why not use display: none for hidden items?
Because display: none removes the element from the render tree instantly, killing any CSS transition. Using opacity plus transform gives you a real animation.
Does this work with lazy loading?
Yes. Native loading="lazy" plays well with the pattern. Images that are filtered out but not yet loaded will simply not fetch until they become visible again.
Wrapping up
You now have a complete filterable image gallery in JavaScript with sorting, smooth animations and mobile support, in less than 100 lines of code. Extend it with a search box, a lightbox, or lazy pagination and you have a production-ready component that beats most plugin-based solutions in performance and control.
