How often have you seen markup like this?

<a href="/page2/" class="nav-link active">Page 2</a>

We have an anchor with two class names: nav-link and active. Presumably, the active class means that this anchor points to the current page. It's safe to assume that we'll use this class to style active navigation links differently than others:

/* other pages */
.nav-link {}

/* current page */
.nav-link.active {}

This approach is common in component frameworks, where we tend to pass along props for disabled, active, and "current" states, and these might be used to conditionally apply class names inline:

import classNames from 'classnames';

const NavLink = ({ href, isActive, isDisabled, children }) => {
  return (
    <a
      href={href}
      className={classNames("nav-link", {
        active: isActive,
        disabled: isDisabled,
      })}
    >
      {children}
    </a>
  );
};

This is even more tempting—and usually the only option—in utility-first frameworks like Tailwind, where class names are the basic building blocks of your UI.

However, I've noticed that we tend to reach for class names too soon when we want to represent a change in UI state. Instead, we should try to first communicate this state through the appropriate HTML attributes and then use CSS attribute selectors to style each discrete state. I've also found that if you're using both HTML attributes and CSS classes to represent an element's state, then you're storing redundant information at the markup level, and this problem can be avoided entirely by using attribute selectors from the get-go.

Skip table of contents

Table of Contents

Representing UI State with HTML Attributes

Below are just a few examples of markup that relies on HTML classes to represent an element's state. In each section, we'll explore a better alternative: representing an element's state using HTML attributes and styling the element using the CSS attribute selector.

We have this markup from the intro:

<a href="/page2/" class="nav-link active">Page 2</a>

We can assume that the corresponding styles for the active class are sufficient to convey the link's activeness state to sighted users. However, importantly, screen readers and search engine crawlers won't treat this link differently than any other on the page because they can't interpret semantics from a class name.

Instead, we can use the aria-current attribute, which identifies the current element in a collection of related items. The definition of "current" depends wholly on the context. In the case of navigation links, the current item corresponds to the current page. And as it turns out, "page" is a valid value for this attribute, made specifically for this use case. So we can set aria-current="page" on the link and remove the active class entirely:

<a href="/page2/" class="nav-link" aria-current="page">Page 2</a>

Now, we can style the active navigation link with the CSS attribute selector, targeting navigation links that have an aria-current attribute of "page":

.nav-link[aria-current="page"] {}

This communicates the same information as before but without an extra class name. This offers two benefits:

  1. Screen readers will now narrate the link's activeness state: Link current page [text].
  2. We don't have to repeat ourselves by specifying both HTML attributes and classes.

Toggle Buttons

Consider the classic hamburger button that's used to toggle a navigation menu:

<button type="button" aria-label="Toggle menu"></button>
<ul class="navbar-menu">
  <!-- links here -->
</ul>

This is a special case of a more general scenario: toggle buttons. A toggle button is an interactive element that, when clicked, alters the visibility of another element on the page that it controls or owns. For example, the details disclosure element is a native toggle button.

It's very likely that a toggle button will need to change its styling to reflect its current state: toggled or not toggled. In the case of a navigation menu, this would communicate whether the menu is open or closed. So you may be tempted to use different class names to represent these states and style them accordingly:

<!-- Closed menu -->
<button
  type="button"
  aria-label="Toggle menu"
  class="navbar-toggle closed"
></button>

<!-- Open menu -->
<button
  type="button"
  aria-label="Toggle menu"
  class="navbar-toggle open"
></button>

However, this runs into the same problem as the navigation link example: We're not communicating the right semantics to screen readers by using a class name alone. And once we do add the proper semantics with HTML attributes, the class names will become redundant.

Let's ignore the fact that this button would need other HTML attributes to be fully accessible; I've listed them below for completeness, but they're not too important for our purposes. Instead, let's focus on just one of those attributes: aria-expanded. In the case of trigger buttons like a hamburger icon, aria-expanded communicates whether the element associated with the button is currently expanded or contracted. The markup might look like this:

<button
  id="navbar-toggle"
  type="button"
  aria-label="Toggle menu"
  aria-controls="navbar-menu"
  aria-expanded="true"
></button>
<ul id="navbar-menu" aria-labelledby="navbar-toggle"></ul>

Now, rather than styling the button states with class names, we can style them using the attribute selector:

/* State: closed. Click to open. */
#navbar-toggle[aria-expanded="false"] {}

/* State: open. Click to close. */
#navbar-toggle[aria-expanded="true"] {}

This reads just as naturally as the first example with class names, but it removes the unnecessary noise of picking a class name and using it to style the element's states.

Multi-Select Widgets

Imagine you're building a UI where a user can select one or more items on the screen. This may be implemented with native checkboxes or radio buttons, or it may be implemented using buttons that have their role set accordingly (e.g., to radio, checkbox, or some other valid value). Implementation details aside, it's likely that the user's selection will be highlighted somehow, like with a decorative outline.

Again, the class name approach is very tempting, especially when you already have inline logic for determining the activeness of an element, like in this React example:

<ul role="radiogroup">
  {items.map((item) => {
    const isSelected = item.id === selectedId;

    return (
      <li key={item.id}>
        <button
          type="button"
          role="radio"
          onClick={() => setSelectedId(item.id)}
          // This feels tempting and is easy to read
          className={classNames({ active: isSelected })}
        >
          {item.label}
        </button>
      </li>
    );
  })}
</ul>;

Instead, we can (and should!) leverage the aria-checked attribute since we're using a role of radio. Native radio buttons implement these semantics under the hood, but since we're using a button with an explicit role, we should set the corresponding ARIA attribute:

<button
  type="button"
  role="radio"
  onClick={() => setSelectedId(item.id)}
  aria-checked={isSelected ? 'true' : undefined}
>
  {item.label}
</button>

And now, styling the active selection is a simple matter of using the attribute selector:

button[aria-checked="true"] {}

Just as before, it turns out that the class name was completely redundant. In fact, because we reached for a class name prematurely, we forgot to communicate the right semantics at the markup level.

Exceptions to the Rule: Child Element State

Before wrapping up, I want to note that there are some exceptions to this rule.

In the case of the hamburger toggle button, the parent element (e.g., a navbar) may need to know whether the button has been toggled so it can style itself accordingly. Since there's no way for a parent to change its own appearance in response to changes in a child element's state, the best that we can do is to apply an additional class name at the parent level. Alternatively, if you're using CSS in JS, you could interpolate values using ternaries.

The Need for Relational Selectors: :has

This is one of the strongest arguments in favor of introducing relational selectors like :has to the CSS standard. Assuming that developers don't abuse it, the :has selector would allow a parent element to style itself based on changes in a child element's state:

/* Open navbar */
.navbar:has(#navbar-toggle[aria-expanded="true"]) {}

/* Closed navbar */
.navbar:has(#navbar-toggle[aria-expanded="false"]) {}

Thus, you wouldn't need to introduce additional class names at the parent level just so you could select and style those states.

Use Attributes First and Class Names Second

If possible, try to represent your UI state using HTML attributes first, and only reach for class names when you really need them. With this approach, you're forced to use class names for their intended purpose: styling elements, not representing UI state. Consider whether there are existing HTML attributes that you can use to communicate an element's state to assistive technologies and crawlers. From there, styling the element should be straightforward and may not even require any additional class names.

Attributions

The photo used in this post's social media preview was taken by Chris Lawton (Unsplash).

Comments

Post comment

This comment system is powered by the GitHub Issues API. You can learn more about how I built it or post a comment over on GitHub, and it'll show up below once you reload this page.

Loading...