Skip to main content

🎯 Attribute and Pseudo-class Selectors

Element, class, and ID selectors get you started β€” but the real expressive power of CSS comes from selecting by an element's attributes and its state. Master these and you'll target exactly what you mean with surgical precision, keeping your HTML clean and free of throwaway helper classes.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Write attribute selectors for presence, exact value, and the four substring matchers (*=, ^=, $=, ~=, |=)
  • Apply pseudo-classes for user interaction, form state, links, and document structure
  • Use structural pseudo-classes including the an+b formula in :nth-child()
  • Combine logical pseudo-classes (:not(), :is(), :where(), :has()) and reason about their specificity

Estimated Time: 35–45 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a form that gives rich validation feedback using only selectors β€” zero JavaScript.

In This Lesson

Why Advanced Selectors?

A selector is a query against your document: it describes a set of elements, and the browser hands the matching ones your styles. Basic selectors describe elements by who they are (a <p>, an element with class="btn"). Advanced selectors let you describe elements by what they carry (their attributes) and how they are behaving right now (their state).

πŸ’‘ A useful analogy: Basic selectors are like calling out people by name in a crowd. Advanced selectors are like saying "everyone wearing a red badge who is currently raising their hand." You target by trait and behavior β€” no need to hand each person a name tag first.

The payoff is cleaner HTML. Instead of littering your markup with class="input-required input-invalid" that some JavaScript has to keep in sync, you let CSS read the state the browser already tracks (:required, :invalid) and style it directly.

πŸ“– Key Terms

Attribute selector: matches on an HTML attribute's presence or value, written in square brackets: [type="email"].

Pseudo-class: matches an element in a particular state or position, written with one colon: :hover, :first-child.

Specificity: the weight the browser uses to decide which conflicting rule wins. Attribute selectors and pseudo-classes each count the same as a class.

Attribute Selectors

Attribute selectors target elements by the attributes on their opening tag. The simplest form matches on presence β€” the attribute exists, regardless of value:

/* Any element that has a title attribute */
[title] {
  cursor: help;
}

