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 which you may not have used before.

What We’re Working Towards

Each instructor here at Tuts+ has his or her 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:

  1. All
  2. CSS
  3. JavaScript
  4. jQuery
  5. WordPress
  6. Slider
  7. 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.

CSS-only filtering component in actionCSS-only filtering component in actionCSS-only filtering component in action
CSS-only filtering component in action

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 which 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 which 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 which 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 which need improving.

Conclusion

That’s it folks! With just a few CSS rules and some structured markup, we managed to build a fully functional 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!

More on CSS Selectors

©2024 SIRRONA Media, LLC.  All Rights Reserved.  

Log in with your credentials

Forgot your details?