18 March 2023

Abusing CSS :before pseudo-element

TL;DR

I’ve encountered a :before element in CSS for a switch/slider that made no sense. There was nothing “before” the switch. Now I understand what it was used for, but I don’t like the particular use case. As a bonus - I got a dark/light theme toggle for this site.

The story

Having not been hands-on with web development since 2019, I recently decided to implement a dark/light theme switch for my website (this one, ejiek.id). I wanted the switch to look aesthetically pleasing, meaning a simple checkbox wouldn’t cut it. A quick search for a CSS switch led me to a w3schools example:

Toggle them ☝️

Looks good, works well. However, there are some questions to the implementation. In this post, I go over the problems, possible solutions, and improvements.

Let’s start analyzing the HTML part:

<label class="switch">
  <input type="checkbox">
  <span class="slider"></span>
</label>

Each slider has only two visual elements: a switch body and a knob. However, there are three HTML elements describing it:

  • label holds everything inside and provides click handling for the input
  • checkbox stores the on/off state and is hidden
  • span creates the visual representation of ⚠️ both the switch and its knob.

We need to stylize and dynamically alter both the slider body and the knob. While span works well for the slider body, there is nothing left for the knob. Aha! This is where the :before pseudo-element comes into play.

:before is a pseudo-element intended to define a visual element that appears before its parent element. Its counterpart, :after, works similarly. The MDN documentation provides a good example of how :before is meant to be used - decorating a link.

The following CSS makes all your links go from ejiek.id to ejiek.id

a::before {
    content: '🔗';
}

I can understand this :before use case. It’s easy to reason about - :before is actually before. This visual enhancement doesn’t require any modification to the source (HTML). Easy! The slider solution, on the other hand, requires extra HTML anyway, and :before is semantically broken.

Going back to the slider, a straightforward approach would be to create two meaningful HTML elements and manipulate them:

<span class="slider">
  <span class="knob"></span>
</span>

I’ve seen a knob positioned next to the slider, but I find the nesting variant more representative of the final result. Hence, easier to understand and maintain. This restructure eliminates one of the perks of using :before. In the original version, the knob in an “on” position is defined like this:

input:checked + .slider:before {
  /* some knob manipulation */
}

It uses + (adjacent sibling combinator) which works for two elements that are right next to each other. Both slider and slider:before are tied to the same HTML element, so they have the same neighbors! Our new knob with a separate HTML element isn’t next to a checkbox because it’s hidden in a slider span. So the following doesn’t work:

input:checked + .knob {
  /* some knob manipulation */
}

Fortunately, it’s easy to fix because the parent of the knob is still an input neighbor. We just need to specify it:

input:checked + .slide .knob {
  /* some knob manipulation */
}

We can be less specific about a parent:

input:checked + * .knob {
  /* some knob manipulation */
}

Version with a separate knob element requires more HTML and just a tiny bit more complex CSS but to my eye, it’s much more readable and maintainable because it’s easier to reason about. The knob is a knob, the slider body is a slider body. No mysterious :before for an element that’s not actually before.

Wait, there is more!

A modern dark/light theme switch is more complex than a binary decision. How so? There is a system-wide preference. I want to have an option to respect that. That means that a checkbox no longer suits our needs because our choice is no longer binary. Light | System | Dark. Three options. Now we’re talking! There are several ways to tackle this problem. We can style an existing HTML slider to look and behave like a switch or draw the switch like before but with another way to store the value.

HTML slider

Set a min value to 0, a max value to 2, style it, aaaaaaand it’s working just like a slider…

Slide them ↔️

There are a lot of problems with a slider though:

  • non-standard pseudo-elements for the knob/thumb (::-webkit-slider-thumb, ::-moz-range-thumb, …)
  • it’s made with an idea that a knob is bigger than the track, so it’s hard to style
  • CSS knows nothing about a range value / requires JS
  • even though many binary switches look like sliders they often behave like a button & user expects to press/click, not to drag

It’s a fan solution but far from practical, so I’m scratching it.

Radio buttons

I don’t know any reasonable way to make them look and behave like a 3 state switch.

Tick ☝️ it

