Most developers use exactly two CSS selectors with any regularity. Class selectors and, occasionally, ID selectors when they're feeling lazy or working with legacy code. Maybe a descendant combinator when nesting things. That's about it.
The rest of the selector toolbox sits there gathering dust. Pseudo-classes like :nth-child get some love. But attribute selectors? They're criminally underused. I've seen developers add JavaScript to toggle classes based on DOM state when a single line of CSS could have handled the same thing more elegantly.
Attribute selectors let you target elements based on the presence, value, or partial value of any HTML attribute. Not just class and id. Any attribute. href, data-*, aria-*, type, disabled, target, rel — the entire attribute space is available. And once you internalize how they work, you start seeing use cases everywhere.
The most basic attribute selector wraps the attribute name in square brackets. [disabled] targets any element with a disabled attribute, regardless of its value. That's the presence selector, and it's already useful.
[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
That single rule replaces a common pattern where developers manually add a .is-disabled class to elements. The disabled attribute already exists on form elements. The browser already manages its state. Why duplicate that information with a class?
The exact value selector uses an equals sign. [type="submit"] targets elements where the type attribute is exactly "submit". This is how you style specific input types without adding classes.
input[type="submit"] {
background-color: #3b82f6;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
}
What surprises people is how many variants exist beyond exact matching. You can match partial values, hyphenated values, space-separated values, and even case-insensitive values. Each one solves a specific problem.
The exact match operator is straightforward. [href="https://example.com"] matches exactly that URL. But what if you want to style all external links? That's where the partial operators shine.
The starts-with operator uses a caret. [href^="https"] matches any link that begins with "https". Combine that with a :not() pseudo-class to exclude internal links, and you've got a clean external link indicator without adding a class to every link.
a[href^="http"]:not([href*="example.com"])::after {
content: " ↗";
font-size: 0.75em;
}
The contains operator uses an asterisk. [href*="example"] matches any link containing "example" anywhere in the URL. This is looser than starts-with and catches subdomains, query parameters, anything with that substring.
The ends-with operator uses a dollar sign. [href$=".pdf"] matches links ending in ".pdf". Perfect for adding PDF icons to document links without any JavaScript.
a[href$=".pdf"]::before {
content: "📄 ";
}
a[href$=".doc"]::before,
a[href$=".docx"]::before {
content: "📝 ";
}
The hyphen-separated operator uses a pipe. [lang|="en"] matches elements with lang="en" or lang="en-US" or lang="en-GB". This is specifically for attributes that use hyphenated value lists, and it's most commonly used with the lang attribute.
The space-separated operator uses a tilde. [class~="card"] matches elements where "card" appears as one of the space-separated values in the class attribute. This is similar to a regular class selector but allows partial matching within multi-value attributes.
Here's where attribute selectors genuinely reduce the amount of code you write. HTML5 form validation provides pseudo-classes like :valid and :invalid, but they apply immediately before the user has interacted with the field. Showing validation states on a pristine form is bad UX.
The standard fix involves JavaScript — add an .is-dirty or .is-touched class after the user interacts with the field, then scope validation styles to .is-dirty:invalid. But you can do this with attribute selectors and no JavaScript at all.
/* Only show validation after the field has received input */
input:not([value=""]) {
border-color: #22c55e;
}
input:not([value=""]):invalid {
border-color: #ef4444;
}
/* Or use the placeholder-shown pseudo-class */
input:not(:placeholder-shown):invalid {
border-color: #ef4444;
}
The [value=""] selector targets inputs where the value attribute is empty. The :not() negation means we only apply styles once something has been entered. Combine that with :invalid and you get validation styling that only appears after the user has typed something. No JavaScript required.
This pattern handles most basic form validation needs. For more complex validation, you might still reach for JavaScript. But for indicating whether a field has been filled in or whether a required field is still empty, attribute selectors paired with pseudo-classes are enough.
Attribute selectors and ARIA work beautifully together. ARIA attributes reflect component state — whether something is expanded, selected, pressed, or hidden. Instead of manually syncing those states to CSS classes, you can style directly against the ARIA attributes.
[aria-expanded="true"] {
/* Styles for an expanded accordion or dropdown */
}
[aria-expanded="true"] + .dropdown-menu {
display: block;
}
[aria-selected="true"] {
background-color: #3b82f6;
color: white;
}
[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
If you're using a component library like React Aria Components, these ARIA attributes are managed for you automatically. The components set the correct attributes based on user interaction. Your CSS can respond to those state changes without any additional wiring.
This pattern eliminates an entire class of bugs. When your CSS classes and ARIA attributes represent the same state information, they inevitably fall out of sync. Maybe someone forgets to toggle the class. Maybe a refactor breaks the connection. Styling directly against ARIA attributes means there's only one source of truth for state, and the CSS naturally reflects whatever state the accessibility layer reports.
If you've read our guide on WAI-ARIA authoring practices, you know that ARIA attributes must reflect real component state. CSS attribute selectors give you a way to visualize that state without duplicating it.
Data attributes are custom attributes prefixed with data-. They exist specifically so developers can attach arbitrary information to DOM elements. They're valid HTML, they don't conflict with anything, and they're accessible via both CSS and JavaScript.
Using data attributes as styling hooks creates a clean separation between content and presentation. A data-status attribute can drive the visual state of a component. A data-size attribute can control dimensions. A data-variant attribute can switch between visual treatments.
[data-status="success"] {
border-left: 4px solid #22c55e;
}
[data-status="warning"] {
border-left: 4px solid #f59e0b;
}
[data-status="error"] {
border-left: 4px solid #ef4444;
}
[data-size="small"] {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
[data-size="large"] {
padding: 1rem 2rem;
font-size: 1.25rem;
}
This pattern shows up in modern CSS frameworks and design systems. Tailwind uses data attributes internally. Headless UI libraries use them to expose component state. The approach is more semantic than overloading the class attribute because the attribute name describes what the value represents.
The data attribute approach also plays nicely with JavaScript. You can set element.dataset.status = 'success' and the CSS responds automatically. No class name management. No toggling logic. The attribute is the state.
Attribute selectors have the same specificity as class selectors. [disabled] and .disabled carry identical weight in the cascade. This makes them predictable to work with — they won't override IDs or inline styles, and they won't be overridden by element selectors.
This also means you can combine them without creating specificity headaches. An element selector with an attribute qualifier like input[type="email"] has the specificity of two selectors — one for the element, one for the attribute. It won't override a single class, but it will override a bare element selector.
/* Specificity: 0-0-1 */
input {
border: 1px solid #ccc;
}
/* Specificity: 0-1-1 — overrides the above */
input[type="email"] {
border-color: #3b82f6;
}
This predictable specificity behavior means attribute selectors slot naturally into existing stylesheets without unexpected overrides. They're not a specificity hack. They're just another selector with the same weight as the class selectors you're already using.
CSS selector performance is rarely the bottleneck in web applications. Browsers are incredibly fast at matching selectors, and they match them right-to-left against the DOM tree. The complexity of your layout and the number of repaints usually matter far more than your selector syntax.
That said, attribute selectors are generally fast. The [attr] presence check and [attr="value"] exact match are optimized by browser engines. Partial matching operators like [attr*="value"] and [attr^="value"] are slightly slower because they require substring comparison, but the difference is measured in microseconds.
The one performance consideration worth mentioning involves the universal selector combined with attribute selectors. *[data-something] or [data-something] without an element qualifier forces the browser to check every element in the DOM. In practice, this is fine for most applications. If you're styling a page with tens of thousands of elements, qualify your selectors with an element type when possible.
If you've encountered forced reflow issues, you know that layout thrashing is a much bigger performance concern than selector optimization. Focus your performance energy on reducing layout triggers and paint complexity. The selectors themselves will be fine.
The real power comes from combining attribute selectors with pseudo-classes. The interaction between the two creates expressive selectors that would otherwise require JavaScript.
/* Links that open in a new tab and point to external sites */
a[target="_blank"][href^="http"]:not([href*="example.com"])::after {
content: " ↗";
}
/* Required inputs that are currently empty */
input[required]:placeholder-shown {
border-color: #f59e0b;
}
/* Disabled buttons in a loading state */
button[disabled][aria-busy="true"] {
background-image: url("spinner.svg");
}
/* Checked radio buttons followed by a label */
input[type="radio"]:checked + label {
font-weight: 600;
color: #3b82f6;
}
Each of these selectors expresses a complex condition without any JavaScript. The browser evaluates them natively, updates them automatically when attributes or states change, and applies the styles immediately. No event listeners. No state management. No class toggling.
Attribute selectors have been supported since IE7. The partial matching operators — ^=, $=, *=, ~=, |= — have been supported since IE7. The case-insensitive flag [attr="value" i] is newer and works in all modern browsers, with IE being the only exception.
You can use attribute selectors in production without transpilation, without polyfills, without worrying about older browsers. They're part of CSS2 and have been reliable for over a decade. The only reason they're not more widely used is that developers don't reach for them.
Let me give you a concrete before-and-after that shows how attribute selectors eliminate code.
Before — using JavaScript to manage state-based styling:
// JavaScript to toggle a class based on input state
input.addEventListener("input", (e) => {
if (e.target.value.length > 0) {
e.target.classList.add("has-value");
e.target.classList.remove("is-empty");
} else {
e.target.classList.add("is-empty");
e.target.classList.remove("has-value");
}
});
After — using CSS attribute selectors:
input[value=""] {
border-color: #e5e7eb;
}
input:not([value=""]) {
border-color: #22c55e;
}
The CSS version is shorter, has no runtime overhead, can't have bugs from event listener timing, and updates automatically whenever the value changes. The browser does the work natively.
Now, there's a caveat. The CSS [value] selector reflects the attribute in the HTML, not the live property in the DOM. If you set input.value through JavaScript, the attribute selector won't see the change unless you also call input.setAttribute('value', newValue). For user input, the attribute and property stay in sync. For programmatic changes, you need to update both.
Over time, certain attribute selector patterns have become automatic for me. I reach for them without thinking because they solve recurring problems elegantly.
External link indicators for documentation sites and blogs:
a[href^="http"]:not([href*="redsurgetechnology.com"])::after {
content: " ↗";
opacity: 0.6;
}
Form field differentiation without wrapper classes:
input[type="email"],
input[type="tel"],
input[type="url"] {
/* Specific styles for these input types */
}
State-based visibility for error messages:
[data-error]:not([data-error=""]) {
display: block;
color: #ef4444;
}
Disabled states that work with any interactive element:
[disabled],
[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
user-select: none;
}
These patterns are compact, self-documenting, and don't require maintaining parallel state in your JavaScript and your CSS.
CSS attribute selectors are one of those features that's been hiding in plain sight for decades. They're widely supported, performant, and they solve real problems that developers routinely reach for JavaScript to handle.
The next time you find yourself writing a function to toggle a class based on DOM state, ask yourself whether the browser already exposes that state through an attribute. If it does, an attribute selector might be the simpler, faster, more reliable approach.
If you've been comparing CSS Modules vs Tailwind, attribute selectors work well with both. In CSS Modules, they're scoped automatically. In Tailwind, you can combine them with @apply or use them in custom styles. They're not tied to any particular styling methodology.
The selector toolbox has more tools than most developers realize. Attribute selectors won't replace class selectors, and they shouldn't. But for styling based on DOM state — especially state that the browser already manages — they're often the best tool available.
Want to write cleaner, more maintainable CSS that does more with less code? Red Surge Technology helps teams modernize their frontend practices and reduce unnecessary JavaScript. Get in touch to discuss your project.