CSS Attribute Selectors
An attribute selector lets you style HTML elements based on the attributes they carry and, optionally, the value of those attributes. Instead of relying only on tag names, classes, or IDs, you can say "style every element that has a title attribute" or "style every <a> whose href ends in .pdf". This makes attribute selectors extremely useful for styling forms, links, and any markup you cannot freely add classes to (for example, content generated by a CMS or a third-party widget).
Overview / How it works
Every HTML element carries a set of attributes as key/value pairs — href, type, disabled, data-*, and so on. When the browser builds the DOM and runs the CSS engine’s selector matching phase, it walks each rule’s selector and checks, element by element, whether the element satisfies every simple selector in that compound selector. An attribute selector adds one more condition to check: does this element’s attribute list contain the named attribute, and (if a value is specified) does the attribute’s value match according to the chosen comparison operator?
Because attribute selectors are evaluated as part of normal selector matching, they participate fully in the cascade: they contribute to specificity exactly like a class selector does (more on the exact number below), they can be combined with other selectors (tag names, classes, pseudo-classes, combinators), and they can appear any number of times in a single compound selector. The browser does not need to know anything about what an attribute means semantically — it only compares strings — so attribute selectors work identically whether the attribute is a standard HTML attribute like type or a custom data-* attribute you invented for your own JavaScript hooks.
One subtlety worth understanding early: matching is purely syntactic. [href="/about"] matches only an element whose href attribute’s literal string value is exactly /about — it does not resolve relative URLs, does not know about redirects, and does not care what the attribute represents. Attribute selectors are also unaffected by the DOM’s live state in most cases (with the notable exception that some attributes, like a checkbox’s checked IDL attribute vs. its content attribute, can diverge — which is why :checked is a pseudo-class, not an attribute selector).
Syntax
The general shape of an attribute selector is a set of square brackets appended directly to a selector (or used alone, which implicitly means "any element"):
selector[attribute operator "value" flag] { property: value; }
| Form | Matches when… |
|---|---|
[attr] |
the element has the attribute at all, regardless of its value (even an empty string) |
[attr="value"] |
the attribute’s value is exactly equal to value |
[attr~="value"] |
the attribute’s value is a whitespace-separated list of words, and one of those words is exactly value |
[attr|="value"] |
the attribute’s value is exactly value, or starts with value immediately followed by a hyphen (commonly used for language codes like en-US) |
[attr^="value"] |
the attribute’s value starts with value |
[attr$="value"] |
the attribute’s value ends with value |
[attr*="value"] |
the attribute’s value contains value anywhere as a substring |
Each part explained:
- attribute — the attribute name, e.g.
type,href,data-status. Attribute names are case-insensitive in HTML documents. - operator — one of
=,~=,|=,^=,$=,*=, or omitted entirely for a bare presence check. - "value" — the string to compare against. You can quote it with single or double quotes, or leave it unquoted if it forms a valid CSS identifier (no spaces, and it can’t start with a digit). Quoting is always safe and is the recommended habit.
- flag — an optional single letter,
iors, separated from the value by a space, before the closing bracket.imakes the value comparison ASCII case-insensitive;s(newer, less supported) forces case-sensitive comparison even in contexts where matching would otherwise be case-insensitive.
Examples
Example 1: presence selector
a[title] {
border-bottom: 1px dotted #666;
cursor: help;
}
Applied to markup such as <a href="/info" title="More information">Info</a> alongside a plain <a href="/info">Info</a> with no title.
Result: only the first link — the one that actually carries a title attribute — gets a dotted gray underline and switches the mouse cursor to the "help" icon on hover. The second link, lacking the attribute entirely, is untouched. Note that even title="" (an empty string) would still match, because [title] only checks for presence, not content.
Example 2: exact value matching on form inputs
input[type="email"],
input[type="password"] {
border: 1px solid #ccc;
border-radius: 4px;
padding: 0.5em;
}
input[type="text"] {
border: 1px solid #999;
}
Result: <input type="email"> and <input type="password"> fields render with a light gray, rounded 1px border and comfortable inner padding. A separate <input type="text"> gets a darker gray border with square corners and no padding. Inputs with other types (like checkbox or submit), or no type attribute at all (which the browser still treats as text internally, but which does not match [type="text"] literally since the attribute is absent), receive none of these styles.
Example 3: substring matching for links and utility classes
a[href^="https://"] {
color: #0a7d2c;
}
a[href$=".pdf"]::after {
content: " (PDF)";
}
[class*="btn-"] {
display: inline-block;
padding: 0.5em 1em;
}
Result: any link whose href begins with https:// turns green — useful for visually flagging secure/external links. Any link whose href ends in .pdf, regardless of what comes before it, gets the literal text " (PDF)" appended right after the link’s own content, generated by the ::after pseudo-element. Finally, any element anywhere in the page whose class attribute contains the substring btn- — such as class="btn-primary" or class="toolbar btn-icon" — becomes an inline-block with padding, even though no exact class name was named.
Example 4: case-insensitive matching
[data-status="active" i] {
background: #eaffea;
}
Result: elements marked up as data-status="active", data-status="Active", or data-status="ACTIVE" all receive the same pale green background, because the trailing i flag tells the browser to compare the value ignoring ASCII letter case. Without the flag, only the exact lowercase spelling would match.
How it works step by step / Under the hood
- The browser parses your stylesheet and, for each rule, builds an internal representation of the selector, including any attribute conditions as separate match steps.
- During style computation, for each candidate element the engine checks compound selectors right-to-left (the rightmost part first, for matching efficiency), evaluating each simple selector — including attribute selectors — against that single element’s attribute map.
- For an attribute selector, the engine looks up the named attribute on the element. If it’s absent, the match fails immediately (except that
[attr]alone only needed presence and would already have matched). - If a value/operator was specified, the engine performs the appropriate string comparison: exact equality, whitespace-token membership, prefix, suffix, substring, or the hyphenated-prefix check for
|=. Thei/sflag controls whether this comparison is case-folded first. - Specificity: an attribute selector counts the same as a class selector or pseudo-class — it contributes (0, 1, 0) in the (ID, class, type) specificity tuple. So
a[title]has the same specificity asa.has-title(0-1-1 total), and[type="email"]alone is just 0-1-0, identical to a lone class selector. - If every simple selector in the compound selector matches (including any attribute conditions), the whole rule is considered a match for that element, and its declarations enter the cascade to be sorted by specificity and source order like any other rule.
Common Mistakes
Mistake 1: leaving a non-identifier value unquoted
[data-index=123] {
font-weight: bold;
}
This looks harmless, but 123 on its own is not a valid CSS identifier (identifiers cannot start with a plain digit) and is not a string either. Depending on the parser, this can cause the whole selector — and sometimes the whole rule — to be discarded as invalid, silently losing your styling. Always quote attribute values unless you are certain they form a valid identifier:
[data-index="123"] {
font-weight: bold;
}
Quoting works for every possible value (numbers, values with spaces, hyphens, colons, etc.), so it is the safest default — reach for it every time rather than deciding case by case.
Mistake 2: using = when the attribute holds a space-separated list
[data-tags=news] {
color: red;
}
Imagine the markup is <article data-tags="news featured homepage">. The = operator requires the entire attribute value to equal news exactly — it does not mean "contains the word news". Because the actual value is a three-word string, this selector never matches and the rule is silently dead. When an attribute is designed to hold multiple whitespace-separated tokens (much like the class attribute itself works), use ~= to match one token out of the list:
[data-tags~="news"] {
color: red;
}
This correctly matches any element whose data-tags contains news as one of its space-separated words, no matter what else is in the list or in what order.
Best Practices
- Quote attribute values by default — it costs nothing and avoids the invalid-identifier trap entirely.
- Prefer
~=over=whenever the attribute you’re targeting is documented or designed to hold multiple space-separated tokens. - Use substring operators (
^=,$=,*=) deliberately and narrowly — a very short substring like[class*="nav"]can accidentally matchclass="navigation",class="main-nav-wrapper", and unrelated names, so prefer^=/$=with a clear boundary character when possible. - Remember attribute selectors add class-level specificity (0-1-0 each); stacking several of them, e.g.
input[type="text"][disabled][data-required], adds up quickly and can make later overrides harder — keep an eye on your cascade. - Use
data-*attributes plus attribute selectors as a clean way to hook styling to component state (e.g.[data-state="open"]) without depending on class names that JavaScript might also toggle for behavior. - Add the
iflag only when the attribute’s value truly comes from user input or an external source with inconsistent casing; for attributes you fully control, keep casing consistent instead and skip the flag.
Practice Exercises
- Write a selector that styles every
<a>element whosehrefstarts withmailto:to display in a distinct color, without affecting any other links. - Given elements with
data-level="beginner",data-level="intermediate", anddata-level="advanced", write one rule that matches only the"advanced"value exactly, and a second rule using a substring operator that would match all three (explain in your own words why that happens). - A button uses
class="btn btn-large btn-primary". Write an attribute selector using~=that matches this button by targeting thebtn-primarytoken specifically, then explain why[class*="btn-primary"]would also match but is less precise.
Summary
[attr]matches by presence alone;[attr="value"]requires an exact match.~=matches one whitespace-separated token,|=matches an exact value or a hyphen-prefixed variant, and^=/$=/*=match a prefix, suffix, or substring respectively.- The optional
iflag makes value comparison case-insensitive;sforces case-sensitivity. - Attribute selectors always add class-level specificity (0-1-0), regardless of which operator is used.
- Always quote attribute values to avoid invalid-identifier parsing errors, and choose
~=instead of=for multi-token attribute values.