I do know how to make them store values in HTML in a way accessible by css. Demo above works exactly that way. Plus, I know how to hide them & draw my own switch. Let’s start by defining 3 radio buttons:

<div class="switch">
  <input type="radio" name="switch">
  <input type="radio" name="switch">
  <input type="radio" name="switch">
  <span class="slider">
    <span class="knob"></span>
  </span>
</div>

The way radio buttons are interacting with CSS is a bit different from the way a checkbox does it. We need to know which input is checked right now. CSS has nth-child pseudo-class that can help us identify a radio button:

input:nth-child(3):checked + .slider {
  /* some slider manipulation */
}

This option works and is quite universal but is a bit hard to maintain. We are tied to a particular order of elements in HTML. Another option would be to give each radio button an id or a unique class. I prefer the unique class way over id because it allows having multiple switches. But then again if you have multiple switches isn’t it easier to use nth-child(). I suggest choosing one method and trying to stick with it.

Another problem comes from the fact that we have multiple inputs. It means that the first and second inputs are no longer neighbors of the slider span. Not a big deal. Let’s change + combinator to a ~ (general sibling combinator). This one looks for a neighbor under the roof of the same parent in a document tree and allows to have other elements in between.

.radio-dark:checked ~ .slider {
  /* some slider manipulation */
}

+ still works for the last radio button, but I prefer consistency. So I’m using ~ for all the inputs.

Toggle them ☝️

You may have noticed, that when I was defining radio buttons earlier, I switched <label> for a <div>. In w3schools example <label> was useful, it handled clicks by changing the state of a checkbox. With 3 radio buttons, it always chooses the first one. I want the click to choose the next option so that a user can easily cycle through available options. There is no way to do it in pure HTML and CSS. Let’s use JS to add a click handler to each slider and use it to cycle through radio buttons:

    const switches = document.querySelectorAll('.switch');
    switches.forEach( switchEl => {
      switchEl.querySelector('.slider').addEventListener('click', (e) => {
        const radios = switchEl.querySelectorAll('input[type="radio"]');
        if (radios[0].checked) {
          radios[1].checked = true;
        } else if (radios[1].checked) {
          radios[2].checked = true;
        } else if (radios[2].checked) {
          radios[0].checked = true;
        }
      });
    });

It works fine for a demo of a switch. There is one more problem. I’ve built all of my theme-switching logic on the state of the radio buttons. To be more precise, I’ve added a change listener to radio buttons. Changing checked status of a radio button from JS doesn’t trigger a change event. To get around this we can create an event manually. Here’s an example for one button:

    radios[1].checked = true;
    radios[1].dispatchEvent(new Event("change"));

This is very close to the variant I’ve decided to stick with for some time. The main difference is the middle state that represents the system preference. I use a media query prefers-color-scheme to style the middle state accordingly to the system preference. There is a chance you can see it in action in the header or a sandwich menu there. A significant downside of the current solution is the lack of keyboard support, but it’s a problem for the future =]

Summary

While ::before and ::after can be useful even when not used as intended, it’s essential to consider more readable and maintainable alternatives. They are two extra elements you can do whatever you want to. They share neighbors with the parent element which can be very useful to simplify CSS. The slider example already required changes to HTML, so there was no reason to stick with the pseudo-element.


PS. Additional discoveries

Difference between :before ::before

I learned from Kevin Powell to use:

*, *::before, *::after {
  /* unimportant to the case */
}

This is how I got the notion that pseudo-elements should be preceded by :: which is css3 syntax for pseudo-elements. While : should be used for pseudo-classes (like :checked).

However, using :: is merely a suggestion and browsers still support : for pseudo-elements. Plus there are old browsers that aren’t aware of :: but support : from css2 age. Fortunately, we’re pretty safe. Most modern browsers have supported the :: syntax for over ten years, so compatibility concerns are minimal.

You can use : in URLs

One of the links in this post is https://developer.mozilla.org/en-US/docs/Web/CSS/::before. It has : not as a protocol separator, but as a part of the path. It’s the first time I’ve noticed such a thing.

This reminds me of an article by Jan Schaumann - URLs: It’s complicated…