I recently refreshed the design of my personal site — partially because it felt a bit dated and partially so that I could explore a few new (to me) tricks. I wanted to replace older techniques like font icons, jQuery, and float-based grids with SVG icons, plain JavaScript, and CSS Grid. I also wanted to play around with modular type scales and a baseline grid, but I’ll save that for a future post.
Today I wanted to touch on how I built a small widget for my portfolio to filter projects based on my role. Because I often work as a hybrid, I tend to play different roles on different projects. I wanted to quickly show at a glance which projects involved which skills.
Getting the data first

In order to build the filter, I needed data. So in my CMS (Craft), I added a field for each portfolio entry to specify which skills that project employed. On the project page itself, this translates to a literal list of skills used near the top of the project.

On my portfolio home page, I added a data attribute (data-roles
) to each item in the list of projects. Each individual skill is separated from the others by a space in the attribute. (I’m using a shorthand name for the skill here that’s just a few characters.) This is the data I need to build my filter.
<li data-roles="ux fed rwd gfx dev write edit ia ">
<a href="URL">
<div>
<img src="IMG_THUMB" alt="ALT_TEXT">
<h5>PROJECT TITLE</h5>
</div>
</a>
</li>
The filter itself is a list of links at the top, each with a single attribute (data-role
) that corresponds to one of the data-roles
in the portfolio items below it.
<div class="folio-filter">
<h6><a href="#">Filter by role: <span id="filter-type" class="muted">All</span></a></h6>
<ul class="filter-list">
<li><a href="#" data-role="all" class="selected">All</a></li>
<li><a href="#" data-role="ux">UX</a></li>
<li><a href="#" data-role="fed">Front End</a></li>
<li><a href="#" data-role="rwd">Responsive</a></li>
<li><a href="#" data-role="gfx">Visual Design</a></li>
<li><a href="#" data-role="research">User Research</a></li>
<li><a href="#" data-role="dev">Full Stack</a></li>
<li><a href="#" data-role="ia">IA</a></li>
<li><a href="#" data-role="write">Writing</a></li>
</ul>
</div>
Making the filter work
Now that I had the data in place to build my filter, I needed to make it work using JavaScript. The basic gist of the script is that when someone clicks a link in the list of filters, it will grab the data-role
attribute for that link and look for any matching items in my portfolio with that role in their data-roles
attribute.
var folioFilter = document.querySelector('.folio-filter');
var filters = document.querySelectorAll('.filter-list li a');
var folio = document.querySelectorAll('.folio li');
if(folioFilter) {
filters.forEach(function(elem) {
elem.addEventListener('click', function(e) {
var filterRole = this.getAttribute('data-role');
var i = 125;
if(filterRole == "all") {
var selectedFolio = folio;
} else {
var selectedFolio = document.querySelectorAll("[data-roles*='"+ filterRole +"']");
}
filters.forEach(function(elem) {
elem.classList.remove('selected');
});
folio.forEach(function(elem) {
elem.classList.add('hidden');
});
selectedFolio.forEach(function(elem) {
setTimeout(function() {
elem.classList.remove('hidden');
}, i);
i = i + 125;
});
this.classList.add('selected');
e.preventDefault();
});
});
}
The script adds a class to hide each item in the portfolio list, then removes that class only on the matching elements. Why not add a class to only the matching elements and hide the rest? First, because then they’d all be hidden by default. But mostly, I wanted to have the selected elements animate in one after the other, and I found this was the best method to achieve that.
It also removes the selected
class from the list of filters, and adds that class back to the one that was clicked.
Finally, I use a setTimeout
function within the function that removes the hidden
class from all of the matching filtered items. This makes each item pop in one after the other instead of simultaneously. (A completely unnecessary but fun visual bonus.)
CSS Grid throws a hiccup
Because I wanted the elements to animate into place one after the other, I couldn’t use display: none
to hide the non-selected elements. (Display is a binary style. It is either on or off so there is no transition between the two states, thus no way to animate it.) Instead I decided to rely on changing the opacity
to 0 and the size (via transform: scale
) to practically nothing.
The downside of this is that the elements took up space in the Grid, even though they were effectively invisible and tiny. This wouldn’t happen with the old float method because the grid isn’t explicitly defined. The end result was that depending on which filter I selected, different holes would appear within my portfolio grid. This was less than ideal.
Fortunately, I was able to fix this with a single CSS definition within my hidden
class: order: 1
. This effectively pushes my hidden portfolio items to the end of the grid because the default order when it’s unspecified is 0 (which comes before 1). Here are the final styles for the .hidden
class and the list item without the class
.folio li {
opacity: 1;
transform: scale(1);
transition: opacity 0.25s ease-in-out, transform 0.25s ease-in-out;
}
.folio li.hidden {
opacity: 0;
order: 1;
overflow: hidden;
transform: scale(0);
}
And here’s what it looks like as a result.
Making it more accessible
If I left it at that, it works fine for sighted users. But if you try to navigate through using a screen reader and keyboard, you’ll notice that even the “hidden” links get read no matter what filters get applied. That’s because they’re effectively just transparent and scaled down.
To instruct screen readers to ignore the hidden links, I added another line in my JavaScript to place an aria-hidden
attribute in the list item and set it to true
. (I set the attribute to false
when I remove the hidden class.)
One issue I encountered in testing was that Voiceover on the Mac was still reading links within list items even with that attribute set. I’m not sure if this was because they were added by JavaScript or if it has buggy support. (I tried adding aria-hidden
to some of the other elements within as well, and Voiceover still seemed to find something to read every time.) I was able to solve the issue, though, by adding a little bit of CSS.
.folio li[aria-hidden="true"] a {
visibility: hidden;
}
This matches all the links within the hidden portfolio list items and sets their visibility
to hidden
. I chose this over display: none
so they will continue to occupy space, which keeps the page below my portfolio from bouncing up and down as each row animates out and back in.
Making the filter responsive
Finally while I could have left a list of links for my filter on mobile devices, it would have pushed the more important portfolio content further down the page. So instead, I decided to condense the list on mobile devices to a single item that can be expanded as a dropdown.

To do this, I added a few things:
- An extra span within the filter heading to tell which one is selected when the dropdown is closed.
- A bit to my JavaScript to update the above span and “open” or “close” the dropdown. This is handled by adding an
opened
class to the overarching filter container. - A pseudo-element in the CSS as an arrow that appears on the right side of the dropdown.
Bringing it all together
I’ve created a stripped down CodePen sample so you can play around with the concept and tweak it for your own uses. The fully stylized version is available on my portfolio page if you want to explore the other visual aspects of it.
Play on CodePen View my portfolio
In conclusion
With only nine items in my portfolio (I’ve retired older projects over time), this filter may seem like overkill. But I like that it showcases the breadth of my skills visually and helps visitors see which skills correlate to which projects at a glance.
Eventually I’m hoping to revisit this and tweak it a little. I’d love to append a hash for each filter to the URL when it is selected. Then I could detect when the page loads if that hash is added and automatically filter the list. (That way I could share a list of specific projects by filter.)