/* Any input carrying the required attribute */
input[required] {
  border-left: 3px solid var(--warning-border, #f59e0b);
}

/* Any element with a data-tooltip hook */
[data-tooltip] {
  position: relative;
}

The next form matches an exact value with =. This is how you style one input type differently from another:

input[type="text"] {
  padding: 0.5rem;
  border: 1px solid #ccc;
}

/* Elements with a specific ARIA role */
[role="navigation"] {
  background: #f8f8f8;
}

πŸ“– The full family of attribute selectors

graph TD A["Attribute selectors"] --> B["[attr] β€” present"] A --> C["[attr=value] β€” exact"] A --> D["[attr*=value] β€” contains substring"] A --> E["[attr^=value] β€” starts with"] A --> F["[attr$=value] β€” ends with"] A --> G["[attr~=value] β€” word in space list"] A --> H["[attr|=value] β€” exact or value- prefix"]

Substring Attribute Matchers

Four operators match parts of an attribute value. Think of them as the CSS equivalents of "contains", "starts with", and "ends with":

SelectorMatches when the value…Classic use
[href*="drive"]contains the substring anywhereLinks to a given service
[href^="https"]starts with the stringSecure vs. insecure links
[href$=".pdf"]ends with the stringFile-type icons on downloads
[class~="primary"]is a whole word in a space-separated listMatching one class among many
[lang|="en"]equals it, or starts with it plus a hyphenLanguage + dialect (en, en-US)

A worked example β€” icons on outbound and download links, expressed entirely in CSS:

/* External links start with a protocol we don't own */
a[href^="http"]:not([href*="mysite.com"]) {
  padding-right: 1.1em;
  background: url("/img/external.svg") right center / 0.9em no-repeat;
}

/* PDF downloads get a document badge */
a[href$=".pdf"] {
  padding-left: 1.2em;
  background: url("/img/pdf.svg") left center / 1em no-repeat;
}

/* Match "primary" as a full class token, not "primary-btn" */
[class~="primary"] {
  font-weight: 700;
}

⚠️ ~= vs *= β€” a subtle but important difference

[class~="primary"] matches class="box primary card" (three whole words) but not class="primary-card". The looser [class*="primary"] matches both, because it only looks for the letters anywhere in the string. Reach for *= deliberately β€” it is the most permissive and the easiest to trigger by accident.

πŸ’‘ Case sensitivity

Attribute-value matching is case-sensitive by default. Selectors Level 4 adds an i flag for case-insensitive matching (and a rarely needed s for forced case-sensitive): [type="text" i] matches TEXT, Text, and text alike. It is well supported in every current browser.

Pseudo-classes: State & Structure

A pseudo-class selects an element based on information that isn't in the markup at all β€” whether the mouse is over it, whether a form field is valid, whether it is the third child of its parent. The browser tracks all of this for you; a pseudo-class simply reads it.

graph TD A["Pseudo-classes"] --> B["User action"] A --> C["Form state"] A --> D["Link state"] A --> E["Structural"] A --> F["Logical"] B --> B1[":hover :active :focus :focus-visible"] C --> C1[":required :valid :invalid :checked :disabled"] D --> D1[":link :visited :target"] E --> E1[":first-child :last-child :nth-child :only-child :empty"] F --> F1[":not() :is() :where() :has()"]

Interaction & Form State

Interaction pseudo-classes respond to what the user is doing right now:

button:hover  { background: #0056b3; color: #fff; }
button:active { transform: translateY(1px); }

/* Show a focus ring ONLY for keyboard users, not mouse clicks */
button:focus-visible {
  outline: 3px solid #f59e0b;
  outline-offset: 2px;
}

βœ… Prefer :focus-visible over :focus

The old habit of outline: none on :focus destroys keyboard accessibility. Modern practice: keep a clear focus indicator, but scope it to :focus-visible so mouse users don't see a ring after clicking, while keyboard users always do. Never remove the indicator without replacing it.

Form-state pseudo-classes read the validity the browser computes from your HTML constraints β€” no JavaScript required:

input:required { border-left: 4px solid #cc0000; }
input:valid    { border-color: #16a34a; }
input:invalid  { border-color: #dc2626; }

/* Style the label next to a checked box (adjacent sibling) */
input:checked + label { font-weight: 700; }

input:disabled {
  background: #f1f1f1;
  cursor: not-allowed;
}

⚠️ Empty fields shouldn't scream "invalid"

A required field is :invalid the instant the page loads, before the user has typed a thing β€” flagging it red immediately feels hostile. Gate your error styling so it only appears after interaction: input:invalid:not(:focus):not(:placeholder-shown) waits until the field has content and has lost focus.

Link pseudo-classes have an order that matters. Define them :link β†’ :visited β†’ :hover β†’ :active β€” remembered as LoVe Hate β€” or later rules will mask earlier ones. Note that browsers restrict :visited to a handful of color properties for privacy reasons.

Structural Pseudo-classes

These select elements by their position in the document tree. The workhorse is :nth-child(), which accepts either keywords or the an + b formula:

tr:nth-child(odd)   { background: #f5f5f5; }  /* zebra stripes */
li:first-child      { font-weight: 700; }
li:last-child       { border-bottom: none; }

/* an + b formula: a = step, b = offset */
:nth-child(3n + 1)  { }   /* 1st, 4th, 7th, … */
:nth-child(n + 5)   { }   /* the 5th element onward */
:nth-child(-n + 3)  { }   /* only the first three */
div:empty           { display: none; }
nth-child versus nth-of-type A parent with an h2 followed by three paragraphs. nth-child counts every child; nth-of-type counts only paragraphs, so the same paragraph has different index numbers under each scheme. <div> children, in order h2 child 1 Β· type 1 p child 2 Β· type 1 p child 3 Β· type 2 p child 4 Β· type 3 p:nth-child(2) matches the FIRST paragraph (it is the 2nd child overall). p:nth-of-type(2) matches the SECOND paragraph (the 2nd of its type).
Figure 1 β€” :nth-child counts every sibling; :nth-of-type counts only siblings of the same element type. Mixing element types is the classic source of "why isn't my nth-child working?" bugs.

πŸ’‘ :nth-child vs :nth-of-type

Use :nth-child when the list is uniform (all <li>, all <tr>). Use :nth-of-type when other element types are interleaved and you want to count only one kind. There are :first-of-type, :last-of-type, and :only-of-type variants too.

Logical Pseudo-classes

These take other selectors as arguments and combine them.

:not() β€” negation

/* Every list item except the first */
li:not(:first-child) { margin-top: 0.5rem; }

/* Buttons that are neither primary nor secondary */
button:not(.primary):not(.secondary) { background: #6b7280; }

:is() and :where() β€” grouping

/* Verbose */
header h1, header h2, main h1, main h2, footer h1, footer h2 {
  font-family: Georgia, serif;
}

/* Same result, grouped with :is() */
:is(header, main, footer) :is(h1, h2) {
  font-family: Georgia, serif;
}

πŸ“– The specificity twist

:is() takes the specificity of its most specific argument. :where() is identical in matching but always has zero specificity β€” perfect for low-priority base styles you want to override easily. :not() also contributes the specificity of its argument.

:has() β€” the long-awaited parent selector

For years CSS could only look "downward and rightward". :has() finally lets a selector depend on an element's descendants or following siblings:

/* A form field that CONTAINS a required input */
.field:has(input:required) label::after { content: " *"; color: #dc2626; }

/* A card that contains an image gets extra padding */
.card:has(img) { padding-top: 0.5rem; }

/* Highlight the nav item whose link is the current page */
.nav-item:has(> a[aria-current="page"]) { background: #eef2ff; }

βœ… Browser support in 2026

:has(), :is(), :where(), and case-insensitive attribute matching are all supported across every current evergreen browser. :has() was the last holdout and shipped everywhere by late 2023 β€” it is safe to use today.

Hands-on Exercise

πŸ‹οΈ JavaScript-free form validation

Objective: Build a sign-up form that gives live, styled feedback using only attribute selectors and pseudo-classes.

Instructions:

  1. Create an email field (type="email" required) and a password field (type="password" required minlength="8").
  2. Give required fields a left accent bar with input:required.
  3. Turn the border green on :valid and red on :invalid β€” but only after the user has typed, using the :not(:placeholder-shown) guard.
  4. Add a checkbox with a styled adjacent label via input:checked + label.
  5. Use :has() so the submit button dims while any field is still invalid.
πŸ’‘ Hint

Give every input a placeholder=" " (a single space). That makes :placeholder-shown true only while the field is empty, which is exactly the "hasn't been touched yet" signal you want to gate error styling on.

βœ… Sample solution
input:required {
  border-left: 4px solid #f59e0b;
}

/* Only flag once the field has content and lost focus */
input:not(:placeholder-shown):not(:focus):invalid {
  border-color: #dc2626;
  background: #fff5f5;
}
input:not(:placeholder-shown):valid {
  border-color: #16a34a;
}

input[type="checkbox"]:checked + label {
  font-weight: 700;
  color: #16a34a;
}

/* Dim submit while the form still contains an invalid field */
form:has(input:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

Every rule reads state the browser already tracks β€” no addEventListener, no class toggling. That is the whole point of state-based selectors.

🎯 Quick Quiz

Question 1: Which selector matches a link whose href value ends with .pdf?

Question 2: In a <div> containing an <h2> followed by three <p> elements, which selector matches the first paragraph?

Question 3: What is special about :where() compared to :is()?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Attribute selectors target elements by what they carry β€” presence, exact value, or a substring via *=, ^=, $=, ~=, |=.
  • Pseudo-classes read state the browser already tracks: interaction, form validity, links, and tree position.
  • Structural selectors use the an+b formula; watch the :nth-child vs :nth-of-type distinction.
  • Logical pseudo-classes (:not, :is, :where, :has) combine selectors β€” and :has() finally gives you a parent selector.
  • State-based selectors replace a surprising amount of JavaScript with plain, declarative CSS.

πŸ“š Further Reading

πŸš€ What's Next?

You now target elements by attribute and state. Next we'll add combinators β€” selecting elements by their relationship to other elements (descendants and siblings), so you can style based on document structure itself.

πŸŽ‰ Well done!

Your selectors just got a lot sharper. On to the relationships between elements.