Post

Practical CSS: simplifying UI code with pseudo-classes

pseudo, adjective: being apparently rather than actually as stated

CSS pseudo-classes are like regular classes in that they can be used to select DOM elements. They’re unlike regular classes in that you can’t see them in the HTML. They select elements dynamically, based on their own rules. This is what makes them powerful.

I really like them because they let me remove dynamic presentation logic from JavaScript and keep it in CSS. That leaves JavaScript more straightforward, easier to understand, and easier to maintain.

Setup

This is best learnt through a concrete example. I aimed to strike a balance between realism and simplicity.

We’ll look at a form for creating tags. Here’s the key behaviour:

  1. Typing a string into the input field and pressing enter creates a new tag.
  2. When you start typing, an “x” button appears inside the input field and lets you clear the field.
  3. When there are no tags, a message appears saying “You have no tags”.
  4. When there’s just one tag, it is rendered more prominently.
  5. Tags can be removed.

Here’s an implementation that uses JS for all of those requirements. This is an interactive widget, so try it out:

You have no tags

I’ll skip the styling CSS since it is irrelevant to this article. It’s here only to give you a nicer widget to look at. But I will show you the full Stimulus controller attached to it.

Open the details block and just scan it. It’s not important to carefully read the code, just get a sense of how it looks:

JS Driven Tag Editor Stimulus Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { Application, Controller } from "@hotwired/stimulus"

const app = Application.start()
app.register("js-tag-editor", class extends Controller {
  static targets = ["input", "tagList", "tagTemplate", "clearButton", "emptyMessage"]

  addTag() {
    const value = this.inputTarget.value.trim()
    if (!value) return

    this.#addTag(value)
    this.#updateVisibility()
    this.clear()
  }

  removeTag(e) {
    e.target.closest("li").remove()
    this.#updateVisibility()
  }

  clear() {
    this.inputTarget.value = ""
    this.inputTarget.focus()
    this.toggleClearButton()
  }

  toggleClearButton() {
    this.clearButtonTarget.classList.toggle("hidden", this.inputTarget.value === "")
  }

  #addTag(name) {
    const clone = this.tagTemplateTarget.content.cloneNode(true)
    clone.querySelector("slot[name='tag-name']").textContent = name
    this.tagListTarget.appendChild(clone)
  }

  #updateVisibility() {
    const tags = this.tagListTarget.querySelectorAll("li")
    const hasTags = tags.length > 0
    const isOnly = tags.length === 1

    this.emptyMessageTarget.classList.toggle("hidden", hasTags)
    this.tagListTarget.classList.toggle("hidden", !hasTags)
    tags.forEach(tag => tag.classList.toggle("only-tag", isOnly))
  }
})

It uses the following classes to work:

Classes used by the JS Driven Tag Editor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#js-driven-tag-editor .hidden {
  display: none;
}
#js-driven-tag-editor .tag-list li.only-tag {
  padding: 0.5rem 1rem;
  font-size: 1rem;
  background: #3a9e75;
  color: white;
}
#js-driven-tag-editor .tag-list li.only-tag .remove-btn {
  color: white;
}
#js-driven-tag-editor .tag-list li:not(.only-tag) {
  padding: 0.3rem 0.7rem;
  font-size: 0.85rem;
  background: #e8e8e8;
  color: #444;
}

All of these are presentation classes and they’re explicitly manipulated by JS.

We’ll now use pseudo-classes to clean it up.

Striping away the presentation logic

I’m assuming you’re familiar with regular CSS selectors and how to combine them. The most basic examples select #element-by-its-id, .by-its-class, or .a-combination.of.classes.

Modern CSS also supports a range of pseudo-classes that let you select elements based on an element’s full context: its state and surrounding elements.

You’re likely already familiar with some pseudo-classes. For example, :hover lets you style an element when someone hovers over it. Another common one is :disabled for input elements or buttons that are disabled.

The full list of supported pseudo-classes is rather long, but a few are more useful than the others. Used well, they can significantly cut presentation logic in your JS and leave it to handle functionality.

First, let’s remove all of the presentation logic from the controller and see what’s left:

