In today’s tutorial, we’ll learn how to build a CSS-only filtering component, something which you’d be forgiven for thinking needs JavaScript. We’ll be using some simple markup, some form controls, and some really interesting CSS selectors that you may not have used before.
What We’re Working Towards
Each instructor here at Tuts+ has their own archive page. We’re going to recreate a tutorial list like this, using our own markup. Then, we’ll implement a component that will filter the posts based on the categories they belong to.
Here’s our final project:
Let’s get building!
1. Begin With the HTML Markup
We start by identifying the filter categories in our component. In this example, we’ll use seven filters:
All
CSS
JavaScript
jQuery
WordPress
Slider
fullPage.js
To do this, we first define seven radio buttons which we group under the categories
keyword. By default, the first radio button is checked:
1 |
<input type="radio" id="All" name="categories" value="All" checked> |
2 |
<input type="radio" id="CSS" name="categories" value="CSS"> |
3 |
<input type="radio" id="JavaScript" name="categories" value="JavaScript"> |
4 |
<input type="radio" id="jQuery" name="categories" value="jQuery"> |
5 |
<input type="radio" id="WordPress" name="categories" value="WordPress"> |
6 |
<input type="radio" id="Slider" name="categories" value="Slider"> |
7 |
<input type="radio" id="fullPage.js" name="categories" value="fullPage.js"> |
Then we create an ordered list which contains the labels related to the aforementioned radio buttons.
Keep in mind that we associate a radio button with a label by setting its id
value equal to the label’s for
value:
1 |
<ol class="filters"> |
2 |
<li>
|
3 |
<label for="All">All</label> |
4 |
</li>
|
5 |
<li>
|
6 |
<label for="CSS">CSS</label> |
7 |
</li>
|
8 |
<li>
|
9 |
<label for="JavaScript">JavaScript</label> |
10 |
</li>
|
11 |
<li>
|
12 |
<label for="jQuery">jQuery</label> |
13 |
</li>
|
14 |
<li>
|
15 |
<label for="WordPress">WordPress</label> |
16 |
</li>
|
17 |
<li>
|
18 |
<label for="Slider">Slider</label> |
19 |
</li>
|
20 |
<li>
|
21 |
<label for="fullPage.js">fullPage.js</label> |
22 |
</li>
|
23 |
</ol>
|
Next, we set up another ordered list which includes the elements we want to filter (our cards). Each of the filtered elements will have a custom data-category
attribute whose value is a whitespace-separated list of filters:
1 |
<ol class="posts"> |
2 |
<li class="post" data-category="CSS JavaScript">...</li> |
3 |
<li class="post" data-category="CSS JavaScript">...</li> |
4 |
|
5 |
<!-- 10 more list items here -->
|
6 |
</ol>
|
In our case, the filtered elements will be posts. So the markup we’ll use to describe a post along with its meta (title, image, categories) looks like this:
1 |
<article>
|
2 |
<figure>
|
3 |
<a href="" target="_blank"> |
4 |
<img src="IMG_SRC" alt=""> |
5 |
</a>
|
6 |
<figcaption>
|
7 |
<ol class="post-categories"> |
8 |
<li>
|
9 |
<a href="">...</a> |
10 |
<li>
|
11 |
|
12 |
<!-- possibly more list items here -->
|
13 |
</ol>
|
14 |
<h2 class="post-title"> |
15 |
<a href="" target="_blank">...</a> |
16 |
</h2>
|
17 |
</figcaption>
|
18 |
</figure>
|
19 |
</article>
|
With the markup ready, let’s turn our attention to the required styles.
2. Define the Styles
We first visually hide the radio buttons:
1 |
input[type="radio"] { |
2 |
position: absolute; |
3 |
left: -9999px; |
4 |
}
|
Then we add a few styles to the filters:
1 |
:root { |
2 |
--black: #1a1a1a; |
3 |
--white: #fff; |
4 |
--green: #49b293; |
5 |
}
|
6 |
|
7 |
.filters { |
8 |
text-align: center; |
9 |
margin-bottom: 2rem; |
10 |
}
|
11 |
|
12 |
.filters * { |
13 |
display: inline-block; |
14 |
}
|
15 |
|
16 |
.filters label { |
17 |
padding: 0.5rem 1rem; |
18 |
margin-bottom: 0.25rem; |
19 |
border-radius: 2rem; |
20 |
min-width: 50px; |
21 |
line-height: normal; |
22 |
cursor: pointer; |
23 |
transition: all 0.1s; |
24 |
}
|
25 |
|
26 |
.filters label:hover { |
27 |
background: var(--green); |
28 |
color: var(--white); |
29 |
}
|
CSS Grid Layout
We continue by specifying some styles for the filtered elements. Most importantly, we use CSS Grid to lay them out differently depending on the screen size:
1 |
:root { |
2 |
--black: #1a1a1a; |
3 |
--white: #fff; |
4 |
--green: #49b293; |
5 |
}
|
6 |
|
7 |
.posts { |
8 |
display: grid; |
9 |
grid-gap: 1.5rem; |
10 |
grid-template-columns: repeat(4, 1fr); |
11 |
}
|
12 |
|
13 |
.posts .post { |
14 |
background: #fafafa; |
15 |
border: 1px solid rgba(0, 0, 0, 0.1); |
16 |
}
|
17 |
|
18 |
.posts .post-title { |
19 |
font-size: 1.3rem; |
20 |
}
|
21 |
|
22 |
.posts .post-title:hover { |
23 |
text-decoration: underline; |
24 |
}
|
25 |
|
26 |
.posts figcaption { |
27 |
padding: 1rem; |
28 |
}
|
29 |
|
30 |
.posts .post-categories { |
31 |
margin-bottom: 0.75rem; |
32 |
font-size: .75rem; |
33 |
}
|
34 |
|
35 |
.posts .post-categories * { |
36 |
display: inline-block; |
37 |
}
|
38 |
|
39 |
.posts .post-categories li { |
40 |
margin-bottom: 0.2rem; |
41 |
}
|
42 |
|
43 |
.posts .post-categories a { |
44 |
padding: 0.2rem 0.5rem; |
45 |
border-radius: 1rem; |
46 |
border: 1px solid; |
47 |
line-height: normal; |
48 |
background: all 0.1s; |
49 |
}
|
50 |
|
51 |
.posts .post-categories a:hover { |
52 |
background: var(--green); |
53 |
color: var(--white); |
54 |
}
|
55 |
|
56 |
@media screen and (max-width: 900px) { |
57 |
.posts { |
58 |
grid-template-columns: repeat(3, 1fr); |
59 |
}
|
60 |
}
|
61 |
|
62 |
@media screen and (max-width: 650px) { |
63 |
.posts { |
64 |
grid-template-columns: repeat(2, 1fr); |
65 |
}
|
66 |
}
|
Note: For readability reasons, in our CSS we don’t group common CSS rules.
Adding the Filtering Styles
The idea here is surprisingly simple. Each time we click on a filter, only the corresponding filtered elements (posts) should appear. To implement that functionality, we’ll use a combination of the following CSS goodies:
When we click on the All
filter, all posts which have a data-category
attribute will appear:
1 |
[value="All"]:checked ~ .posts [data-category] { |
2 |
display: block; |
3 |
}
|
When we click on any other filter category, only the target posts will be visible:
1 |
[value="CSS"]:checked ~ .posts .post:not([data-category~="CSS"]), |
2 |
[value="JavaScript"]:checked ~ .posts .post:not([data-category~="JavaScript"]), |
3 |
[value="jQuery"]:checked ~ .posts .post:not([data-category~="jQuery"]), |
4 |
[value="WordPress"]:checked ~ .posts .post:not([data-category~="WordPress"]), |
5 |
[value="Slider"]:checked ~ .posts .post:not([data-category~="Slider"]), |
6 |
[value="fullPage.js"]:checked ~ .posts .post:not([data-category~="fullPage.js"]) { |
7 |
display: none; |
8 |
}
|
For example as long as we click on the Slider
filter category, only the posts that belong to the Slider
category will be visible.
It’s worth mentioning that in our styles above instead of the [att~=val]
syntax, we could equally have used the [att*=val]
syntax. Here’s what that subtle change would look like:
1 |
[value="CSS"]:checked ~ .posts .post:not([data-category*="CSS"]), |
2 |
[value="JavaScript"]:checked ~ .posts .post:not([data-category*="JavaScript"]), |
3 |
[value="jQuery"]:checked ~ .posts .post:not([data-category*="jQuery"]), |
4 |
[value="WordPress"]:checked ~ .posts .post:not([data-category*="WordPress"]), |
5 |
[value="Slider"]:checked ~ .posts .post:not([data-category*="Slider"]), |
6 |
[value="fullPage.js"]:checked ~ .posts .post:not([data-category*="fullPage.js"]) { |
7 |
display: none; |
8 |
}
|
Quick CSS Selector Explanation
What exactly is this selector saying?
The first bit [value="CSS"]:checked
looks for checked radio buttons with a specific value (“CSS” in this case).
After that, the tilde (~) is what we nowadays call the “subsequent-sibling selector”. It selects elements that have the same parent as the preceding element, even if they don’t immediately follow in the markup. So ~ .posts .post
looks for the element .posts .post
which shares the same parent as the checked radio input.
To get more specific still, :not([data-category~="CSS"])
refines our selector to only those .post
elements which do not have a data-category
attribute that contains a value of CSS
somewhere within a space-separated list.
It then applies a display: none;
to any elements which match those criteria.
It’s quite a complex selector, even though it’s perfectly logical. In human language terms you might describe it as:
“When the radio with a value of “CSS” is checked, find any subsequent-sibling elements that do not contain “CSS” in their data-category list, and hide them.”
Last Bit of Styling
As a last step, we add a rule which highlights the active filter category:
1 |
:root { |
2 |
--black: #1a1a1a; |
3 |
--white: #fff; |
4 |
--green: #49b293; |
5 |
}
|
6 |
|
7 |
[value="All"]:checked ~ .filters [for="All"], |
8 |
[value="CSS"]:checked ~ .filters [for="CSS"], |
9 |
[value="JavaScript"]:checked ~ .filters [for="JavaScript"], |
10 |
[value="jQuery"]:checked ~ .filters [for="jQuery"], |
11 |
[value="WordPress"]:checked ~ .filters [for="WordPress"], |
12 |
[value="Slider"]:checked ~ .filters [for="Slider"], |
13 |
[value="fullPage.js"]:checked ~ .filters [for="fullPage.js"] { |
14 |
background: var(--green); |
15 |
color: var(--white); |
16 |
}
|
3. Accessibility
This filtering is nicely accessible by default; thanks to the native way radio buttons and labels work we can filter our items with the keyboard keys. First press the Tab key to move focus to the checked radio button. Next press the Arrow keys to move focus and selection to the other radio buttons. Try it yourself:
That said, we haven’t paid any real attention to accessibility, so there might well be other a11y aspects that need improving.
4. Extra: Filtering With the :has()
Selector
Let’s now rebuild this CSS-only toggle component by taking advantage of the modern :has()
CSS pseudo-class. This flexible selector allows us to style elements that fulfil conditions defined inside the :has()
function. In this case, we won’t use the :not()
and ~
CSS selectors.
First, we hide all posts:
1 |
.posts .post { |
2 |
display: none; |
3 |
}
|
After that, we replace the filtering rules with these ones:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
*:has([value="All"]:checked) .filters [for="All"], |
4 |
*:has([value="CSS"]:checked) .filters [for="CSS"], |
5 |
*:has([value="JavaScript"]:checked) .filters [for="JavaScript"], |
6 |
*:has([value="jQuery"]:checked) .filters [for="jQuery"], |
7 |
*:has([value="WordPress"]:checked) .filters [for="WordPress"], |
8 |
*:has([value="Slider"]:checked) .filters [for="Slider"], |
9 |
*:has([value="fullPage.js"]:checked) .filters [for="fullPage.js"] { |
10 |
background: var(--green); |
11 |
color: var(--white); |
12 |
}
|
13 |
|
14 |
*:has([value="All"]:checked) .posts [data-category], |
15 |
*:has([value="CSS"]:checked) .posts [data-category~="CSS"], |
16 |
*:has([value="JavaScript"]:checked) .posts [data-category~="JavaScript"], |
17 |
*:has([value="jQuery"]:checked) .posts [data-category~="jQuery"], |
18 |
*:has([value="WordPress"]:checked) .posts [data-category~="WordPress"], |
19 |
*:has([value="Slider"]:checked) .posts [data-category~="Slider"], |
20 |
*:has([value="fullPage.js"]:checked) .posts [data-category~="fullPage.js"] { |
21 |
display: block; |
22 |
}
|
Let’s isolate one of them and translate it into human language terms.
Consider this one:
1 |
*:has([value="CSS"]:checked) .posts [data-category~="CSS"] { |
2 |
display: block; |
3 |
}
|
We can describe it like this:
Check if there’s any (parent) selector that includes a checked radio button with the “CSS“ value. If that’s the case, find all descendant posts whose “data-category“ attribute contains the “CSS“ keyword and display them.
Or like this:
Match and display the posts whose “data-category“ attribute contains the “CSS“ keyword and are children of any parent element that includes a checked radio button with the “CSS“ value.
Here’s the resulting demo, as a reminder:
Conclusion
That’s it, folks! With just a few CSS rules and some structured markup, we managed to build two variations of a fully functional CSS-based filtering component.
I hope you enjoyed this exercise and that it has helped you expand your understanding of CSS selectors.
As always, thanks for reading!