How to Create a Sortable and Filterable Image Gallery With JavaScript

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.

photo gallery grid

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)

photo gallery grid

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):

  1. First: record each item’s current bounding rect.
  2. Last: reorder the DOM, then read the new rects.
  3. Invert: apply a transform that moves each item back to where it started.
  4. 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';
      });
    });
  });
}
photo gallery grid

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: auto and scroll-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
photo gallery grid

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-pressed on the active filter button.
  • Provide meaningful alt text 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.

Recent Posts

No Posts Found!

Categories

Tags

    Subscribe

    You have been successfully Subscribed! Ops! Something went wrong, please try again.

    About Us

    Express Jam Studio was founded in 2004 by John Smith. John had previously worked for a courier company, but he saw an opportunity to start his own business in the web design and development industry.

    Contact Info

    Copyright © 2022 Express Jam Studio. All Rights Reserved.