Skip to content

Semantic Selectors ​

Use semantic selectors for styling, behavior, and testing. This enforces proper semantics and accessibility, is stable over time, saves us from having to invent custom DSLs, and communicates developer intent better than alternative approaches.

When to use ​

Prefer semantic selectors over custom data attributes or class-name conventions when styling state changes (current page, expanded panels, pressed buttons), selecting elements in JavaScript, or writing test selectors that stay stable across refactors.

The pattern ​

Current navigation with aria-current ​

Style the active page link by selecting on aria-current="page" — the same attribute that tells assistive technology which link is current:

css
nav a[aria-current="page"] {
  background: var(--color-white);
  color: var(--color-primary);
  font-weight: bold;
}

The HTML just sets the attribute on the current page's link — no extra classes needed:

html
<nav>
  <a href="/home">Home</a>
  <a href="/about" aria-current="page">About</a>
  <a href="/contact">Contact</a>
</nav>

Form states with native attributes ​

Native boolean attributes like disabled and required double as CSS selectors, keeping state in one place instead of maintaining parallel classes:

css
button[disabled],
input[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}

input[required] {
  border-left: 3px solid var(--color-required);
}

input[required]:valid {
  border-left-color: var(--color-success);
}

Expansion state with aria-expanded ​

Use aria-expanded to drive a disclosure indicator that updates automatically when the attribute changes — CSS reads the same attribute that drives accessibility:

css
button[aria-expanded="false"]::after {
  content: "▶";
}

button[aria-expanded="true"]::after {
  content: "▼";
}

Toggle state attributes ​

AttributeUse case
aria-pressedToggle buttons
aria-selectedSelection in lists
aria-checkedCustom checkboxes
aria-currentCurrent item in navigation
aria-expandedDisclosure widgets

JavaScript selection ​

Query elements by their semantic attributes instead of inventing custom data attributes — the same selectors work in CSS, JavaScript, and screen readers:

javascript
// Find all expanded sections
const expanded = document.querySelectorAll(`[aria-expanded="true"]`);

// Toggle a panel
function toggle(button, panel) {
  const open = button.getAttribute("aria-expanded") === "true";
  button.setAttribute("aria-expanded", String(!open));
  panel.hidden = open;
}

Test selectors ​

Semantic selectors make resilient test locators because they survive class-name refactoring and positional changes:

javascript
// Semantic selectors survive refactoring better than
// class names or positional selectors.
await page.click(`[aria-current="page"]`);
await page.click(`button[aria-expanded="false"]`);
await page.fill("input[required]", "test@example.com");

Trade-offs ​

Semantic attributes provide a single source of truth — one attribute serves styling, JavaScript, and accessibility. They force proper ARIA usage, produce selectors that survive refactoring, and describe purpose rather than appearance.

ApproachProsCons
Semantic attributesOne source of truth, a11yLimited to existing attrs
CSS classesFlexible, familiarTwo sources of truth
data-* attributesCustom stateNo a11y benefit

Prefer semantic attributes for any state that has a matching ARIA or HTML attribute. Fall back to data-* only when no semantic attribute exists.