CSS Reference 2026 — Chapter 1: Selectors and Specificity - vdsology
- CSS Reference 2026 — Chapter 1: Selectors and Specificity
- Part 1 — What Is a CSS Selector?
- Part 2 — Basic Selectors
- Part 3 — Attribute Selectors
- [attr] — Presence
- [attr="val"] — Exact Value
- [attr~="val"] — Space-Separated Word Match
- [attr|="val"] — Hyphen-Prefix Match
- [attr^="val"] — Starts With
- [attr$="val"] — Ends With
- [attr*="val"] — Substring Match
- Case Modifier — i (Case-Insensitive)
- Case Modifier — s (Case-Sensitive / Explicit)
- Combining Attribute Selectors
- Part 4 — Combinators
- Part 5 — Grouping Selectors
- Part 6 — Functional Selectors
- Part 7 — The Complete Specificity System
- Summary of Rules and Gotchas
CSS Reference 2026 — Chapter 1: Selectors and Specificity
What this chapter covers: Every CSS selector type — basic selectors (universal, type, class, ID), attribute selectors (all eight variants including the
iandsflags), all five combinators including the unimplemented column combinator, grouping selectors, and the four functional pseudo-class selectors (:is(), :where(), :not(), :has() with all their nuances). The chapter ends with the complete specificity scoring system — every rule, every edge case, every interaction. Specificity is inseparable from selectors, so it lives here.Not in this chapter: Pseudo-classes and pseudo-elements each have their own chapters (2 and 3). The full cascade order — how specificity interacts with origin, layers, and !important — is in Chapter 22.
Part 1 — What Is a CSS Selector?
A CSS selector is a pattern the browser evaluates against the document tree (the DOM) to determine which elements a set of style declarations applies to. Selectors are defined primarily in the CSS Selectors Level 4 specification, a W3C Candidate Recommendation that is part of the CSS Living Standard.
A complete style rule consists of a selector (or selector list), a declaration block, and the property declarations inside it:
selector {
property: value;
}
When the browser loads CSS, it performs selector matching: for every element in the DOM, it evaluates all rules and collects those that match. Those matching rules are then sorted by the cascade to determine which declarations win. Understanding selectors means understanding both what matches and what specificity score that match carries.
Forgiving vs. Non-Forgiving Selector Lists
This distinction is critical for progressive enhancement and is easy to get wrong.
Non-forgiving (traditional) selector lists are comma-separated selectors in ordinary rule blocks. If any selector in the list is invalid or unrecognized, the entire rule is silently discarded — not just the bad selector, the whole rule.
/* If a browser doesn't know :unsupported-pseudo, this
entire rule is dropped. Neither h1 nor h2 gets styled. */
h1, h2, :unsupported-pseudo {
color: red;
}
This matters enormously when mixing new pseudo-classes with old ones in a regular rule. You cannot write :user-valid, :valid in a single rule in browsers that don’t know :user-valid — the entire rule dies.
Forgiving selector lists are used inside :is(), :where(), and :has(). Invalid or unrecognized selectors are silently dropped from the list; the remaining valid ones still apply. This makes these functional pseudo-classes the correct tool for progressive enhancement:
/* Safe: :unsupported is ignored, h1 and h2 are still styled */
:is(h1, h2, :unsupported) {
color: red;
}
Part 2 — Basic Selectors
Universal Selector — *
What it is: Matches every element in the document regardless of type, class, ID, or position. It is the broadest possible selector.
Syntax:
*
What it does and when to use it: The universal selector is most commonly seen in CSS resets and in combinators where “any element” is the intent, not a specific element type.
/* Classic box-model reset — every element and its pseudo-elements */
*, *::before, *::after {
box-sizing: border-box;
}
/* Every direct child of .grid gets equal flex sizing */
.grid > * {
flex: 1 1 0;
}
/* Every element immediately following a .divider */
.divider + * {
margin-top: 2rem;
}
/* Every element inside a .card except the last child */
.card > *:not(:last-child) {
margin-bottom: 1rem;
}
Implicit universal selector: In compound selectors, * is almost always implied and omittable without any change in behavior or specificity:
*.active /* identical to */ .active
*:hover /* identical to */ :hover
*#header /* identical to */ #header
Specificity: 0-0-0-0 — the universal selector contributes zero to specificity in every column. This is intentional; it is the “match everything” baseline that nothing should override.
Namespace variants: In documents mixing HTML, SVG, and MathML, * matches elements in any namespace. You can be explicit with namespace prefixes:
@namespace svg url('http://www.w3.org/2000/svg');
/* Every element in any namespace */
*|* { }
/* Every SVG element */
svg|* { }
/* Every element with no namespace */
|* { }
Gotcha — performance: Using * as the right-hand operand of a descendant combinator (div *) forces the browser to evaluate every element inside div. Modern browsers handle this efficiently, but it is worth being conscious of in very large or rapidly updating DOMs.
Type Selector (Element Selector)
What it is: Matches all elements with the given HTML or XML tag name. It is the most primitive selector that actually targets a specific kind of element.
Syntax:
tagname
What it does: Applies styles globally to all occurrences of that element type in the document. Type selectors are foundational to establishing typographic and layout defaults.
/* Every paragraph */
p {
line-height: 1.7;
margin-bottom: 1em;
}
/* Every anchor element */
a {
color: #2563eb;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
/* All six heading levels */
h1, h2, h3, h4, h5, h6 {
font-family: 'Georgia', serif;
line-height: 1.2;
font-weight: 700;
}
/* Every button, regardless of type attribute */
button {
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
/* SVG elements inside HTML documents */
svg {
display: block; /* removes descender space */
overflow: visible;
}
path {
fill: currentColor;
}
Specificity: 0-0-0-1 — one element in the rightmost column.
Case sensitivity: In HTML documents, type selectors are case-insensitive for HTML elements. DIV, div, and Div all match <div>. In XML and XHTML documents they are case-sensitive. SVG element type selectors in an SVG namespace are case-sensitive.
Custom elements: Type selectors match custom elements by their tag name, regardless of whether the custom element class has been registered via JavaScript. The :defined pseudo-class (Chapter 2) lets you distinguish between registered and unregistered custom elements.
/* Matches immediately, even before JS registers the element */
my-tooltip {
display: block;
position: absolute;
}
/* Only applies after customElements.define('my-tooltip', ...) runs */
my-tooltip:defined {
/* safe to assume full component behavior */
}
Namespace-qualified type selectors: Rarely needed but important in mixed-namespace documents:
@namespace html url('http://www.w3.org/1999/xhtml');
@namespace svg url('http://www.w3.org/2000/svg');
html|a { color: blue; } /* HTML <a> links only */
svg|a { fill: blue; } /* SVG <a> links only */
Class Selector — .classname
What it is: Matches any element that has the specified token in its class attribute. The class attribute is a space-separated list of tokens; the class selector matches if the target token appears anywhere in that list.
Syntax:
.classname
tagname.classname /* compound: specific element type with this class */
.class1.class2 /* compound: element with BOTH classes simultaneously */
What it does: Class selectors are the most common selector in production CSS. They express intent (“this is a card”, “this is a button in primary style”) without coupling to HTML structure.
/* Any element classed as "card" */
.card {
border-radius: 12px;
padding: 1.5rem;
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
}
/* Only <article> elements that are also .featured */
article.featured {
border-left: 4px solid #f59e0b;
background: #fffbeb;
}
/* Element must have ALL THREE classes at once */
.btn.btn--primary.btn--large {
padding: 0.875rem 2rem;
font-size: 1.125rem;
}
/* Utility class for screen-reader-only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
How multiple class matching works:
<div class="card featured dark">...</div>
This element matches: .card, .featured, .dark, .card.featured, .card.dark, .featured.dark, .card.featured.dark. All of these selectors independently match it.
Specificity: 0-0-1-0 per class token. A compound selector .a.b.c scores 0-0-3-0.
Gotcha — case sensitivity: Class names are always case-sensitive. .Card and .card are different selectors, and class="Card" does NOT match .card. Unlike HTML element names, class names receive no case normalization.
Gotcha — ordering is irrelevant: .btn.primary and .primary.btn are identical selectors — both require the element to have both tokens in its class attribute. Similarly, class="primary btn" and class="btn primary" both match .btn.primary.
ID Selector — #idname
What it is: Matches an element whose id attribute value is exactly the specified string. In valid HTML, id values must be unique within a document — no two elements should share the same ID.
Syntax:
#idname
tagname#idname /* compound: specific element type with this ID */
What it does: ID selectors target a single specific element by its unique identifier. They are most useful for page-level landmarks, JavaScript targets, and fragment navigation anchors.
#site-header {
position: sticky;
top: 0;
z-index: 200;
background: white;
border-bottom: 1px solid #e2e8f0;
}
#main-content {
max-width: 65ch;
margin-inline: auto;
padding-inline: 1.5rem;
}
/* Compound: only a <nav> with this specific ID */
nav#primary-nav {
display: flex;
gap: 2rem;
align-items: center;
}
Specificity: 0-1-0-0 — one ID in the second column. This is the highest single specificity value a selector can contribute (outside of inline styles and !important). One ID outweighs any number of classes, attributes, and type selectors combined:
.a.b.c.d.e.f.g.h.i.j = 0-0-10-0 (loses to one ID)
div p span em code = 0-0-0-5 (loses to one ID)
#hero = 0-1-0-0 (wins)
Why IDs are often avoided in CSS: Because of their extreme specificity weight, ID selectors are commonly avoided in component-based CSS authoring. Once you write #hero { color: red; }, overriding it requires either another ID, a still-more-specific compound selector, or !important. This creates specificity debt. Most style guides recommend using classes for styling and IDs only for JavaScript hooks and in-page anchor targets.
Gotcha — duplicate IDs and CSS: HTML requires unique IDs, but CSS does not enforce this. If your DOM has two elements with id="sidebar" (malformed HTML), #sidebar matches both of them. Browsers do not reject duplicate IDs at the CSS level.
Gotcha — special characters in IDs: ID values can legally contain characters like ., #, [, : — but using those in CSS selectors requires escaping:
/* Element with id="section.one" */
#section\.one { }
/* Element with id="2col" — leading digit must be escaped */
#\32 col { }
Part 3 — Attribute Selectors
Attribute selectors match elements based on the presence or value of their HTML attributes. All attribute selectors score 0-0-1-0 (same as a class selector). They are one of the most versatile selectors in CSS and are particularly useful for styling based on semantic meaning rather than added classes.
[attr] — Presence
Matches any element that has the specified attribute, regardless of its value.
/* Every element with a disabled attribute (regardless of value) */
[disabled] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Every element with a data-tooltip attribute */
[data-tooltip] {
position: relative;
cursor: help;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #1e293b;
color: white;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 150ms;
}
[data-tooltip]:hover::after {
opacity: 1;
}
/* Any input that has a placeholder defined */
input[placeholder] {
/* may want different padding for inputs with placeholder */
}
Important note on boolean attributes: HTML boolean attributes (disabled, checked, required, hidden, readonly, open, etc.) are considered present when the attribute exists, regardless of what value (if any) you give them. All of disabled, disabled="", disabled="disabled", disabled="false" match [disabled]. The string value of a boolean attribute is semantically irrelevant in HTML.
[attr="val"] — Exact Value
Matches elements where the attribute’s value is exactly and entirely equal to the specified string.
/* Input whose type is exactly "email" */
input[type="email"] {
font-family: monospace;
}
/* Input whose type is exactly "range" */
input[type="range"] {
accent-color: #2563eb;
width: 100%;
}
/* Links pointing exactly to the root */
a[href="/"] {
font-weight: 700;
}
/* ARIA roles */
[role="banner"] {
border-bottom: 1px solid #e2e8f0;
}
[role="main"] {
min-height: 60vh;
}
[role="contentinfo"] {
background: #1e293b;
color: white;
}
Gotcha — exact means exactly the entire value: [type="text"] does NOT match type="text/plain". The entire attribute value must equal the string, not just start with it or contain it.
[attr~="val"] — Space-Separated Word Match
Matches elements where the attribute’s value is a whitespace-separated list of tokens and one of those tokens exactly equals the specified string. This is actually how .classname works internally — it is equivalent to [class~="classname"].
/* Equivalent to .card */
[class~="card"] { }
/* Elements whose data-features attribute lists "lazy-load" as one feature */
[data-features~="lazy-load"] {
loading: lazy; /* not CSS, just illustration */
}
<!-- MATCHES [data-features~="lazy-load"] -->
<img data-features="lazy-load responsive retina" src="...">
<!-- Does NOT match — "lazy-load" is not a whole word in this value -->
<img data-features="lazy-loading responsive" src="...">
Use case: Most useful for attributes designed to hold space-separated lists of tokens, similar to how class works. Commonly seen with custom data-* attributes.
[attr|="val"] — Hyphen-Prefix Match
Matches elements where the attribute’s value either:
- Is exactly the specified string, or
- Begins with the specified string followed immediately by a hyphen (
-)
This was designed specifically for language codes following BCP 47 format.
/* Matches lang="en", lang="en-US", lang="en-GB", lang="en-AU", etc. */
[lang|="en"] {
font-family: 'Merriweather', Georgia, serif;
hyphens: auto;
quotes: "\201C" "\201D" "\2018" "\2019";
}
[lang|="de"] {
hyphens: auto;
quotes: "\201E" "\201C" "\201A" "\2018";
}
[lang|="zh"] {
font-family: 'Noto Sans SC', 'Source Han Sans', sans-serif;
line-height: 1.9;
word-break: keep-all;
}
[lang|="ar"] {
direction: rtl;
font-family: 'Noto Sans Arabic', sans-serif;
}
Gotcha: [lang|="en"] does NOT match lang="engine", because the hyphen must follow the prefix directly. [lang|="en"] only matches the exact value en or values beginning with en-.
[attr^="val"] — Starts With
Matches elements where the attribute’s value begins with the specified string.
/* External links */
a[href^="https://"],
a[href^="http://"] {
/* Add visual indicator that link opens external site */
}
/* All telephone links */
a[href^="tel:"]::before {
content: "📞 ";
}
/* All email links */
a[href^="mailto:"]::before {
content: "✉ ";
}
/* All links to PDFs specifically at /downloads/ */
a[href^="/downloads/"] { }
/* All IDs starting with "section-" get scroll offset */
[id^="section-"] {
scroll-margin-top: 80px;
}
/* Images from a specific CDN */
img[src^="https://cdn.example.com/"] {
border-radius: 8px;
}
[attr$="val"] — Ends With
Matches elements where the attribute’s value ends with the specified string.
/* Links to PDF files */
a[href$=".pdf"] {
padding-right: 1.5em;
background: url('/icons/pdf.svg') no-repeat right 0.2em center;
background-size: 1em;
}
/* Links to specific file types */
a[href$=".zip"],
a[href$=".tar.gz"] {
/* download indicator */
cursor: progress;
}
/* Image sources that are SVG */
img[src$=".svg"] {
/* SVGs don't need max-width: 100% usually */
}
/* Scripts from a .min.js bundle */
/* (Would appear in attribute value selectors for custom purposes) */
[attr*="val"] — Substring Match
Matches elements where the attribute’s value contains the specified string anywhere within it.
/* Links to any YouTube URL */
a[href*="youtube.com"],
a[href*="youtu.be"] {
color: #dc2626;
}
/* Any class containing "icon" as part of a longer class name */
/* (Matches "btn-icon", "icon-arrow", "my-icon-set") */
[class*="icon"] {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* data-component attributes mentioning "modal" anywhere */
[data-component*="modal"] {
position: fixed;
z-index: 1000;
}
Gotcha — this is substring, not word: [class*="is"] matches class="visible" (contains “is”), class="disabled" (contains “is”), and class="is-active" (starts with “is”). If you want word-level matching, use [class~="is"]. Substring matching with *= is a broad net.
Case Modifier — i (Case-Insensitive)
Adding i (or I) as a flag after the value makes the comparison case-insensitive, regardless of the document language’s normal case rules.
/* Matches type="text", type="TEXT", type="Text", etc. */
input[type="text" i] {
border: 1px solid #94a3b8;
}
/* Match file extensions regardless of case */
a[href$=".PDF" i] { } /* matches .pdf, .PDF, .Pdf */
a[href$=".jpg" i] { } /* matches .jpg, .JPG, .Jpg */
/* Case-insensitive language codes */
[lang="en-us" i] { } /* matches en-US, EN-US, en-us */
Browser support: All modern browsers. Baseline 2016.
Gotcha: The i flag applies to the value you are matching against, not to the attribute name. Attribute names in HTML are already case-insensitive by virtue of HTML parsing.
Case Modifier — s (Case-Sensitive / Explicit)
Adding s (or S) forces case-sensitive matching. This matters in XML documents where attribute values are case-sensitive by default, or when you want to override a context where values might be normalized.
/* Only exactly this casing, even in contexts that might normalize */
[data-state="Active" s] { } /* does NOT match "active" or "ACTIVE" */
When you actually need s: In XML documents and in some edge cases with XHTML, where the default comparison behavior might otherwise be case-insensitive. In HTML documents, attribute value comparisons are already case-sensitive for most attributes (except some specific HTML-defined attributes like type on form elements in some contexts), so s is rarely needed.
Browser support: All modern browsers.
Combining Attribute Selectors
Attribute selectors can be chained into compound selectors with each other and with type, class, and ID selectors:
/* An input that is type="text" AND has a required attribute
AND has a placeholder */
input[type="text"][required][placeholder] {
border-left: 3px solid #f59e0b;
}
/* A link that starts with https AND ends with .pdf */
a[href^="https"][href$=".pdf"] {
color: #dc2626;
}
/* An <img> with a specific alt pattern — contains "logo" */
img[alt*="logo"][src^="/brand/"] {
height: 2rem;
width: auto;
}
Each attribute selector in the chain adds 0-0-1-0 to specificity. Two attribute selectors together score 0-0-2-0.
Part 4 — Combinators
Combinators express relationships between selectors. They do not match elements themselves — they describe the structural relationship between the element matched by the left selector and the element matched by the right selector. Combinators contribute 0-0-0-0 to specificity.
There are five combinators in CSS Selectors Level 4.
Descendant Combinator — space ( )
Syntax: A B (whitespace between A and B)
What it matches: Elements matching B that are anywhere inside an element matching A — not just direct children, but any descendant at any depth.
/* Any <a> anywhere inside a <nav> */
nav a {
text-decoration: none;
font-weight: 500;
}
/* Any <li> anywhere inside a .sidebar */
.sidebar li {
padding: 0.25rem 0;
}
/* Any <input> inside a .form-group, even nested deeply */
.form-group input {
width: 100%;
}
/* Any <strong> inside a <blockquote> */
blockquote strong {
color: #dc2626;
}
/* Any .error inside a .form */
.form .error {
color: #dc2626;
font-size: 0.875rem;
}
Important behavior: “Descendant” means child, grandchild, great-grandchild, etc. — any level of nesting. The intermediate elements between A and B are irrelevant.
<nav> <!-- matches the A in "nav a" -->
<ul> <!-- intermediate, ignored -->
<li> <!-- intermediate, ignored -->
<a href="/">Home</a> <!-- matches the B — this is styled -->
</li>
</ul>
</nav>
Gotcha — whitespace is the combinator: Any whitespace (space, tab, newline) between two simple selectors creates a descendant combinator. This means nav a, nav a, and nav\na are all identical. Be careful when formatting complex selectors across lines.
Performance note: The browser reads selectors right-to-left for matching. nav a is read as “find all a elements, then check if any of their ancestors is a nav.” This makes the descendant combinator inherently broader than the child combinator.
Child Combinator — >
Syntax: A > B
What it matches: Elements matching B that are direct children (one level deep only) of an element matching A.
/* Only <li> elements that are direct children of <ul> */
ul > li {
list-style: disc;
}
/* Prevents styling of nested li */
/* <ul> > <li class="parent"> > <ul> > <li> — inner li is not styled */
ul > li > ul > li {
list-style: circle;
}
/* Only the direct children of a flex container */
.flex-container > * {
flex: 1 1 0;
min-width: 0; /* prevent flex overflow */
}
/* Only direct paragraph children of article,
not paragraphs inside nested divs */
article > p {
font-size: 1.125rem;
line-height: 1.8;
}
/* Direct children of a grid */
.grid > .grid__item {
grid-column: span 1;
}
When child vs descendant matters:
<div class="card">
<p>This paragraph IS a direct child of .card</p>
<div class="card__body">
<p>This paragraph is NOT a direct child of .card</p>
</div>
</div>
.card > p matches only the first <p>. .card p matches both.
Performance: Child combinators are more performant than descendant combinators because the browser’s right-to-left matching only needs to check the immediate parent rather than traversing the entire ancestor chain.
Adjacent Sibling Combinator — +
Syntax: A + B
What it matches: An element matching B that immediately follows an element matching A in the DOM, and both share the same parent.
“Immediately follows” means there are no element nodes between them. Whitespace text nodes and comments between them do not count.
/* The paragraph immediately after an h2 */
h2 + p {
font-size: 1.125rem;
color: #475569;
margin-top: 0.5rem;
}
/* The label immediately after a checkbox input */
input[type="checkbox"] + label {
cursor: pointer;
font-weight: 500;
}
/* The error message immediately after an invalid input */
input:invalid + .error-message {
display: block;
color: #dc2626;
}
/* First paragraph after any heading (the "lede") */
:is(h1, h2, h3) + p {
font-size: 1.25rem;
line-height: 1.6;
color: #475569;
}
/* Lobotomized owl: margin between adjacent siblings */
/* Less aggressive than * + * but illustrates the concept */
p + p {
margin-top: 1rem;
}
The “owl operator” pattern: The adjacent sibling combinator is the backbone of the “lobotomized owl” pattern (* + *) for spacing that only applies between siblings:
/* Add top margin only when an element follows another element */
/* Avoids unwanted margin on the first child */
.stack > * + * {
margin-top: 1.5rem;
}
This is cleaner than margin-top on every child and then removing it from the first child.
General Sibling Combinator — ~
Syntax: A ~ B
What it matches: All elements matching B that follow an element matching A in the DOM (at any distance after it), with both sharing the same parent. Unlike +, there can be any number of elements between A and B.
/* All paragraphs after the first h2, within the same parent */
h2 ~ p {
font-size: 1rem;
}
/* All .tab-panel elements after a .tab-panel.active */
/* Used in pure-CSS tab patterns */
.tab-panel.active ~ .tab-panel {
display: none;
}
/* All checkboxes after the "select all" checkbox */
#select-all:checked ~ input[type="checkbox"] {
/* checked state styling */
}
/* All .note elements after a .warning in the same section */
.warning ~ .note {
border-left-color: #f59e0b;
}
Important limitation: The general sibling combinator only matches elements that come after A in the DOM source order. CSS has no “preceding sibling” combinator. To select an element based on what comes after it, you use :has() (covered below).
/* This is NOT possible with ~ alone: style h2 if it's followed by a p */
/* Instead use :has(): */
h2:has(+ p) {
margin-bottom: 0.25rem;
}
Column Combinator — ||
Syntax: A || B
What it matches: Elements matching B that belong to the column represented by the <col> or <colgroup> element matching A. It is designed for styling table cells based on which column they belong to.
/* Style all cells belonging to the .selected column,
including cells spanning into that column */
col.selected || td {
background: #dbeafe;
}
col.highlight || th,
col.highlight || td {
border-inline: 2px solid #2563eb;
}
<table>
<colgroup>
<col>
<col class="selected">
<col>
</colgroup>
<tbody>
<tr>
<td>A</td>
<td>B</td> <!-- matched by col.selected || td -->
<td>C</td>
</tr>
<tr>
<td colspan="2">D</td> <!-- also matched: spans into column 2 -->
<td>E</td>
</tr>
</tbody>
</table>
The key capability: The column combinator correctly handles colspan — a cell that spans across the selected column is still matched, even if it originates in a different column. This is impossible to achieve with any other selector.
Browser support: ⚠️ No browser currently implements the column combinator. As of April 2026, it is defined in the CSS Selectors Level 4 specification and has been for years, but Chrome, Firefox, and Safari have all not shipped it. It is still a valuable thing to know exists (for future use and to understand the spec), but do not use it in production.
Part 5 — Grouping Selectors
Selector List — ,
Syntax: A, B, C { }
What it does: Applies the same set of declarations to multiple selectors simultaneously. It is purely a syntactic convenience — there is no specificity interaction between the listed selectors; each is evaluated independently.
/* All heading levels share base typography */
h1, h2, h3, h4, h5, h6 {
font-family: 'Georgia', serif;
font-weight: 700;
line-height: 1.2;
}
/* Reset button and input */
button,
input,
select,
textarea {
font-family: inherit;
font-size: inherit;
}
/* Multiple pseudo-class states */
a:link,
a:visited {
color: #2563eb;
}
a:hover,
a:focus-visible {
color: #1d4ed8;
text-decoration: underline;
}
The critical gotcha — one bad selector kills the whole rule: In a regular CSS rule, if any selector in a comma-separated list is syntactically invalid or unrecognized, the browser drops the entire rule. No declarations apply to any of the listed selectors.
/* If browser doesn't know :user-valid, the ENTIRE rule is dropped */
/* input:valid is also affected — it gets nothing */
input:valid,
input:user-valid {
border-color: green;
}
/* Correct approach: separate rules */
input:valid { border-color: green; }
input:user-valid { border-color: green; }
/* Or use :is() which has forgiving parsing */
input:is(:valid, :user-valid) { border-color: green; }
Each selector retains its own specificity: Writing h1, #main does not give h1 the specificity of #main. Each selector is matched and scored independently. h1 still scores 0-0-0-1 and #main scores 0-1-0-0.
Part 6 — Functional Selectors
These four functional pseudo-classes are the most powerful tools for writing flexible, maintainable selectors. They are covered here rather than Chapter 2 because their primary function is as selector combinators and grouping tools, and their specificity rules are central to the specificity system covered at the end of this chapter.
:is() — Matches Any
Syntax:
:is( selector-list )
What it does: Matches an element if it matches any selector in the argument list. It functions identically to a comma-separated selector list, but with two critical differences: it uses forgiving parsing (invalid selectors are ignored) and it takes the specificity of its most specific argument.
/* Style all headings without repeating the declarations */
:is(h1, h2, h3, h4, h5, h6) {
font-family: 'Georgia', serif;
line-height: 1.2;
}
/* Any heading inside a .card or .panel */
:is(.card, .panel) :is(h2, h3) {
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
/* Combine multiple states efficiently */
button:is(:hover, :focus-visible) {
background: #1d4ed8;
outline: 2px solid #93c5fd;
outline-offset: 2px;
}
/* Equivalent to a:link, a:visited but also forgiving */
a:is(:link, :visited) {
color: #2563eb;
}
/* Reduce repetition in complex selectors */
/* Without :is(): */
.nav > ul > li > a,
.nav > ol > li > a {
text-decoration: none;
}
/* With :is(): */
.nav > :is(ul, ol) > li > a {
text-decoration: none;
}
Where :is() really shines — avoiding selector list explosion:
Without :is(), styling links in multiple contexts requires exhaustive combinations:
/* The old way — 6 selectors for 3 containers × 2 elements */
header a, header button,
footer a, footer button,
main a, main button {
/* ... */
}
/* With :is() — one clean selector */
:is(header, footer, main) :is(a, button) {
/* ... */
}
Specificity of :is(): Takes the specificity of the most specific selector in its argument list, even if that most specific selector didn’t actually cause the match.
/* Specificity = 0-1-0-0 (from #main, the most specific arg) */
/* Even when it matches h2 without the ID */
:is(h2, #main) {
color: blue;
}
This is a gotcha: :is(h1, .title, #hero) scores 0-1-0-0 for every element it matches — including h1 elements that have no class or ID relationship to #hero.
Nested :is(): Fully valid and useful:
:is(article, section):is(.featured, .highlighted) > :is(h2, h3) {
color: #f59e0b;
}
:where() — Zero-Specificity Match
Syntax:
:where( selector-list )
What it does: Functionally identical to :is() — it matches an element if it matches any selector in its argument list, uses forgiving parsing, and accepts the same kinds of selectors. The one and only difference from :is() is its specificity: always zero — 0-0-0-0.
/* This applies color, but with zero specificity */
/* Any class or type selector will override it */
:where(h1, h2, h3) {
color: #1e293b;
}
/* Intentionally weak base style that components can easily override */
:where(button) {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
/* Framework or reset styles that should never win specificity battles */
:where(*) {
box-sizing: border-box;
}
/* A "layer 0" for default link styles */
:where(a) {
color: #2563eb;
text-decoration: underline;
}
When to use :where() vs :is():
Use :where() when you want to apply default or base styles that you expect component-level or utility styles to easily override without needing to write more specific selectors or use !important. It is the right choice for:
- CSS resets and normalizations
- Framework base styles
- Default states that components customize
- Styles inside
@layerblocks where you want the layer’s inherent ordering to control priority, not specificity inflation
Use :is() when you want the specificity of your actual selectors to be preserved for normal cascade resolution.
/* A well-structured reset using :where() */
:where(*, *::before, *::after) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:where(h1, h2, h3, h4, h5, h6) {
font-size: inherit;
font-weight: inherit;
}
:where(ul, ol) {
list-style: none;
padding: 0;
}
/* These one-word class selectors (0-0-1-0) easily override the :where() resets */
.heading { font-weight: 700; font-size: 2rem; }
.list { list-style: disc; padding-left: 1.5rem; }
:not() — Exclusion
Syntax:
:not( selector-list )
What it does: Matches elements that do not match any selector in the argument list. CSS Selectors Level 4 upgraded :not() from accepting only a single simple selector to accepting a full selector list (multiple selectors, including compound and complex selectors).
Basic usage:
/* Every element that is not a paragraph */
:not(p) {
font-style: normal;
}
/* All inputs except hidden ones */
input:not([type="hidden"]) {
border: 1px solid #94a3b8;
padding: 0.5rem 0.75rem;
border-radius: 6px;
}
/* All links except those already styled .btn */
a:not(.btn) {
text-decoration: underline;
color: #2563eb;
}
/* Every list item except the last */
li:not(:last-child) {
border-bottom: 1px solid #e2e8f0;
}
/* Every .card that is not .card--full-width */
.card:not(.card--full-width) {
max-width: 380px;
}
Multiple selectors in :not() (Selectors Level 4, all modern browsers):
/* Not a div, not a span, not a paragraph */
:not(div, span, p) {
display: block;
}
/* Inputs that are not disabled and not readonly */
input:not([disabled]):not([readonly]) {
background: white;
}
/* Equivalent with list: */
input:not([disabled], [readonly]) {
background: white;
}
/* Not any heading */
:not(h1, h2, h3, h4, h5, h6) {
font-size: 1rem;
}
Complex selectors inside :not():
/* Articles that don't have a .featured child */
article:not(:has(.featured)) {
opacity: 0.8;
}
/* Links that don't start with https */
a:not([href^="https"]) {
color: #dc2626;
}
/* List items that are not the last AND not the first */
li:not(:first-child, :last-child) {
/* middle items only */
}
Specificity of :not(): Takes the specificity of the most specific selector in its argument list, just like :is().
/* Specificity is 0-1-0-0 (from #special, the most specific arg) */
/* Applied to p elements that are not #special */
p:not(#special, .hidden) {
color: navy;
}
Double negation is illegal: :not(:not(p)) is invalid and ignored. You cannot negate a :not().
Gotcha — :not() cannot contain pseudo-elements: :not(::before) is invalid. :not() only accepts compound and complex selectors targeting elements, not pseudo-elements.
Gotcha — it matches the element, not the relationship: :not(div) p matches paragraphs that have some ancestor that is not a div — which is almost always true, since <html> and <body> are not divs. This almost certainly doesn’t do what you intended. If you want “paragraphs NOT inside a div,” use a different approach or reconsider the HTML structure.
:has() — Relational (Parent Selector)
Syntax:
:has( relative-selector-list )
What it does: This is the long-awaited parent selector. It matches an element if any of the relative selectors match a relationship starting from that element. The relative selectors in :has() are relative to the element being tested — they default to “has a descendant matching” but can be made specific using child, sibling, and adjacent combinators.
:has() is one of the most significant additions to CSS in years. It enables styling patterns that previously required JavaScript.
Basic descendant (default — “has a descendant matching”):
/* Figure elements that contain an img */
figure:has(img) {
border-radius: 8px;
overflow: hidden;
}
/* Articles that have a .featured-badge somewhere inside */
article:has(.featured-badge) {
border: 2px solid #f59e0b;
background: #fffbeb;
}
/* Navigation that has more than 5 items (indirect — with counter tricks) */
/* More practically: nav that has a .mobile-toggle */
nav:has(.mobile-toggle) {
/* mobile nav styles */
}
/* Form that contains at least one invalid field */
form:has(:invalid) {
/* Disable submit or show error summary */
}
form:has(:invalid) [type="submit"] {
opacity: 0.5;
pointer-events: none;
}
Direct child (> A):
/* <section> that has a direct child <h2> */
section:has(> h2) {
padding-top: 2rem;
}
/* <ul> that has a direct child <li> with class .active */
ul:has(> li.active) {
border-left: 3px solid #2563eb;
}
/* A div that directly contains a <video> */
div:has(> video) {
aspect-ratio: 16 / 9;
background: black;
}
Adjacent sibling (+ A):
/* Style h2 when it is immediately followed by a <p> */
h2:has(+ p) {
margin-bottom: 0.25rem;
}
/* Label that is immediately after a checked checkbox */
/* This works better than the checked + label pattern in some cases */
:has(input:checked + label) {
/* Style the wrapper */
}
/* dt immediately followed by another dt (no dd in between) */
dt:has(+ dt) {
color: #475569;
}
General sibling (~ A):
/* Checkbox label when any following checkbox in the group is checked */
input[name="options"]:has(~ input[name="options"]:checked) {
opacity: 0.7;
}
Replacing the removed :target-within:
The :target-within pseudo-class was removed from the CSS specification. The equivalent behavior — matching an element that contains the current URL fragment target — is now achieved with :has():
/* Highlight the section containing the current fragment target */
/* REMOVED: section:target-within { } */
/* CORRECT 2026 approach: */
section:has(:target) {
background: #fef9c3;
outline: 2px solid #fbbf24;
}
/* Expand a details element if it contains the target */
details:has(:target) {
open: true; /* not a real property — use JS */
}
/* More practically: */
details:has(:target) > summary {
color: #2563eb;
}
Media states with :has():
/* Page layout when sidebar has content */
.layout:has(.sidebar:not(:empty)) {
grid-template-columns: 1fr 320px;
}
/* Card that contains a video vs one that doesn't */
.card:has(video) {
padding: 0;
}
.card:not(:has(video)) {
padding: 1.5rem;
}
Forgiving parsing in :has(): Like :is() and :where(), :has() uses forgiving selector list parsing. Invalid selectors within it are ignored.
Specificity of :has(): Takes the specificity of the most specific selector in its argument list, just like :is() and :not().
/* Specificity of .container:has(#hero) is 0-1-1-0 */
/* (0-0-1-0 for .container + 0-1-0-0 for #hero inside :has()) */
.container:has(#hero) { }
What :has() cannot contain:
- Pseudo-elements (
::before,::after, etc.) — invalid - The
:has()pseudo-class itself — nesting:has()inside:has()is not defined and is not supported
Browser support: Chrome 105+, Safari 15.4+, Firefox 121+. Baseline 2023. Fully safe for production as of 2024-2025.
Performance note: :has() can be expensive when used broadly, because it requires the browser to check element descendants to resolve the selector. Modern browser engines have optimized this significantly, but avoid patterns like *:has(div) in large documents. Scoped usage (.component:has(.element)) is efficient.
Part 7 — The Complete Specificity System
Specificity is the mechanism CSS uses to decide which declaration wins when two or more rules apply to the same element and set the same property. It is a four-column score computed from the selector(s) that matched the element.
The Four Columns
Specificity is represented as four numbers: A - B - C - D
| Column | What it counts | Example |
|---|---|---|
| A | Inline styles | style="color: red" |
| B | ID selectors | #header, #nav |
| C | Class selectors, attribute selectors, pseudo-classes | .card, [type], :hover |
| D | Type selectors, pseudo-elements | div, p, ::before |
Columns are compared left to right. A higher value in a more-left column always wins, regardless of the values in columns to its right. There is no arithmetic — 1-0-0-0 beats 0-255-255-255. The columns are not decimal digits; they are separate integer counters.
Examples:
h1 = 0-0-0-1
.card = 0-0-1-0
#header = 0-1-0-0
style="" = 1-0-0-0
h1.title = 0-0-1-1
.card.featured = 0-0-2-0
#header nav = 0-1-0-1
#header .nav a = 0-1-1-1
div p span em = 0-0-0-4
.a.b.c.d = 0-0-4-0
#foo = 0-1-0-0 (beats all of the above)
What Contributes What
Inline styles (column A): The style attribute on an HTML element contributes 1-0-0-0. This beats every selector specificity except !important.
ID selectors (column B): #id contributes 0-1-0-0. Each ID in a compound selector adds one.
#a#b { } /* 0-2-0-0 — two IDs */
#a.b#c { } /* 0-2-1-0 — two IDs + one class */
Class selectors (column C): Each .classname contributes 0-0-1-0.
Attribute selectors (column C): Each [attr] or [attr="val"] contributes 0-0-1-0 — same as a class selector.
[type="text"] /* 0-0-1-0 */
[href][title][lang] /* 0-0-3-0 */
Pseudo-classes (column C): Each pseudo-class contributes 0-0-1-0.
:hover /* 0-0-1-0 */
:nth-child(2n+1) /* 0-0-1-0 */
:is(h1, h2) /* 0-0-0-1 (from h1/h2, type selectors) */
:not(.hidden) /* 0-0-1-0 (from .hidden) */
:has(> .featured) /* 0-0-1-0 (from .featured) */
Type selectors (column D): Each element type selector contributes 0-0-0-1.
div /* 0-0-0-1 */
div p span /* 0-0-0-3 */
Pseudo-elements (column D): Each ::pseudo-element contributes 0-0-0-1.
p::first-line /* 0-0-0-2 (p = 0-0-0-1, ::first-line = 0-0-0-1) */
.card::before /* 0-0-1-1 (.card = 0-0-1-0, ::before = 0-0-0-1) */
Universal selector and combinators (nothing): *, , >, +, ~, || all contribute 0-0-0-0. They are transparent to specificity.
* > * ~ * + * { } /* 0-0-0-0 — all zeros */
Specificity of Functional Pseudo-classes
This is where things get interesting.
:is() specificity — takes the most specific argument:
:is(div, .class, #id) { }
/* Specificity: 0-1-0-0 (from #id, the most specific argument) */
/* Even if the element matched because of div or .class */
This means the specificity of :is() is determined at write-time by examining the argument list, not at match-time by examining which argument actually matched. Every element matched by :is(div, .class, #id) gets 0-1-0-0 specificity — including plain div elements.
:not() specificity — takes the most specific argument (same rule):
:not(div, .class, #id) { }
/* Specificity: 0-1-0-0 (from #id) */
p:not(.hidden) { }
/* Specificity: 0-0-1-1 (p = 0-0-0-1, :not(.hidden) = 0-0-1-0) */
:has() specificity — takes the most specific argument (same rule):
article:has(.featured) { }
/* Specificity: 0-0-2-1 */
/* article = 0-0-0-1, :has(.featured) = 0-0-1-0, total = 0-0-1-1 */
/* Wait — let's count again: */
/* article contributes 0-0-0-1 */
/* :has(.featured): .featured is 0-0-1-0, so :has() contributes 0-0-1-0 */
/* Total: 0-0-1-1 */
div:has(#hero) { }
/* div = 0-0-0-1, :has(#hero) → #hero = 0-1-0-0 */
/* Total: 0-1-0-1 */
:where() specificity — always zero:
:where(div, .class, #id) { }
/* Specificity: 0-0-0-0 — always, regardless of arguments */
p:where(.featured, #special) { }
/* p = 0-0-0-1, :where() = 0-0-0-0 */
/* Total: 0-0-0-1 */
This is :where()’s defining feature and its primary use case. Use it when you want to apply a style without locking in specificity.
Worked Examples of Specificity Calculation
/* Selector: nav.primary > ul > li > a:hover */
/* nav = 0-0-0-1 (type) */
/* .primary = 0-0-1-0 (class) */
/* ul = 0-0-0-1 (type) */
/* li = 0-0-0-1 (type) */
/* a = 0-0-0-1 (type) */
/* :hover = 0-0-1-0 (pseudo-class) */
/* Total: 0-0-2-4 */
/* Selector: #main .card[data-status="active"]::after */
/* #main = 0-1-0-0 (ID) */
/* .card = 0-0-1-0 (class) */
/* [data-status="active"] = 0-0-1-0 (attribute) */
/* ::after = 0-0-0-1 (pseudo-element) */
/* Total: 0-1-2-1 */
/* Selector: :is(#sidebar, .panel) > :where(h2, h3) + p */
/* :is(#sidebar, .panel): most specific is #sidebar = 0-1-0-0 */
/* :where(h2, h3): always 0-0-0-0 */
/* p: 0-0-0-1 */
/* Total: 0-1-0-1 */
Specificity Ties
When two selectors have identical specificity and both apply to the same element for the same property, the source order wins: the declaration that appears later in the stylesheet wins.
/* Both have specificity 0-0-1-0 */
/* The second one wins because it comes later */
.error { color: red; }
.warning { color: orange; }
/* If an element has both classes, it gets color: orange */
/* <div class="error warning"> → color: orange */
This is why ordering of rules in a stylesheet matters even when specificity is equal.
!important and Its Interaction with Specificity
!important is not part of specificity. It is a separate layer of the cascade that overrides the entire specificity system. A declaration with !important wins over any declaration without !important, regardless of specificity. When two declarations both have !important, specificity is then used to break the tie (and the higher-specificity !important wins).
/* This wins over any non-!important declaration for color */
.error { color: red !important; }
/* This wins over the above even with lower specificity */
/* because it's also !important and comes later */
* { color: navy !important; }
/* Full interaction: two !important rules — specificity decides */
.error { color: red !important; } /* 0-0-1-0 with !important */
p { color: blue !important; } /* 0-0-0-1 with !important */
/* For a <p class="error">: .error wins (0-0-1-0 > 0-0-0-1) */
The full !important interaction with the cascade (including @layer behavior) is detailed in Chapter 22.
The Specificity of :nth-child() and Other Parametric Pseudo-classes
Parametric pseudo-classes like :nth-child(2n+1), :nth-of-type(3), :nth-child(2n+1 of .card) all contribute exactly 0-0-1-0 regardless of their arguments.
:nth-child(1) /* 0-0-1-0 */
:nth-child(100n + 7) /* 0-0-1-0 */
:nth-child(2n+1 of .card) /* 0-0-2-0 — the "of .card" adds 0-0-1-0 */
Note the of <selector> syntax: :nth-child(2n+1 of .featured) adds the specificity of .featured (0-0-1-0) to the pseudo-class itself (0-0-1-0) for a total of 0-0-2-0. The of argument is treated as if it were a :is() argument — it contributes the specificity of its most specific selector.
Specificity Quick-Reference Table
| Selector | Specificity |
|---|---|
* |
0-0-0-0 |
div |
0-0-0-1 |
.class |
0-0-1-0 |
[attr] |
0-0-1-0 |
:hover |
0-0-1-0 |
::before |
0-0-0-1 |
#id |
0-1-0-0 |
style="" |
1-0-0-0 |
:is(h1, #id) |
0-1-0-0 (from #id) |
:where(h1, #id) |
0-0-0-0 |
:not(.class) |
0-0-1-0 (from .class) |
:has(.class) |
0-0-1-0 (from .class) |
.a .b .c |
0-0-3-0 |
div p span |
0-0-0-3 |
#a .b div p::before |
0-1-1-2 |
Summary of Rules and Gotchas
Selector parsing is right-to-left: Browsers evaluate selectors from right to left for performance. nav a finds all a elements first, then checks ancestors for nav. This is why selectors with broad right-hand components (like nav *) are slower than specific ones.
Combinators are zero-specificity: All five combinators — descendant (space), child (>), adjacent sibling (+), general sibling (~), and column (||) — contribute nothing to specificity.
One bad selector kills a non-forgiving list: In regular rule blocks, an invalid selector in a comma list drops the whole rule. Use :is() or :where() to avoid this when mixing new pseudo-classes with old ones.
:is() and :not() take the highest-specificity argument: Even if a lower-specificity argument caused the match. Write argument lists in :is() and :not() with awareness of this — putting #id in an :is() list elevates the specificity of the entire selector for every match.
:where() is always zero: No matter what you put inside :where(), the contribution to specificity is always 0-0-0-0. This is its superpower for resets and base styles.
:has() is a read-forward selector: :has(+ p) lets you style an element based on what comes after it. No combinator can do this without :has(). Use it to replace the now-removed :target-within with :has(:target).
The column combinator || has no browser support: Defined in the spec, but as of April 2026, no browser implements it. Do not use it in production.
Specificity is per-declaration, not per-rule: Each property-value pair in a rule is evaluated independently against all matching declarations for that property. Two properties in the same rule can be overridden by different rules.
Tags: #CSS #css #vdsology #webdevelopment #vdsologyCSS #CSS3 #CSSreference #CSSreference2026
Write a comment