A Guide to CSS Attribute Selectors Including Substring and Case-Insensitive Value Matching
Use CSS attribute selectors for substring and value matching, with specificity values and the case-insensitive flag that shipped across engines.
A common wrong assumption about CSS attribute selectors is that they are a modern convenience, something you reach for when a class-based approach fails. The substring matching variants have been in engines since 2004 and 2008. They replace JavaScript loops that front-end developers still write today. An attribute selector is a declarative constraint: it tells the engine to match an element based on the value of an attribute, exactly once, at style resolution time. It is not a hook you attach. It is a predicate the cascade evaluates. The consequence is that you can delete a querySelectorAll call and a class-toggling loop, replace them with a rule that never runs as a side effect, and get the same visual result with less code that cannot fail at the wrong moment. That shift, from procedural to declarative, is the core of what follows.
The Syntax of Every Attribute Selector Variant
The attribute selector family has seven forms. Each one answers a different matching question. The simplest is [attr], which matches any element that has the attribute, regardless of its value. [attr=value] matches an exact value. The substring variants extend that: [attr^=value] matches when the attribute value begins with the supplied prefix, [attr$=value] matches when it ends with the supplied suffix, and [attr*=value] matches when the value contains the supplied substring anywhere. Two list-based forms exist as well. [attr~=value] matches a whitespace-separated list where one full item equals the value. [attr|=value] matches a hyphen-separated value where the first segment equals the value. Each is a distinct predicate, and confusing them is the most common failure mode. For example, [href^="https://"] matches only links that start with that exact protocol. [href*="example"] matches any link whose value contains that string anywhere. [href$=".pdf"] matches only links that end with that extension. The specificity of every one of these variants is identical: the unitless triple (0, 1, 0). A single attribute rule outranks any number of type rules. div[href] is (0, 1, 1) because the type selector adds (0, 0, 1). It loses to a single class rule, which is also (0, 1, 0) and then resolves by source order. This specificity is the same for [attr^=value], [attr$=value], [attr*=value], [attr~=value], and [attr|=value]. The form of the operator does not change the weight.
CSS [href^=https] External Link Styling
Replace the JavaScript Loop
The first practical pattern is styling external links without a JavaScript loop. The older technique adds a class like class="prefix-external" to every link that leaves the site, or runs document.querySelectorAll('a[href^="https://"]') and then toggles a class on each match. Both are procedural. Both run after the DOM is ready. Both are fragile if the markup changes later. The CSS replacement is a single rule that the engine evaluates at style time, with no runtime cost and no timing dependency.
/* External link styling without JavaScript */
a[href^="https://"]:not([href*="example.com"]) {
padding-right: 1.2em;
background-image: url("data:image/svg+xml,...");
background-repeat: no-repeat;
background-position: right center;
background-size: 1em 1em;
color: #1a0dab;
}
Quote the Value and Avoid the Wrong Operator
That rule matches any anchor whose href begins with https:// and does not contain your own domain, so internal links stay unstyled. The common mistake here is forgetting to quote the value when it contains special characters, spaces, or starts with a digit. [href^=https://] is invalid because the unquoted value is parsed as a series of identifiers; it must be [href^="https://"]. Another mistake is confusing ^= with *=. [href^="example"] will not match href="https://www.example.com" because the value starts with https://, not with example. The substring variants shipped in Gecko in 2004 and in Blink and WebKit in 2008, per the caniuse.com feature entry for CSS attribute selectors. The failure case is a link that uses a protocol-relative URL like //example.com. It does not begin with https://, so it will not match. Handle it with a second rule using [href^="//"] if you need it.
CSS [data-*] Attribute Selector Component State
Move State Out of JavaScript
The second pattern moves component state out of JavaScript and into CSS. The older technique is a class-based approach where a framework router or a state manager adds a class like is-active or is-selected to an element, and the stylesheet targets that class. The class has no semantic meaning beyond the styling intent. It can be forgotten or mistyped. The data-* attribute is the standard mechanism for custom state, and the attribute selector pairs with it naturally.
<nav>
<a href="/" data-state="home">Home</a>
<a href="/about" data-state="about" aria-current="page">About</a>
<a href="/contact" data-state="contact">Contact</a>
</nav>
/* State-driven navigation highlight without a router class */
nav a[aria-current="page"] {
font-weight: 700;
color: #005a9c;
border-bottom: 2px solid currentColor;
}
/* Fallback for older browsers that ignore aria-current */
nav a[data-state="about"] {
font-weight: 700;
color: #005a9c;
border-bottom: 2px solid currentColor;
}
Specificity and the ARIA Fallback
The specificity here is still (0, 1, 0) for each rule. If you have a base rule like nav a at (0, 0, 2), the attribute rule wins without needing !important. The common mistake is relying on aria-current alone without verifying that the engine exposes it. It is an ARIA attribute. The CSS attribute selector matches it regardless of assistive technology support, so the styling works even if the ARIA semantics are not announced. The fallback for older browsers is the [data-state] rule, a pure attribute selector with no dependency on ARIA. The older technique replaced here is the framework router class, which required a JavaScript step after navigation. With the attribute selector, the markup carries the state natively and the engine computes the style.
CSS Attribute Selector Specificity Calculation
The Unitless Triple
The specificity of an attribute selector is exactly (0, 1, 0), the same as a class selector. This is the unitless triple that determines which rule wins when two rules match the same element. The calculation works by counting three categories: the number of ID selectors in the first slot, the number of class, attribute, and pseudo-class selectors in the second slot, and the number of type selectors and pseudo-elements in the third slot. So [href^="https"] is (0, 1, 0). div[href^="https"] is (0, 1, 1). #nav a[href^="https"] is (1, 1, 1). What matters for your daily work is that an attribute selector beats a type selector but not another attribute selector. If two attribute rules match the same element, the one later in the stylesheet wins. They have equal specificity. This is true for all seven forms.
The Case-Insensitive Modifier
It is true for the case-insensitive modifier i that you can append like [href^="https" i]. It does not change the specificity at all. The modifier shipped in Chrome 49, Safari 9, and Firefox 47, per MDN’s browser compat data for attribute selectors. The practical consequence is that you can use [attr^="value" i] to match values regardless of letter case, and it costs nothing in specificity. The failure case is assuming the i flag is supported everywhere. It is not in older browsers. If you need case-insensitive matching in a legacy environment, write two rules with explicit case variants instead.
CSS [attr~=value] Whitespace-Separated List Matching
Not a Substring Match
The [attr~=value] selector matches when the attribute value is a whitespace-separated list and one of the items equals the specified value. This is the same matching logic that governs the class attribute itself, which is why [class~="external"] is equivalent to .external in most cases. The pattern generalises to any attribute that carries a list, such as rel, data-tags, or aria-describedby. The common mistake is treating [attr~=value] as a substring match. It is not. [attr~="foo"] will not match data-tags="foobar" because the list item is foobar, not foo. The correct use is for attributes where the value is a space-separated set of tokens.
<a href="/print" rel="noopener noreferrer">Print this page</a>
/* Whitespace-separated list matching on rel */
a[rel~="noopener"] {
cursor: alias;
}
a[rel~="noreferrer"] {
text-decoration: underline dotted;
}
This works in every engine that has shipped since 2004, per the caniuse.com feature entry for CSS attribute selectors. The specificity is (0, 1, 0). The failure case is when the attribute value uses commas instead of spaces. rel="noopener,noreferrer" will not match because the comma is part of the token. The older technique this replaces is JavaScript-based attribute filtering, where you wrote document.querySelectorAll('[rel~="noopener"]') and then applied styles imperatively. The CSS rule makes that loop unnecessary.
CSS [attr|=value] Language Subcode Matching
Hyphen-Separated Prefixes
The [attr|=value] selector matches when the attribute value is exactly the specified value, or begins with the specified value followed by a hyphen. This is designed for language codes, where lang="en" and lang="en-US" are both valid for English content. The matching rule is precise: [lang|=en] matches lang="en" and lang="en-US", but not lang="enUS" or lang="en_US" because those use an underscore or no separator. The common mistake is using [attr*=value] instead. That would match any substring, including the wrong ones.
<p lang="en-US">This is American English.</p>
<p lang="en-GB">This is British English.</p>
<p lang="fr-FR">Ceci est en français.</p>
/* Language subcode matching */
p[lang|=en] {
font-style: normal;
quotes: "\201C" "\201D" "\2018" "\2019";
}
p[lang|=fr] {
quotes: "\00AB" "\00BB";
}
Declarative Language Styling
The specificity is (0, 1, 0). Support matches the rest of the attribute selector family: Gecko, Blink, and WebKit have all shipped it since their earliest versions, per the caniuse.com feature entry. The older technique this replaces is class-based prefix targeting, where you manually added a class like lang-en to every element based on its language. The attribute selector does that work declaratively from the lang attribute that is already present for accessibility and translation purposes. The failure case is using [lang|=en] on an attribute that does not follow the hyphen-separated format, such as lang="en_US". That will not match. You would need a different selector or a data attribute instead.
Case-Insensitive Matching with the i Flag
How the Flag Works
The case-insensitive modifier is a single letter i placed after the value, inside the square brackets: [attr^="value" i]. This makes the comparison ignore letter case, so [href^="https" i] matches href="HTTPS://example.com" as well as the lowercase form. The modifier works on all seven attribute selector forms, including [attr=value i], [attr~=value i], and [attr|=value i]. The specificity remains (0, 1, 0). The i flag adds no weight.
Engine Support and the Fallback
Blink shipped it in Chrome 49, WebKit in Safari 9, and Gecko in Firefox 47, per MDN’s browser compat data on the attribute selectors page. The common mistake is assuming the flag is universally supported. It is not in browsers older than those versions. If you target legacy browsers, you need a fallback. Write two rules with explicit case variants, such as [href^="https"] and [href^="HTTPS"], both with the same declarations. The practical use is for attributes where values are case-insensitive by convention, like rel or data-state, but where the markup may vary. The failure case is using the i flag in an @supports guard incorrectly. @supports selector([attr^="value"]) tests the selector syntax, not the flag, so you cannot use it to test for the i modifier. Instead, test with a rule that uses the flag and check if it applies, or rely on the version numbers above.
Combining Attribute Selectors with CSS Nesting and @supports
Nesting Without a Preprocessor
Modern CSS allows you to compose attribute selectors with other selector features, including CSS nesting and the @supports at-rule. CSS nesting, which shipped in all engines during 2023-2024, lets you write a rule inside another rule using the & token. You can group attribute-based styles under a parent selector. The specificity of the nested rule is still computed normally. The attribute selector inside keeps its (0, 1, 0) weight. The & acts as a placeholder that does not add specificity on its own.
When To Use @supports
The @supports guard is useful when you combine an attribute selector with a newer selector feature that might not be supported everywhere. The syntax for testing an attribute selector is @supports selector([attr^="value"]). This checks whether the engine can parse and apply that selector. Use this guard only when combining with newer selectors, such as :has() or :nth-child(an+b of selector). The attribute selector alone is old enough to be safe. The common mistake is using @supports for an attribute selector alone, which is unnecessary. The failure case is mixing attribute selectors with CSS nesting in a way that fails in older engines. If you use native nesting, the whole rule is dropped in engines that do not support nesting. The fallback must be an un-nested rule outside the nested block. The older technique replaced here is preprocessor nesting from Sass or Less, which required a build step and introduced its own specificity quirks. Native nesting with the & token behaves predictably, but it cannot concatenate strings to form selectors. You cannot write [data-state="&-"] to build dynamic class names.
Practical Sample: [data-state] Component Variant with CSS Custom Properties
State Without a Class Toggle
The third complete sample pairs the [data-state] attribute selector with CSS custom properties to create component variants without a single line of JavaScript. This pattern replaces the older technique of class-based prefix targeting, where you manually added classes like state-open or state-closed to buttons and then wrote separate rules for each class. With a data attribute, the state is explicit in the markup and the CSS reads it directly.
<button data-state="closed" aria-expanded="false">
<span class="label">Show details</span>
</button>
<div data-state="closed" class="panel">
<p>This panel is hidden by default.</p>
</div>
.panel {
--panel-opacity: 0;
--panel-visibility: hidden;
opacity: var(--panel-opacity);
visibility: var(--panel-visibility);
transition: opacity 0.3s ease, visibility 0.3s;
}
.panel[data-state="open"] {
--panel-opacity: 1;
--panel-visibility: visible;
}
button[data-state="open"] .label::after {
content: "Hide details";
}
How the Cascade Resolves
The specificity here is (0, 2, 0) for .panel[data-state="open"] because the class adds (0, 1, 0) and the attribute adds (0, 1, 0). That beats a bare .panel rule at (0, 1, 0). The custom properties inherit and cascade, so setting --panel-opacity inside the attribute-selected rule changes the used value. This pattern replaces a JavaScript state toggle that would flip a class on click. The CSS alone cannot respond to the click, so you still need a small script to change the data-state attribute, but the styling is entirely declarative. The failure case is forgetting that CSS custom properties are not supported in older engines, but those are far past their end of life. The common mistake is using content on a non-replaced element like a span. It does not work consistently. Use a pseudo-element and read a data-label attribute with attr() instead.
The Failure Case: When the Attribute Value Is Missing or Malformed
Silent Failures
Attribute selectors fail silently when the attribute is absent or the value does not match the expected format. The most common failure is a link that uses a protocol-relative URL or an uppercase protocol, which the case-sensitive [href^="https"] will not match. The fix is to add the i flag, if your engine support allows it, or to write a second rule. Another failure is an attribute value that contains special characters, such as a space or a quote. You must use quotes inside the selector. [data-tags="foo bar"] requires the quotes because the space separates tokens. Without quotes, the selector is invalid. The same applies to values that start with a digit: [data-id="123"] needs quotes.
Debugging Unstyled Elements
The failure case for [attr~=value] is a value that uses commas or other separators instead of spaces. The selector will not match because the token includes the separator. For [attr|=value], the failure is a value that uses an underscore instead of a hyphen, or a value where the subcode is not separated by a hyphen. When any of these failures happen, the element goes unstyled. The page renders with the default appearance. There is no error message. The practical response is to inspect the element in the developer tools, check the actual attribute value in the DOM, and verify the selector syntax. The older technique you are replacing, JavaScript-based attribute filtering, had the same failure mode, but it was easier to debug because you could log the query results. The CSS route gives you no console output, so you check the computed styles instead.
Browser Engine Support and Baselines, Without Guesswork
Ship Dates for the Core Variants
The attribute selector family, including the substring variants, is baseline-supported across all major engines. The ship dates are specific: Gecko shipped version 1.0 on 2004-11-09, Blink shipped version 1.0 on 2008-12-11, and WebKit shipped at its version 1.0 release, all per the caniuse.com feature entry for CSS attribute selectors. Presto and EdgeHTML also shipped the feature in their earliest versions, per the same sources.
The i Flag and the Real-World Gap
The case-insensitive i flag is newer, with Blink in Chrome 49, WebKit in Safari 9, and Gecko in Firefox 47, per MDN’s browser compat data. This means the substring matching variants are safe to use without a feature query in any engine that has shipped since 2009. The i flag is safe in any engine from 2016 onward. The failure case is assuming that a feature is supported everywhere. The real gap is the small population of users on older device-locked browsers, such as iOS Safari on unsupported devices or Android WebView in apps that do not update. For a public-facing page, that population exists but is not reliably measured by a single survey. If you use the i flag, provide a fallback rule. The @supports guard is not needed for the base attribute selectors. It is useful when you combine them with :has() or other newer features, using the syntax @supports selector([attr^="value"]) to test the selector’s parseability.
Common Mistakes and What To Do Instead
Quoting and Operator Confusion
The first mistake is forgetting to quote the value when it contains special characters, spaces, or starts with a digit. The rule: if the value contains anything other than alphanumeric characters, hyphens, or underscores, wrap it in quotes. [href^=https://] fails because the slashes and colon are parsed as syntax. [href^="https://"] works. The second mistake is confusing the operators. ^= means starts with. $= means ends with. *= means contains. ~= means whitespace-separated list item. |= means hyphen-separated prefix. A quick test: [href^="example"] will not match href="https://www.example.com" because the value starts with https://, not example. To match that link, you need [href*="example"].
Misplaced Responsibility
The third mistake is assuming the i flag works everywhere. It does not in engines released before 2016. Write a fallback. The fourth mistake is using an attribute selector where a class would be clearer, such as styling a component state that is not truly an attribute on the element. The attribute selector adds specificity without adding meaning if the attribute is not a real reflection of state. The fifth mistake is relying on content to change text inside an element. That only works on pseudo-elements. Use a data attribute and read it with attr() in a pseudo-element instead. The older technique you are replacing, class-based prefix targeting, had the same problem of naming collisions. The attribute selector gives you a key-value pair that is harder to confuse.
The Honest Caveat: Attribute Selectors Are Not a Performance Silver Bullet
Right-to-Left Matching
Attribute selectors do not make your CSS faster by themselves. The engine still needs to read the attribute value and compare it. A selector like [attr*=value] requires a substring search that is potentially more expensive than a class match, because the class lookup is hashed while the substring search is linear. In practice, for the number of elements on a typical page, the difference is measurable only in pathological cases: thousands of elements with long attribute values and a complex selector chain. The performance recommendation is to keep the right-to-left matching in mind. The engine matches the rightmost part of the selector first. a[href^="https"] matches anchors first and then checks the attribute, which is efficient because there are few anchors. A selector like [href^="https"] a would be terrible. It would match every element with an href and then look for an anchor descendant. That is the wrong order.
Know the Limit
Use the most specific rightmost component you can. Avoid [attr*=value] on large sets if you can use [attr^=value] instead. This is not a reason to avoid attribute selectors. It is a reason to write them with the matching direction in mind. The older technique, JavaScript attribute filtering, had the same performance characteristic but added the overhead of a DOM query and a style recalculation. The CSS route is better by comparison. The real limitation of attribute selectors is that they cannot match attribute values that are computed by JavaScript after load. The cascade resolves at style time. If you need to react to a state change, you must update the attribute itself. That is exactly what the data-state pattern does.
What This Page Does Not Cover, and Where To Go Next
The attribute selector family is part of a larger subject. This page does not cover the general selector syntax, the cascade, or the box model. The advanced selectors hub owns that category overview. It does not cover JavaScript-based attribute manipulation, because that is a programming question, not a CSS one. It does not cover CSS-in-JS libraries or framework-specific styling approaches. Those are tooling choices that change with each release. It does not cover the parent selector. That does not exist in CSS. It does not cover container queries or style queries in any depth. Those are separate features that answer different questions. It does not cover text-wrap: balance or view transitions. They are unrelated to attribute matching. If you need to understand how the cascade resolves conflicting rules, read the cascade and inheritance specification. If you need to style based on an element’s position in the document, read the structural pseudo-classes. If you need to respond to viewport size, read media queries. The attribute selector is a precise tool for a precise job: matching an element based on the value of one of its attributes, at style resolution time, with a known specificity of (0, 1, 0) for every variant. The failure case is using it for something it cannot do, like matching a computed style or a JavaScript variable, and then wondering why the rule does not apply. The advanced selectors hub covers those topics and points to the right specification for each feature.