Attribute selectors let you style elements by the data they already carry, and combinators let you describe relationships between elements. Together they let you target a link by its href, a row by its data-status, or the paragraph that follows a heading, without inventing a class for every case. This page walks through the operators, the four combinators and the specificity each one contributes.
Why target an attribute instead of adding a class?
A class is a styling hook you invent. An attribute is a fact about the element that usually already exists: its language, its input type, its link target, its state, its role in a dataset. When the fact is already written down, a class that repeats it is redundant, and redundancy is where bugs start, because the class and the attribute can drift apart.
The clearest case is the mail link. An anchor with href="mailto:[email protected]" already says it is an email link, and the selector a[href^="mailto:"] says the same thing in CSS with no extra markup to maintain. The same logic applies to input[type="checkbox"], to iframe[src*="youtube"], and to any element carrying an aria-* attribute, where the attribute is literally a state machine that CSS can read.
One trap catches everyone. The class attribute is a space-separated list of tokens, not a single string, so [class="btn"] matches only an element whose entire class attribute is exactly btn. If the element is class="btn btn--large", that selector misses it. To test for one class among several, use [class~="btn"].
<ul class="resources">
<li><a href="/docs/getting-started" data-kind="guide" data-status="new" data-tags="guide urgent">Getting started</a></li>
<li><a href="https://example.com/spec" data-kind="reference" data-status="stable" data-tags="reference external">Platform spec</a></li>
<li><a href="/docs/faq" data-kind="guide" data-status="stable" data-tags="guide">FAQ</a></li>
</ul>Which attribute matching operators exist?
There are seven forms, and they cover every realistic case. The square brackets on their own test for presence. An equals sign tests for an exact value. The tilde tests for one word inside a whitespace-separated list. The pipe tests for an exact value or a value followed by a hyphen, which is how language subtags work. The caret tests for a prefix, the dollar sign for a suffix, and the asterisk for a substring anywhere.
| Selector | Matches |
|---|---|
[data-kind] | The element carries a data-kind attribute at all |
[data-kind="guide"] | The value is exactly guide |
[data-tags~="urgent"] | One word in a space-separated list is urgent |
[lang|="en"] | The value is en or starts with en- |
[href^="https://"] | The value starts with the given text |
[href$=".pdf"] | The value ends with the given text |
[href*="docs"] | The value contains the given text anywhere |
A trailing flag makes the comparison case-insensitive: [data-kind="GUIDE" i] matches guide, Guide and GUIDE. Without the flag, attribute values are compared exactly, and the case sensitivity of the document language does not change that. Put the flag after the value with a space, never inside the quotes.
a[data-kind] {
border-bottom: 1px dotted #7d8fa1;
}
a[data-kind="guide"] {
color: #1c4fa3;
}
a[data-tags~="urgent"] {
color: #b3261e;
font-weight: 700;
}
a[href^="https://"]::after {
content: " (external)";
font-size: 0.85em;
}
a[data-status="NEW" i] {
background: #fff4d6;
}How do the four combinators differ?
A combinator describes the relationship between two simple selectors. The descendant combinator is a space and matches at any depth. The child combinator is a greater-than sign and matches only a direct child. The adjacent sibling combinator is a plus sign and matches the element immediately after its neighbour. The general sibling combinator is a tilde and matches every following sibling.
The child combinator is the one beginners underuse. The rule .nav li reaches into every list nested inside the navigation, however deep, so a sub-menu inherits styles meant for the top level. .nav > li stops at one level. Using the child combinator is often the difference between a component that is reusable and one that quietly breaks the first time somebody nests it.
The sibling combinators are useful for editorial content. h2 + p styles the paragraph directly under a heading, and h2 ~ p styles every paragraph until the next heading, which is a neat way to set a lead paragraph apart without adding a class.
/* Descendant: matches at any depth */
.resources li a {
text-decoration: none;
}
/* Child: only direct children of the list */
.resources > li {
margin-block: 0.4rem;
}
/* Adjacent sibling: the row right below this one */
.resources li + li {
border-top: 1px solid #e3e9ef;
}
/* General sibling: this row and every row after it */
.resources li ~ li {
padding-inline-start: 1rem;
}
/* Editorial: the paragraph straight after a heading */
h2 + p {
font-size: 1.15rem;
color: #43586d;
}A fifth combinator, the column combinator written with two pipes, relates cells that belong to the same table column or the same multi-column layout. It is correct but rarely needed, and it has no effect outside those layout contexts, so reach for it only when you are building a genuine column layout.
What does an attribute selector do to specificity?
Specificity is counted in three columns. The first counts ID selectors, the second counts class selectors, attribute selectors and pseudo-classes, and the third counts type selectors and pseudo-elements. The universal selector counts for nothing at all.
An attribute selector therefore lands in the second column, exactly where a class would. The selector a[href] has a specificity of (0,1,1): one attribute and one type. The selector .resources a[href] has (0,2,2): one class, one attribute and two types. Because attribute selectors sit in the same column as classes, a rule like a[href] will outrank a plain a, and a rule with two attribute selectors will outrank one with a single class.
Two grouping functions make this far easier to control. :is() takes the specificity of the most specific selector in its argument list, so :is(.btn, .link) behaves like whichever of those two is stronger. :where() takes zero, no matter what you put inside it. That makes :where() the right tool for base styles you want to be easy to override later, and :is() the right tool for lists of alternatives that should all carry the same weight.
/* (0,1,1) — attribute plus type */
a[href] {
text-decoration: underline;
}
/* (0,2,2) — class, attribute and two types */
.resources a[href] {
text-decoration: none;
}
/* (0,1,0) — the class inside :is() sets the weight */
:is(.btn, .link) {
font-weight: 600;
}
/* (0,0,0) — :where() contributes nothing, so overrides are easy */
:where(.resources) a[href] {
text-decoration: none;
}How do you combine them without writing brittle selectors?
Ask one question before adding a selector: is this attribute part of the contract of the element, or is it an accident of the current markup? Attributes such as lang, type, href, alt and the aria-* family are contract. A selector built on those stays valid when the page is rewritten. A selector built on something incidental, like the third div inside a particular wrapper, will not.
Attribute selectors pair well with classes but should not replace them. A class says how you want an element styled; an attribute says what the element is. When both are available, the class is usually the safer hook for appearance, and the attribute is the safer hook for state.
Attributes that carry interaction state deserve special mention. aria-expanded, aria-current, aria-pressed and aria-selected already exist to publish state to assistive technology, so styling them costs nothing extra and keeps your visual layer in step with what a screen reader announces.
button[aria-expanded="false"] .panel {
display: none;
}
button[aria-expanded="true"] .panel {
display: block;
}
nav a[aria-current="page"] {
font-weight: 700;
color: #10243a;
}Selector patterns worth memorising
[attr]tests presence,[attr="v"]an exact value,[attr~="v"]a word in a list,[attr^="v"]a prefix,[attr$="v"]a suffix and[attr*="v"]any substring.- The trailing
iflag makes the value comparison case-insensitive. - For the class attribute itself, always use
[class~="name"]rather than[class="name"]. - The child combinator
>keeps component styles from leaking into nested sub-components. - The adjacent sibling combinator
+is the natural way to style the element that follows another one. - Attribute selectors count in the class column, so they are as strong as a class and no stronger.
- Use
:where()for base styles that must be easy to override, and:is()when the alternatives should share the strongest weight.
Try it yourself: take a list of links and give each one a data attribute describing its kind and status. Write one rule that targets every link carrying a data attribute, one that matches only external links with the caret operator, one that flags a single word inside a space-separated tag list with the tilde operator, and one that matches an uppercase status case-insensitively. Then build a small nested list inside a nav element and show the difference between the descendant selector and the child selector by adding a second level of nesting. Finally, write the same rule twice, once with a class and once with :where(), and check in the browser inspector that the two produce different specificity.