Home/Learn/HTML, CSS & Responsive/Selectors and Pseudo-Classes

Selectors and Pseudo-Classes

Intermediate
CSS Fundamentals

Beyond classes there is a whole vocabulary — combinators, structural pseudo-classes, :has() — that removes the need for extra markup and JavaScript.

Overview

Most production CSS uses classes and little else, which is a reasonable default but leaves a lot on the table. Structural pseudo-classes style the first, last or nth item without a special class. State pseudo-classes handle hover, focus, disabled and validity without JavaScript. And :has() — the long-requested parent selector, now supported everywhere — lets a container style itself based on what it contains. Knowing the vocabulary means writing less markup and less JavaScript, both of which are wins.

Combinators and Structure

Relationships between elements, and position within a parent.

Combinators, nth-child, attributes
.card .title      /* descendant, at any depth */
.card > .title    /* direct child only */
.a + .b           /* the next sibling, immediately after */
.a ~ .b           /* any following sibling */

/* Structural — no extra classes needed */
li:first-child    li:last-child    li:only-child
li:nth-child(2)   li:nth-child(odd)   li:nth-child(3n)
li:nth-last-child(2)
p:first-of-type   /* first p, regardless of other siblings */
:empty            /* no children at all, not even text */

/* Two very useful idioms */
.stack > * + *          { margin-block-start: 1rem }  /* space between */
tr:nth-child(even)      { background: var(--surface) } /* zebra stripes */

/* Attribute selectors */
a[href^="http"]         /* starts with */
a[href$=".pdf"]         /* ends with */
[data-state="open"]     /* exact */
[aria-invalid="true"]   /* style from ARIA state — keeps CSS and
                           accessibility in sync automatically */

/* :is() and :where() shorten long lists */
:is(h1, h2, h3) + p { margin-block-start: .5rem }

State

Interaction and form state, handled without JavaScript.

:focus-visible, :user-invalid, :focus-within
a:hover      a:active      a:visited
button:focus-visible          /* keyboard focus only — NOT mouse clicks */
button:disabled
input:checked
details[open]

/* :focus-visible is the modern default. :focus fires on mouse
   clicks too, which is why people removed outlines and broke
   keyboard access. */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px }

/* Form validity */
input:required    input:invalid    input:valid
input:user-invalid   /* only after the user has interacted — use this */
input:placeholder-shown

/* The floating-label trick, with no JavaScript */
input:placeholder-shown + label { transform: translateY(1.4rem) }

/* :focus-within — style a container when anything inside has focus */
.search:focus-within { outline: 2px solid var(--accent) }

/* Hover only where hovering exists — a phone has no hover, and
   sticky hover states are a common mobile bug */
@media (hover: hover) {
  .card:hover { transform: translateY(-2px) }
}

:has() and Pseudo-Elements

The parent selector, and generated content.

:has() replaces a lot of JavaScript
/* :has() — style a parent based on its children */
.card:has(img)            { grid-template-rows: auto 1fr }
.field:has(:invalid)      { border-color: var(--danger) }
label:has(input:checked)  { font-weight: 600 }
form:has(:invalid) button[type=submit] { opacity: .5 }

/* Combined with sibling combinators it replaces a lot of JS: */
body:has(dialog[open]) { overflow: hidden }   /* lock scroll, no JS */

/* Pseudo-elements — two colons, and they need content to render */
.badge::before { content: "★"; margin-inline-end: .25rem }
.external::after { content: " ↗" }
td::before { content: attr(data-label) }      /* read an attribute */

::selection { background: var(--accent); color: white }
::placeholder { color: var(--ink-faint) }
::marker { color: var(--accent) }             /* list bullets */
::first-line   ::first-letter

/* Generated content is NOT reliably announced by screen readers
   and cannot be selected or translated — never put meaning in it. */
.icon::before { content: "✓" }                 /* decorative only */

Key Points to Remember

  • 1Combinators and nth-child style position without adding classes; > * + * is the standard spacing idiom
  • 2Attribute selectors can style from ARIA state, keeping CSS and accessibility in sync
  • 3:focus-visible targets keyboard focus only, which is why it replaced the outline-removal habit
  • 4:has() selects a parent by its contents and removes a surprising amount of JavaScript
  • 5Generated content from ::before is decorative — it is not reliably announced, selectable or translatable

Interview Questions

Sign in to ask Aria
1

What is the difference between :focus and :focus-visible?

Medium
2

What does .card:has(img) select?

Medium
3

Why should meaningful text never live in a ::before content property?

Hard

Ask Aria about Selectors and Pseudo-Classes

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…