CSS & HTML

The Power of CSS Selectors and How to Use Them

By Vail Joy Updated 12 min read

The Power of CSS Selectors and How to Use Them

CSS selectors are how you tell the browser which elements a rule applies to, and today they can do far more than match a tag, class or ID. With :has(), :is(), :where(), :not(), :user-invalid and native nesting, you can style a parent from its children, react to form state without JavaScript, and keep specificity under control.

This guide walks through the selectors worth knowing, grouped by the job they do: matching states, picking elements by position, building relationships, matching attributes, and managing specificity. Every snippet comes from one small demo, shown below, that uses nothing but HTML and CSS. The full source is near the end of the guide, so you can paste it into a file and reproduce the screenshot.

A demo with a form showing green, red and disabled field states, numbered cards styled by position, and links with lock, PDF and arrow badges
The demo: form states, position-based cards and attribute-driven link badges, all pure CSS.

Anatomy of a selector, in one minute

A selector combines up to four ingredients. A type, class or ID picks elements by name. A combinator (space, >, +, ~) describes how two elements relate. An attribute selector such as [href$=".pdf"] matches on markup. A pseudo-class such as :hover matches on state, position or contents. A pseudo-element such as ::before targets a part of an element.

The browser matches selectors right to left, which is why a long chain is rarely a performance problem in modern engines. The real cost of long selectors is maintainability and specificity, and the newer selectors below help with both.

Style a parent with :has()

For years CSS could not select an element based on what it contained. :has() fixes that. It is Baseline widely available, with support in every major browser since December 2023 according to MDN.

/* A field wrapper that contains a required input gets an asterisk */
.field:has(input:required) label::after {
  content: " *";
  color: var(--bad);
}

/* Highlight a whole option row when its checkbox is checked */
.prefs label:has(:checked) {
  border-color: var(--accent);
  background: color-mix(in oklch, var(--accent) 10%, white);
}

/* Card layout changes only when it has an image */
.card:has(img) { grid-template-columns: 8rem 1fr; }

The first two rules replace what used to need a JavaScript class toggle or an extra wrapper in the markup. The third is a convenience rather than a revolution: you could always add a .card--with-image class by hand, but :has() removes the chance of forgetting it. :has() also takes relative selectors, so h2:has(+ p) matches a heading directly followed by a paragraph, and figure:not(:has(figcaption)) finds figures without a caption.

Two things to remember. Specificity of :has() equals that of its most specific argument. And :has() is not forgiving: if its argument is invalid or unsupported, the whole rule is invalidated, not just that part of the selector. Every current browser supports it, so you rarely need a guard, but when a rule is a pure enhancement you can gate it:

@supports selector(:has(*)) {
  .card:has(img) { grid-template-columns: 8rem 1fr; }
}

Browsers that fail the @supports selector() test skip the block, and the card keeps its single-column layout.

Form state without JavaScript

Form pseudo-classes let the browser’s built-in validation drive your design. The ones you will use most:

Pseudo-class Matches
:required / :optional Fields with or without the required attribute
:disabled / :enabled Fields that cannot / can be used
:read-only / :read-write Whether the user can edit the value (:read-only also matches most non-form elements)
:checked, :indeterminate, :default Checkbox, radio and option states
:in-range / :out-of-range Numeric and date inputs against min and max
:focus-within An element that has focus itself or contains a focused descendant, such as a field wrapper around an input
:empty Elements with no children at all, not even whitespace text
:placeholder-shown Inputs whose placeholder is currently visible, meaning they are empty
:user-valid / :user-invalid Valid or invalid, but only after the user has interacted

The last row is the most important change since the original version of this article. :invalid matches the instant the page loads, so an empty required field is already red before anyone types. :user-invalid waits until the user has interacted with the field or tried to submit, which is the behaviour you actually want. MDN lists it as Baseline widely available.

