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.