You have no tags

    Everything still works, it’s just that it doesn’t look quite right.

    But the JS controller is now much simpler:

    Stimulus Controller free of presentation logic
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    
    import { Application, Controller } from "@hotwired/stimulus"
    
    const app = Application.start()
    app.register("tag-editor", class extends Controller {
      static targets = ["input", "tagList", "tagTemplate"]
    
      addTag() {
        const value = this.inputTarget.value.trim()
        if (!value) return
    
        this.#addTag(value)
        this.clear()
      }
    
      removeTag(e) {
        e.target.closest("li").remove()
      }
    
      clear() {
        this.inputTarget.value = ""
        this.inputTarget.focus()
      }
    
      #addTag(name) {
        const clone = this.tagTemplateTarget.content.cloneNode(true)
        clone.querySelector("slot[name='tag-name']").textContent = name
        this.tagListTarget.appendChild(clone)
      }
    })
    
    

    Now we’ll build it back up with pseudo-classes! Look at me trying to get you excited about CSS with exclamation marks!

    Building it back up with pseudo-classes

    :only-child

    We’ll start easy and take care of the unique styling of a single tag.

    :only-child matches an element when it is its parent’s only child, which is exactly what we need. We’ll style the tag differently when that happens:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    .tag-editor .tag-list li:only-child {
        padding: 0.5rem 1rem;
        font-size: 1rem;
        background: #3a9e75;
        color: white;
    }
    .tag-editor .tag-list li:only-child .remove-btn {
        color: white;
    }
    

    Another example of :only-child usage

    If you have a list where the last element should not be removed, you want to hide the remove button when there’s just one element in the list. That can be done purely with CSS:

    1
    
    li:only-child btn.remove { display: none }
    

    :has and :not

    The :has pseudo-selector is a heavy hitter. It’s my favourite CSS selector by a wide margin because it often lets me simplify my JavaScript. Is it weird that I have a favourite CSS selector? I don’t care.

    The :has pseudo-selector matches an element based on the elements rendered inside it. For example, div:has(.my-class) matches any div that contains an element with my-class. To clarify, the div itself does not have my-class; an element inside it does. That difference is extremely powerful. It allows reversing the normal direction of influence: a child element can influence a parent. And it’s widely supported in all modern browsers.

    :not is much simpler: it matches an element that does not match the selector inside the parentheses. For example, div:not(.my-class) matches any div that does not have my-class.

    We can use the two in combination to show and hide the tag list and the “You have no tags” message:

    1
    2
    3
    4
    5
    6
    
    .tag-editor:has(.tag-list li) .empty-message {
      display: none;
    }
    .tag-editor:not(:has(.tag-list li)) .tag-list {
      display: none;
    }
    

    Another example of :has usage

    Imagine you have a modal dialog that loads its content through a Turbo Frame inside the dialog. Sometimes the modal should be wide and sometimes narrow. Most content needs padding, but sometimes the content should go edge to edge and the frame should have no padding around it.

    You could solve this with a bit of JavaScript that monitors the content and toggles classes on the modal element. Or you could just use the :has pseudo-selector!

    1
    2
    3
    4
    5
    6
    7
    
    .modal:has(.modal-content-wide) {
        width: 80%;
    }
    
    .modal:has(.modal-content-edge-to-edge) {
        padding: 0;
    }
    

    :placeholder-shown

    :placeholder-shown matches any <input> or <textarea> element that is currently displaying placeholder text.

    In our input field, we have an “x” button that clears the field. There is no point in showing it when there is nothing to clear. There is no selector for empty inputs. But empty inputs also show their placeholder.

    So if the placeholder is shown, we can conclude the input is empty, and we should hide the clear button:

    1
    2
    3
    
    .tag-editor .input-area input:placeholder-shown ~ .clear-btn {
      display: none;
    }
    

    Final result

    Putting it all together, we have completely restored the original widget:

    You have no tags

      And we didn’t touch JavaScript. The final controller has remained clean and simple, with only pure functional logic: add or remove tags and clear input field.

      Notice that CSS ended up being declarative. It does not describe how to change the style. It describes how the page should look in each state. Since the browser handles state changes, the presentation becomes almost reactive: when the state changes, the presentation changes automatically. We don’t need a reactive JS framework to make that happen. The pure CSS approach is: easier to understand, easier to maintain, and more performant.

      One small word of warning: I have seen some mobile browsers struggle with very complex CSS selectors that contain pseudo-classes. They would occasionally fail to recalculate the layout. As mobile browsers improve, this will go away. For now be careful when using pseudo-classes inside very complex selectors with four or more nested selectors.

      This post is licensed under CC BY 4.0 by the author.