Appearance
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
| Attribute | Use case |
|---|---|
aria-pressed | Toggle buttons |
aria-selected | Selection in lists |
aria-checked | Custom checkboxes |
aria-current | Current item in navigation |
aria-expanded | Disclosure 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.
| Approach | Pros | Cons |
|---|---|---|
| Semantic attributes | One source of truth, a11y | Limited to existing attrs |
| CSS classes | Flexible, familiar | Two sources of truth |
data-* attributes | Custom state | No 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.