In this article, I want to explore how we can use CSS’s powerful attribute selectors to manage component states (traditionally handled by JavaScript) directly within our stylesheets.
Why? Because too often, our styling logic ends up living in our JavaScript, wrapped in ternaries, helper functions, and conditional class names. Sure it works, but it also couples behavior and appearance more tightly than it needs to. Moving some of that logic into CSS makes our components simpler, more declarative, and easier to reason about. With a minor shift in mindset, we can get CSS to do more of what it’s already great at: styling by state.
The Problem with Classes
Be honest with me, how much JavaScript do you write to determine which classes or set of styles to apply to your markup?
Does this ternary look familiar?
<div className={isOpen ? 'open' : 'closed'}>
How about this helper function?
<div className={getWrapperStyles(stateA, stateB, stateC)}>
If it does, I feel your pain. But I’m here to tell you there’s an easier, cleaner way to solve these problems by moving this “logic” into your stylesheets, where you can style by state.
Stateful HTML and Expressive CSS
Let’s be clear. You already know how to style by state: the state of the class attribute, a space-separated collection of strings.
Everyone’s favourite data-structure: a space separated collection of strings.
– No one, ever
Let’s start simple and style a button. If that button is using the “primary” variant, is a “medium” size, and is displaying a loading state, the HTML of a BEM enjoyer’s button will read a little something like this:
<button class="btn btn--primary btn--md btn--loading">
Or maybe we’re generating this dynamically in some kind of framework/template and it looks more like this:
<button className={`btn btn--${variant} btn--${size} ${isLoading ? 'btn--loading' : ''`}
This works, of course, but let’s see what this looks like when we supply this button’s state directly to attributes:
<button class="btn" data-variant="primary" data-size="md" data-loading={isLoading}>
A little verbose, but the clarity we get in return is priceless. Each piece of state is discrete, and labelled by its corresponding attribute, like a value and its key.
If using vanilla JS, you can now leave the element’s capable but cumbersome classlist property alone, and use the element’s dataset instead, enabling you to treat it like a plain old object (which it is).
button.dataset.variant = "secondary"
button.dataset.size = "lg"
button.dataset.loading = "false"
// Although if you're dealing with Booleans, you may prefer:
button.toggleAttribute('loading')
A Notification
With that primer out of the way, let’s turn our eyes to a notification.
And, for the sake of argument, let’s imagine that I’ve asked you to work on a component with the following single piece of state:
state = "success high-priority dismissible top-right"
And then imagine, for a moment, the code required to make that component manage and respond to that state.
I bet my bottom dollar that you would shake your head, crack your knuckles, and refactor it to one of these two more reasonable methods of organizing the same state:
// as an object
state = {
status: "success"
priority: "high"
isDismissible: true
position: "top-right"
}
OR
// as discrete pieces of state
status = "success"
priority = "high"
isDismissible = true
position = "top-right"
Keys and values! That’s what I’m talking about! And if you agree that this is an improvement, then, honestly, you’re going to LOVE styling by state.
Let’s again compare the class approach to the attribute approach by placing that space separated string state where it looks most at home – in a class attribute:
<div class="notification success high-priority dismissible top-right">
And the stylesheet?
.notification {/* base styles */}
.success {}
.failure {}
.low-priority {}
.med-priority {}
.high-priority {}
.dismissible {}
.not-dismissible {}
.top-right {}
// other positions, etc
This definitely works. Always has. But we can do better.
First, let’s get properly acquainted (or reacquainted) with attribute selectors. They’re a little bulkier than a class selector, but they offer a precision (as well as a few bells and whistles we’ll talk about later) that make them well worth your while.
[data-key="a particular value"] {
// styles that are applied to any element whose "data-key" is exactly "a particular value"
}
button[data-key="a particular value"] {
// styles that are applied to any button whose "data-key" is exactly "a particular value"
}
With this in mind, let’s write some stateful HTML by applying those same pieces of state to a div using data-attributes. Let's also classify that div as "notification":
<div
class="notification"
data-dismissible="true"
data-status="success"
data-position="top-right"
data-priority="high"
>
Look at all those props! Is it just me or is that div looking a lot like a Notification component all of a sudden. Let’s hop into the stylesheet and write the “logic” that tells this Notification component how to look based on the state of its props using attribute selectors.
.notification {
/* Let's nest these selectors to keep things tidy */
&[data-status="success"] {}
&[data-status="failure"] {}
&[data-priority="low"] {}
&[data-priority="med"] {}
&[data-priority="high"] {}
&[data-dismissible="true"] {}
&[data-dismissible="false"] {}
&[data-position="top-right"] {}
/* other positions, etc. */
}
It is immediately apparent that this “notification” is being styled according to 4 distinct pieces of state. It’s also crystal clear what values each of these properties responds to.
Plus, we have made it impossible for there to be conflicting states. Using class names, you could have easily made the notification both low and high priority and the class later in the cascade would win out. Using an attribute, there can only ever be one type of priority active at a time.
Making CSS do JS
How do you make CSS do JS? Well… we’ve already started.
Look at the attribute selectors above and tell me how they’re different from this little snippet of JavaScript:
if (notification.status === 'success') {
// apply styles
}
if (notification.status === 'failure') {
// apply styles
}
Every single selector you write is an if statement. Before long, those brackets you wrap around attribute selectors will simply feel like the parentheses you wrap around JavaScript if clauses.
And when you chain two or more selectors together, that’s an AND ( && ) operand:
Compare JavaScript:
if (notification.status === 'success' && notification.priority === 'high') {
// apply styles
}
To CSS:
/* a notification with a "success" status AND a "high" priority */
.notification[data-status="success"][data-priority="high"] {
/* apply styles */
}
We even have an OR ( || ) operand:
Again, comparing JavaScript:
if (notification.dismissible === true && (notification.status === "success" || notification.priority === "high")) {
// apply styles
}
To CSS:
/* a notification is dismissible and has either a "success" status OR a "high" priority */
.notification[data-dismissible=”true”]:is([data-status="success"], [data-priority="high"]) {
/* apply styles */
}
If you've not seen the :is() pseudo-class before, it behaves a lot like an OR operand, and will select any of the elements provided to it.
Interesting to note, however, is that the specificity it adds is determined by the highest specificity selector provided to it. For instance, styles applied to a button selected by :is(button, .class, #id) would have the specificity you expect of an id (1, 0, 0) vs (0, 0, 1).
Also worth noting is that the :where() pseudo class does the same thing as :is(), but adds 0 specificity. Perfect for your resets and base styles.
Attribute Selector Syntax Goodies
In most cases, [attr=”value”] will be all you need to make your CSS do JS. However, there are some additional nuances to the attribute selector syntax that can come in very handy in certain circumstances.
Let’s do a quick overview of what else these things can do:
.notification {
&[data-position] {
// selects a notification that has a data-position value, regardless of what it is
}
&[data-position*="-"] {
// selects a notification whose data-position value has the specified string anywhere in it
// e.g., we could apply styles specific to notifications in corners, like "bottom-left"
}
&[data-position^="top"] {
// selects a notification whose data-position value starts with the specified string
// e.g., we could apply styles that would apply to "top", "top-left", and "top-right"
}
&[data-position$="right"] {
// selects a notification whose data-position value ends with the specified string
// e.g., we could apply styles that would apply to "right", "top-right" and "bottom-right"
}
}
If you’d like to see a live example, check out this far-from-fancy Codepen.
When not to use data attributes (and use ARIA attributes instead!)
I love data attributes for all of the reasons listed above and more, but there’s one place you should never use them: when you can use an ARIA attribute instead!
For instance, if you’re thinking of adding a data-open or data-expanded attribute, you should consider reaching for https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-expanded instead, as it will also communicate this state to assistive technologies.
Thinking you might want to add a data-current attribute to your “nav-link” class in order to highlight the current page? I love where your head’s at, but what you really want is https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-current. When you use [aria-current=”page”], you’re leveling up your accessibility score for free. It also accepts other values like “step” to indicate the current step of a multi-step process and “date” to indicate the current date on a calendar. The uses are somewhat niche, but they feel so good to use.
Those are just two of my favourite accessibility minded attributes to hang my styling hat on. I encourage you to look out for others along your way.
Wrapping Up
I hope I’ve piqued your interest in attribute selectors and that you’ll give “styling by state” a try. I think you’ll be surprised by how much less JavaScript you’ll end up writing and how much more expressive your stylesheets can become because of it. Give it a try in your next project, and share your experience in the comments below!
Top comments (2)
Cleaner separation of concerns, reduced cognitive overhead, fewer moving parts to debug, UA performance gains, and less tech debt - CSS handling presentation-layer state logic seems, err . . . logical. Excellent post.
Hi Adam.
Great post. I look forward to chat with you.