Written by Harry Roberts on CSS Wizardry.
Table of Contents
- The HTML
- The CSS
- Highlighting the Current Filter Link
- Awkward Page Jumps
- Drawbacks
What started off as a drunken excitable, 2am
experiment in ‘I
wonder if…’ has actually turned out to be quite a compelling and interesting bit
of functionality: filtering content using only CSS.
By using CSS’ :target
pseudo-selector, we can work out what a user has clicked
on, and by using the adjacent sibling selector, we can use that user’s action to
style subsequent parts of the DOM.
Take a look at the
demo.
The HTML
We start off by simply defining our filter context, .c-filter
. Everything is
going to happen inside of this section
.
Next, we list a collection of targets. We have one target per filter subject,
and they’re defined with a class each for styling, and a unique ID pertaining to
the topic. The IDs are what the :target
selector will be looking out for later
on: these are the backbone to the whole technique.
They’re defined as (empty) anchor elements, because that’s precisely what they
are: anchors for us to link to.
After this, we have our filtering links that the user will click on:
Each link has a class, and an href
pointing to its respective anchor. When a
user clicks one of these links, our :target
selector will intercept and behave
accordingly.
Following this, we have our list of items we’d like to filter. In this case,
they’re blog posts:
class="c-article c-filter__item" data-tag="vim">
Using Macros
Pellentesque habitant morbi tristique senectus et netus et malesuada
fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies
eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper.
Aenean ultricies mi vitae est. Mauris placerat eleifend leo.
The article
elements have a number of noteworthy things on them. Firstly, they
all have a class of c-article
that is completely separate to the filtering:
this class just carries styles specific to the articles and is not actually
required for the filtering to work.
Secondly, they all have a class of c-filter__item
: this simply lays the items
out in the filtering context and, again, isn’t actually required for the
filtering to take place.
Finally, we see a data-tags
attribute which is filled with one or more tags
pertaining to the content. These data attributes are necessary for the filtering
to work, because we use the values of them to decide what we need to show the
user.
The process will go a little like this:
- The page loads showing all items.
- The user clicks a filtering link which jumps to the correspoinding
#tags:*
anchor, appending a fragment identifier onto the URL. - We’ll grab the value of the fragment identifier by using the
:target
pseudo
selector. - We’ll use this knowledge to show/hide the relevant content.
The CSS
The majority of the work happens in the CSS. The first chunk isn’t really
aything exciting: it’s just cosmetic styling and laying out our items.
/**
* 1. Clearfix our filter context.
*/
.c-filter {
overflow: hidden; /* [1] */
}
.c-filter__links {
padding: 1em;
margin-bottom: 1em;
background-color: #fff;
border-bottom: 1px solid #e5e5e5;
}
.c-filter__link {
display: inline-block;
padding: 0.5em 1em;
border-radius: 100px;
background-color: rgba(0, 0, 0, 0);
transition: 0.333s;
}
.c-filter__link--reset {
border-right: 1px solid;
border-radius: 0;
}
.c-filter__item {
float: left;
width: calc(33.333333333% - 1em);
margin-right: 0.5em;
margin-bottom: 1em;
margin-left: 0.5em;
transition: 0.333s;
}
Now things start to get a little more interesting:
.c-filter__target:not(:target) ~ .c-filter__item {
opacity: 1;
}
This selector is doing a few things. Let’s break it down:
.c-filter__target
refers to all of our emptya
elements at once.:not(:target)
checks that none of the anchors are currently targeted.~
is the adjacent sibling selector, and that looks for things later in the
DOM but that are on the same level of the DOM tree..c-filter__item
is simply looking for all of our filterable items.
So basically, if we aren’t currently targeting any anchors, look ahead in the
DOM to find filter subjects and make sure they’re all turned on.
‘Turned on’ in this case is opacity: 1;
. This could be anything else (e.g. it
actually started off as display: block;
in my first version of this).
The next bit of CSS looks quite similar:
.c-filter__target:target ~ .c-filter__item {
opacity: 0.25;
}
We’re doing almost the exact same lookup as before, only this time we’re missing
the :not()
selector. What this means that we’re checking if any anchors are
targeted, and if so, turn all of our filterable items off (opacity: 0.25;
).
So two things here: if no anchors are targetted, turn everything on; if any
anchors are targetted, turn everything off.
After we’ve turned everything off, we want to turn the specific items back
on. We have to get a little more involved here, because we’re looking out for
specific DOM nodes: we can’t use a blanket rule.
I’m using Sass to help me out here, and that looks like this:
@each $tag in html, css, accessibility, performance, vim {
[id="tag:#{$tag}"]:target ~ [data-tag~="#{$tag}"] {
opacity: 1;
}
...
}
Here we have a loop that contains every one of my tags, and goes through them
all making selectors that look like this:
[id="tag:css"]:target ~ [data-tag~="css"] { ... }
What’s happening here?
[id="tag:css"]:target
sees if the anchor with an ID exactly equal to
tag:css
is currently targeted. N.B. I could have written this selector
as#tag\:css:target
but there are two reasons why I didn’t: firstly, as you
can see, we have to escape that colon, and secondly, we don’t use IDs in CSS.
Writing this as an attribute selectors means I don’t need to put any other
CSS-specific hooks in my HTML, and it has the added advantage of being able to
select an ID using a class worth of specificity.~
, as before, looks ahead in the DOM and finds certain elements on the same
level in the DOM tree.[data-tag~="css"]
is the element later on, on the same level of the DOM
tree, that the~
is looking for. What I’m doing here is using a substring
selector to interrogate the contents of thedata-tags
attribute. The~=
specifically means check that the attribute contains a list of space-separated
values, and that one of the values is exactly equal to (in this case)css
.
So if we’re targeting the css
anchor, please go ahead and find all elements
tagged with css
later in the DOM tree. Once you’ve found them, please turn
them on:
This is all that is really necessary to make the filtering work—it’s actually a
very small amount of code.
Highlighting the Current Filter Link
What I thought would be a nice touch would be to somehow style the currently
filtered link, so that the user has a visual cue as to what content they’re
looking at. This proved a little trickier than I was expecting, but was
certainly not insurmountable.
Inside of the same Sass loop as before, I’m generating a new suite of selectors
that each look like this:
[id="tag:css"]:target ~ .c-filter__links [href="#tag:css"] { ... }
Stepping through this one bit at a time:
[id="tag:css"]:target
checks to see if thecss
anchor is currently
targeted.~
looks ahead in the DOM..c-filter__links
is the thing we’re looking for.[href="#tag:css"]
looks for an element whosehref
attribute is exactly
equal to#tag:css
.
One thing I found interesting here was actually getting at the links. Because
they live inside of a nav
element (.c-filter__links
)—therefore are not on
the same level of the DOM tree, as required by the ~
selector—I couldn’t
target them directly with the ~
selector. I almost abandoned this whole
highlighting exercise because of that very fact, but then I realised a
workaround.
Instead of trying to target the links themselves, I can target their nav
element wrapper because that is on the same level of the DOM tree. The next
step was just a case of looking for the correct link inside of that. This is why
I had to nest the last bit of the selector.
Awkward Page Jumps
There was another problem I was having that I managed to solve pretty
painlessly. Because we’re linking to fragment identifiers, the page would
awkwardly jump around if the content was long enough to cause the page to
scroll. The solution was a one-line fix.
Because we’re linking to empty anchor elements, they’re not actually visible at
all in the page (but they do still exist at a point in the DOM, which is why we
have the page jumps). By simply applying position: fixed;
to all of the
anchors, they will always be positioned at the very top-left of the viewport.
The practical upshot of this is that the anchors will always be exactly where
the user has scrolled to, so the page didn’t actually have to jump anywhere.
.c-filter__target {
position: fixed;
}
Problem solved.
Drawbacks
There are a few drawbacks to this technique. In no particular order:
- Adding new tags means writing and recompiling new CSS. We could get around
this by having our CMS drop astyle
block into the page and looping over our
tags there, so it’s not completely the end of the world. - Filtering adds new history states. It could certainly get very annoying
clicking your back button a dozen times through filters. This does, however,
mean that we can link people directly to filtered results. - Accessibility. Honestly, I’m not sure how good or bad this is for
accessibility. I have no doubt that there will be some improvements we could
make. At least it isn’t using the checkbox hack, eh? - Should I have done this at all? It feels like it’s blurring the lines between
what CSS should be doing, and what we should defer to JavaScript. - Ugly CSS. Nested selectors, substring selectors, heavily location dependent.
It’s not the best, is it?
Still, it’s been quite a long time since I played around with CSS like this. and
it was a lot of fun. I need to make sure I do things like this more often.
Leave a Reply