ESC

Type to search the knowledge base.

CSS Selectors Attribute and Sibling

Attribute selectors, combinators, and sibling selectors — match states and structure without extra classes when the DOM already has the signal.

beginner3 min read
  • css
  • selectors
  • attributes

Classes aren’t the only hook. Attribute selectors target href, type, aria-*, and data-*. Sibling combinators style elements based on what comes before them in the tree. Used carefully, they reduce class noise; used carelessly, they couple CSS tightly to markup order.

Docs: MDN attribute selectors, combinators.

Attribute selectors

/* Presence */
a[download] {
  font-weight: 600;
}

/* Exact match */
input[type="password"] {
  letter-spacing: 0.05em;
}

/* Space-separated token (class-like) */
div[class~="featured"] {
}

/* Hyphen-separated language prefix */
[lang|="en"] {
  quotes: "“" "”";
}

/* Starts with / ends with / contains */
a[href^="https://"] {
  /* external-ish */
}

a[href$=".pdf"]::after {
  content: " (PDF)";
  font-size: 0.85em;
}

a[href*="github.com"] {
  /* contains */
}

/* Case-insensitive flag */
a[href$=".PDF" i] {
}
<input type="email" name="email" required aria-invalid="true" />
input[aria-invalid="true"] {
  border-color: var(--danger);
}

input:required:not(:placeholder-shown):invalid {
  /* progressive enhancement patterns */
}

Prefer aria-* and native validity hooks over inventing parallel classes when the accessibility tree already exposes state.

Sibling combinators

Combinator Meaning
A + B Next-sibling — B immediately after A
A ~ B Subsequent-sibling — any B after A sharing parent
/* Error text immediately after invalid field */
input[aria-invalid="true"] + .field-error {
  display: block;
  color: var(--danger);
}

/* Label spacing between stacked fields */
.field + .field {
  margin-block-start: 1rem;
}

/* Dim all options after a selected one? usually ~ */
.step.is-done ~ .step {
  opacity: 0.7;
}
<label class="field">
  <span>Email</span>
  <input type="email" aria-invalid="true" />
  <span class="field-error">Enter a valid email</span>
</label>

There is no previous-sibling combinator. Restructure markup or use :has() when you need “parent/previous” logic.

Child and descendant

/* Direct child only */
.menu > li {
  list-style: none;
}

/* Any depth */
.menu a {
  text-decoration: none;
}

> avoids accidentally styling nested menus the same way as top-level items.

Specificity note

Attribute selectors count like classes (one class-level weight), not like elements. input[type="text"] is higher than input alone.

input {
  border: 1px solid #ccc;
}
input[type="text"] {
  border-color: #999; /* wins over bare input */
}

When to prefer a class

  • Reused visual pattern not tied to one attribute
  • Markup order is unstable (React reordering)
  • Server-rendered vs client-rendered attributes differ
  • You need a public design-system API (.btn--primary)

Interview out-loud

“Attribute selectors match on presence or value of attributes — useful for type, aria-invalid, and file extensions. + styles the immediate next sibling; ~ styles all following siblings. Specificity matches classes. I avoid previous-sibling assumptions and use :has() or classes when structure is fragile.”

Footguns

  1. Styling based on order that CMS editors will rearrange.
  2. Case sensitivity on attribute values without the i flag.
  3. Over-specific chains that break when a wrapper is added.
  4. Using attribute selectors for performance-sensitive huge DOMs without need (usually fine; readability first).
  5. Forgetting *= can false-positive substrings.

Real form pattern

<div class="field">
  <label for="pw">Password</label>
  <input id="pw" type="password" name="password" aria-invalid="false" />
  <p class="hint" id="pw-hint">At least 12 characters.</p>
  <p class="error" id="pw-error" hidden>Password too short.</p>
</div>
.field:has(input[aria-invalid="true"]) {
  --field-border: var(--danger);
}
.field input {
  border-color: var(--field-border, var(--border));
}
input[aria-invalid="true"] + .error,
input[aria-invalid="true"] ~ .error {
  display: block;
  color: var(--danger);
}

Sibling selectors keep markup lean when the error node immediately follows the control. If React portals the error elsewhere, switch to :has on a wrapper or a state class — do not force fragile DOM order.

Further reading

Related guides