How to build a filtering component in pure CSS (2 methods)

How to build a filtering component in pure CSS (2 methods)


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:

This tutorial has been updated with another implementation that uses the new :has() selector ⬇️

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:

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
    class="filters">
2
  
  • 3
        
    
    4
      
    
    5
      
  • 6
        
    
    7
      
    
    8
      
  • 9
        
    
    10
      
    
    11
      
  • 12
        
    
    13
      
    
    14
      
  • 15
        
    
    16
      
    
    17
      
  • 18
        
    
    19
      
    
    20
      
  • 21
        
    
    22
      
    
    23
    
    

    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
      class="posts">
    2
     
  • class="post" data-category="CSS JavaScript">...
  • 3
     
  • class="post" data-category="CSS JavaScript">...
  • 4
     
    
    5
     
    
    6
    
    

    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:

    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 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!

    More on CSS Selectors



    Source link