input:focus-visible {
  outline: 3px solid color-mix(in oklch, var(--accent) 40%, transparent);
  border-color: var(--accent);
}
input:user-valid   { border-color: var(--ok); }
input:user-invalid { border-color: var(--bad); }
input:disabled     { background: #e9ecf3; color: #7a8296; }

/* Show the error hint only for a field the user got wrong */
.field:has(input:user-invalid) .hint { display: block; }

Two more selectors earn their place in forms. :focus-within lets a wrapper react when any control inside it is focused, and :empty hides containers that have nothing in them, which is handy for message slots that a script or the server fills in only sometimes:

.field:focus-within label { color: var(--accent); }
.note:empty { display: none; }

Remember that :empty fails if the element contains even a space or a line break, so write <p class="note"></p> with nothing between the tags.

Note :focus-visible in place of plain :focus. It shows the focus ring for keyboard users and hides it for most mouse clicks, so you can keep a strong, accessible outline without the visual noise. Never remove focus styling without replacing it.

The :read-only and :read-write pair, which the old article called experimental, is now standard and ships in all current browsers. Still, a [readonly] attribute selector is often clearer when you only care about the attribute. For the placeholder, use ::placeholder; the old ::-webkit-input-placeholder prefix is no longer needed.

Colour alone is not enough to communicate an error. Pair the red border with visible text, as the demo does with its hint, and mark fields with aria-describedby where the message needs to be announced. For ready-made validated forms see our registration form with validation snippet.

Pick elements by position

:nth-child() and :nth-of-type() take an An+B formula. odd and even are shorthand for 2n+1 and 2n. A negative n counts from the start, so :nth-child(-n+3) matches the first three items. :first-child, :last-child, and :only-child cover the common edge cases.

.cards li:nth-child(odd)    { background: color-mix(in oklch, var(--accent) 22%, white); }
.cards li:nth-child(-n+2)   { outline: 2px solid var(--accent); }
.cards li:last-child        { background: var(--ok); color: #fff; }

The old use for these, clearing floats at every third item, is gone. Grid and flexbox handle row layout now, with gap for spacing; see Master CSS Flexbox in 5 Simple Steps. Position selectors remain useful for zebra striping, highlighting the first items, and removing the last border.

:nth-child() also accepts an of S filter, such as li:nth-child(2 of .visible), which counts only the siblings that match the selector. That solves the classic problem of zebra stripes breaking when some rows are hidden.

Combinators: describe relationships

Combinator Example Meaning
Descendant (space) .menu a Any link anywhere inside the menu
Child > .menu > li > a Only first-level links
Adjacent + label + input The input immediately after a label
General sibling ~ h3 ~ p Every later paragraph sibling of an h3

The child combinator keeps sub-menu links from inheriting top-level styling, which avoids a pile of overrides. For a menu pattern that works on small screens, see Four Elements of Truly Mobile-Friendly Responsive Menus. The adjacent combinator is ideal for spacing that depends on context, for example adding space only when a caption actually follows an image:

.image-wrap + .image-caption { margin-top: .5rem; }

Attribute selectors

Attribute selectors match on markup you already have, with no extra classes. The operators are = (exact), ^= (starts with), $= (ends with), *= (contains), ~= (one word in a space-separated list), and |= (exact or followed by a hyphen, used for language codes). Add a trailing i to ignore case.

The demo uses them to badge links. Instead of the Font Awesome glyph hack the old article showed, it uses plain generated content, so there is no icon font to load:

.links a[href^="https://"]::before { content: "\1F512  "; font-size: .8em; }
.links a[href$=".pdf" i]::after {
  content: " PDF";
  font-size: .7em;
  background: var(--bad);
  color: #fff;
  padding: .1em .4em;
  border-radius: 4px;
}
.links a[target="_blank"]::after { content: " \2197"; }

The i flag is why a link to BROCHURE.PDF still gets its badge. If you want a real icon, an inline SVG used as a mask-image on the pseudo-element lets it follow currentColor. Generated content is not reliably read by every screen reader, so treat it as decoration and keep the meaning in the link text. For another example of this idea, see How to Add Icon Fonts to Any Element With CSS.

Control specificity with :is(), :where() and :not()

:is() and :where() take a selector list and match any of its members. They shorten repetitive rules, and both ignore an invalid selector inside the list rather than discarding the whole rule. The difference is specificity: :is() takes the weight of its most specific argument, while :where() is always zero.

/* Before */
article h2, section h2, aside h2, nav h2 { font-size: 1.25rem; }

/* After */
:is(article, section, aside, nav) h2 { font-size: 1.25rem; }

/* Zero-specificity defaults that any class can override */
:where(.prose) a { color: var(--accent); text-decoration: underline; }

/* Interaction states in one rule */
a:is(:hover, :focus-visible) { text-decoration: underline; }

Use :where() for library and base styles so authors never need to fight your selectors. Both have been available across browsers since January 2021 (:is(), per MDN), and neither can match pseudo-elements.

:not() accepts a list too: a:not([href], [role="button"]) excludes both. In WordPress themes, the old trick body:not(.home) .site-header still works to style inner pages without resetting the homepage, and the block editor era adds classes you can target the same way.

Native nesting

Nesting lets you write related rules inside their parent, without Sass. The demo’s .field styles are written this way:

.field {
  display: grid;
  gap: .25rem;

  & label { font-weight: 600; }
  & input:user-invalid { border-color: var(--bad); }
  &:has(input:disabled) label { opacity: .55; }

  @media (min-width: 40rem) {
    gap: .5rem;
  }
}

Use & for compound cases like &:hover or &.active. Keep nesting to two or three levels; deep nesting recreates the over-specific selectors you were trying to avoid. Native nesting has worked in every major browser since December 2023 (Chrome and Edge 120, Firefox 117, Safari 17.2) and is now Baseline widely available, so you can ship it without a Sass build step. The relaxed syntax is supported too: you may write & label or a bare label inside a rule. The demo writes & explicitly, which also works everywhere.

Pseudo-elements worth knowing

Pseudo-elements use a double colon and style a part of an element. The useful ones today:

  • ::before and ::after insert decorative content, as in the link badges above.
  • ::first-letter creates drop caps, and ::first-line styles the opening line. The modern way to size a drop cap is initial-letter, though check support before relying on it.
  • ::selection restyles highlighted text. Keep contrast high for readability.
  • ::placeholder styles placeholder text; keep it readable.
  • ::marker styles list bullets and numbers.
p::selection { color: #fff; background: oklch(62% .15 40); }
li::marker   { color: var(--accent); }

The full demo

Here is the complete file behind the screenshot. Save it as selectors.html and open it in any current browser. The error states appear once you type in a field and leave it, because :user-invalid waits for interaction.

<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Selectors demo</title>
<style>
:root{--accent:oklch(60% .19 255);--ok:oklch(62% .16 150);--bad:oklch(58% .21 25);--ink:#1c2230;--line:#cfd6e4}
*{box-sizing:border-box}
body{font:16px/1.5 system-ui,sans-serif;color:var(--ink);background:#f4f6fb;margin:0;padding:2rem;display:grid;grid-template-columns:1fr 1fr;gap:2rem;align-items:start}
@media(max-width:800px){body{grid-template-columns:1fr}}
h2{margin:0 0 1rem;font-size:1.1rem}
h2.gap{margin-top:1.5rem}
section{background:#fff;border-radius:14px;padding:1.5rem;box-shadow:0 2px 12px #0001}
form{display:grid;gap:1rem}
.field{display:grid;gap:.25rem;
  & label{font-weight:600;font-size:.9rem}
  & input{font:inherit;padding:.6rem .75rem;border:2px solid var(--line);border-radius:8px}
  & input:focus-visible{outline:3px solid color-mix(in oklch,var(--accent) 40%,transparent);border-color:var(--accent)}
  & input:user-valid{border-color:var(--ok)}
  & input:user-invalid{border-color:var(--bad)}
  & input:disabled{background:#e9ecf3;color:#7a8296}
  & .hint{font-size:.8rem;color:#5b6479;display:none}
  &:has(input:user-invalid) .hint{display:block;color:var(--bad)}
  &:has(input:required) label::after{content:" *";color:var(--bad)}
  &:has(input:disabled) label{opacity:.55}
  &:focus-within label{color:var(--accent)}
}
.field:has(input:placeholder-shown) label{font-weight:400}
.note{margin:0;padding:.5rem .75rem;border-radius:8px;background:#fff4d6}
.note:empty{display:none}
.prefs{display:grid;gap:.5rem}
.prefs label{padding:.5rem .75rem;border:2px solid var(--line);border-radius:8px;display:flex;gap:.5rem}
.prefs label:has(:checked){border-color:var(--accent);background:color-mix(in oklch,var(--accent) 10%,white)}
.prefs label:has(:focus-visible){outline:3px solid color-mix(in oklch,var(--accent) 40%,transparent)}
ul.cards{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(4,1fr);gap:.5rem}
.cards li{padding:1rem;border-radius:8px;background:#e9ecf3;text-align:center;font-weight:600}
.cards li:nth-child(odd){background:color-mix(in oklch,var(--accent) 22%,white)}
.cards li:nth-child(-n+2){outline:2px solid var(--accent)}
.cards li:last-child{background:var(--ok);color:#fff}
.links{list-style:none;padding:0;display:grid;gap:.4rem}
.links a{color:var(--accent);text-decoration:none}
.links a[href^="https://"]::before{content:"\1F512  ";font-size:.8em}
.links a[href$=".pdf" i]::after{content:" PDF";font-size:.7em;background:var(--bad);color:#fff;padding:.1em .4em;border-radius:4px;margin-left:.3em}
.links a[target="_blank"]::after{content:" \2197"}
.links a:is(:hover,:focus-visible){text-decoration:underline}
:where(.links) a{color:var(--accent)}
</style></head><body>
<section><h2>Form state selectors</h2>
<form novalidate onsubmit="return false">
<div class="field"><label for="n">Name</label><input id="n" required minlength="2" placeholder="Your name" value="A"><span class="hint">At least two characters.</span></div>
<div class="field"><label for="e">Email</label><input id="e" type="email" required value="[email protected]"></div>
<div class="field"><label for="m">Coupon</label><input id="m" disabled value="EXPIRED"></div>
<div class="field"><label for="x">Backup email</label><input id="x" type="email" required value="not-an-email"><span class="hint">Enter a valid email address.</span></div>
<div class="prefs"><label><input type="checkbox" checked> Weekly digest</label><label><input type="checkbox"> Product news</label></div>
<p class="note"></p></form></section>
<section><h2>:nth-child and attribute selectors</h2>
<ul class="cards"><li>1</li><li>2</li><li>3</li><li>4</li></ul>
<h2 class="gap">Link badges</h2>
<ul class="links"><li><a href="https://developer.mozilla.org/">MDN (https)</a></li><li><a href="/files/brochure.PDF">Brochure (pdf)</a></li><li><a href="https://example.com/" target="_blank">External (new tab)</a></li><li><a href="/about">Plain link</a></li></ul></section>
</body></html>

Selector habits that hold up

  1. Prefer classes for components and use ID selectors only as link targets; IDs are hard to override.
  2. Reach for :where() in base styles and :is() to shorten lists.
  3. Use :has() for parent-aware styling instead of JavaScript class toggles, but keep the base design usable without it.
  4. Use :user-invalid rather than :invalid for visible error states.
  5. Always style :focus-visible.
  6. Do not use the universal reset * { margin: 0; padding: 0 } blindly. Target what you need, or use a short modern reset.

For the full reference, see the MDN selectors index. To keep levelling up, read 20 Pro CSS Tips, then add motion with Easy CSS Animation With Transition and Transforms.

Frequently asked questions

What is the difference between :is() and :where()?

They match the same things. :is() counts towards specificity as its most specific argument, while :where() always contributes zero, which makes it the right choice for easily overridden defaults.

Is :has() safe to use?

Yes. MDN lists it as Baseline widely available, with support in all major browsers since December 2023.

Why use :user-invalid instead of :invalid?

:invalid applies from page load. :user-invalid applies only after the user has interacted with the field, so you do not show errors on a blank form.

1 comment from the archive
  1. Thank you for this comprehensive article. I have only done custom themes for specific clients but the idea of creating a premium theme is appealing.

Comments are closed on this article.