Using ARIA Attributes with CSS Selectors: Styling Accessible Component States
How to use CSS attribute selectors to style component states based on ARIA attributes like aria-expanded and aria-current, with complete code samples.
ARIA properties paired with CSS attribute selectors connect the accessibility tree’s state directly to the visual layer. A component’s appearance follows its semantic condition. No JavaScript required. The technique has been available since Internet Explorer 7 shipped attribute selectors in 2006. Every major engine has supported them since 2008, placing them on the Baseline widely available list. What changed is not the selector syntax but the discipline around it. Modern custom widgets routinely expose aria-expanded, aria-pressed, aria-current, aria-hidden, and aria-disabled in the DOM. CSS reads those properties directly. The accessibility tree, defined by WAI-ARIA 1.2 (W3C Recommendation, 6 June 2023), states that ARIA properties are exposed as HTML attributes. A selector like [aria-expanded="true"] matches the same node an assistive technology sees as expanded. That alignment is the whole point. It fails only when the CSS targets the wrong value or the wrong property name. What follows walks through the selector grammar, three complete samples, and the failure modes that turn a good pattern into a WCAG violation.
How the Attribute Selector Grammar Maps to ARIA State
Six Selector Forms That Matter
The CSS attribute selector has six forms that matter for ARIA properties. Presence-only, written [aria-attribute], matches a node that has the property at all, regardless of its value. Exact match, [aria-attribute="value"], requires the precise string. Word match, [aria-attribute~="value"], matches when the value is a whitespace-separated list containing the word. Prefix, [aria-attribute^="value"], matches when the value starts with the string. Suffix, [aria-attribute$="value"], matches when it ends with the string. Substring, [aria-attribute*="value"], matches when the string appears anywhere.
For ARIA state, exact match is the workhorse. Presence-only is the trap.
The presence selector does not test truthiness. [aria-disabled] will match a node with aria-disabled="false" just as readily as one with aria-disabled="true". The selector only asks whether the property exists in the DOM. That distinction is the root of the most common styling bug in this space.
Boolean-Like Versus Enumerated Properties
The ARIA specification defines each state’s default value, often the absence of the property itself. A selector written against a value that is never present will silently fail. Know which ARIA properties are boolean-like and which are enumerated. Then write selectors that match the exact serialized values the browser expects.
The Toggle Button: Styling aria-pressed with Exact Match
A toggle button is the canonical case for aria-pressed. The property carries a three-state value: true, false, and mixed. The selector [aria-pressed="true"] matches only the pressed state. [aria-pressed="false"] matches the unpressed state. [aria-pressed="mixed"] covers the indeterminate case for tri-state controls.
Here is a complete toggle button that changes its visual affordance without any class toggling:
<button id="mute-button" aria-pressed="false" class="toggle">
Mute
</button>
.toggle[aria-pressed="true"] {
background-color: #1a73e8;
color: #fff;
border-color: #1a73e8;
}
.toggle[aria-pressed="false"] {
background-color: #fff;
color: #1a73e8;
border-color: #1a73e8;
}
.toggle[aria-pressed="mixed"] {
background-color: #e8f0fe;
color: #1a73e8;
border-color: #1a73e8;
text-decoration: underline;
}
The specificity of .toggle[aria-pressed="true"] is (0, 2, 0). That beats a single class rule but loses to an ID. If your design system uses IDs, match that specificity or rely on the cascade order.
The JavaScript that updates the property is a single line: element.setAttribute('aria-pressed', String(!element.getAttribute('aria-pressed'))). The visual state and the semantic state move together. This satisfies WCAG 2.2 Success Criterion 4.1.2 (Name, Role, Value): the accessibility tree’s value must reflect the control’s state. CSS is the cheapest way to keep the two in sync.
The failure case is a class like .is-active. That class says nothing to the accessibility tree. The button’s pressed state becomes invisible to screen reader users.
Styling aria-expanded: The Disclosure and the Menu
Write the Closed State First, Override the Open State
The aria-expanded property signals whether a widget that controls a collapsible region is open or closed. The selector [aria-expanded="true"] styles the open state. The absence of the property, or aria-expanded="false", styles the closed state.
The mistake that appears in production code is targeting [aria-expanded="false"] when the property is absent by default. A framework may only write the property after the first interaction. Absence means false in ARIA. A button that has never been clicked does not carry aria-expanded at all. A selector demanding the literal string “false” will not match.
Fix it. Write the base styles for the closed state without a selector. Then add the property-driven override for the open state:
<button aria-expanded="false" aria-controls="menu" class="disclosure">
Options
</button>
<div id="menu" class="menu-panel" hidden>
<ul>
<li><a href="/a">First</a></li>
<li><a href="/b">Second</a></li>
</ul>
</div>
.disclosure {
/* base styles for closed state */
padding: 0.5rem 1rem;
border: 1px solid #ccc;
}
.disclosure[aria-expanded="true"] {
border-color: #000;
background-color: #eee;
}
.menu-panel {
display: none;
}
.disclosure[aria-expanded="true"] ~ .menu-panel {
display: block;
}
This sample uses the general sibling combinator (~) to show the panel when the button is expanded. The display toggle is a stylistic choice, not a requirement. The HTML hidden attribute on the panel removes it from the accessibility tree when closed. The CSS display: none is redundant but harmless.
Render the Property in Every State
The real lesson: aria-expanded selectors work only when the property is written to the DOM in every state, including the initial one. If your component library skips the false value on first render, the page loads with the closed state styled by the base rule. The open state appears only after the first click. That is a progressive enhancement failure, not a CSS failure. The selector is fine. The markup is incomplete.
Navigation Links: aria-current and the Exact Match Trap
aria-current indicates the current item in a set, like the active page in a navigation list. The property takes a token value: page, step, location, date, time, or true. The exact match selector [aria-current="page"] is the one that matters for most sites.
It breaks when a developer writes [aria-current] as a presence selector.
The presence selector matches any node with the property, including aria-current="false", which is a legal value that explicitly means not current. The specificity of the presence selector is lower than the exact match. A rule like nav a[aria-current] { font-weight: bold; } will also bold a link that has aria-current="false" if that property is present in the DOM.
Here is a correct navigation sample:
<nav aria-label="Main">
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
nav a[aria-current="page"] {
font-weight: 700;
color: #000;
border-bottom: 2px solid #000;
}
nav a:not([aria-current="page"]) {
color: #767676;
}
The second rule, using :not(), styles every link that is not the current page. That is the safe inverse. The alternative, targeting [aria-current="false"], assumes the property is present on every link, which is rare. The WAI-ARIA 1.2 spec says aria-current is not present by default. Absence is the signal for “not current.” The accessibility tree reads the property when it exists. The CSS mirrors that exactly.
The Common Mistake: aria-hidden with a Visible Display Value
Never Style the False Value
The most damaging misuse of ARIA-driven CSS is styling [aria-hidden="true"] with a visible display value. The aria-hidden state tells assistive technology to remove a node from the accessibility tree. It has no automatic effect on visual rendering. If a stylesheet declares [aria-hidden="true"] { display: block; }, or overrides a previous display: none with a more specific selector, the node becomes invisible to screen readers but visible to sighted users. That is a direct violation of WCAG 2.2 Success Criterion 4.1.2. The name, role, and value of the node are inconsistent between the two trees.
Use the visibility property or display: none in conjunction with the property, not against it:
[aria-hidden="true"] {
display: none;
}
[aria-hidden="false"] {
display: block;
}
The second rule here is the trap. aria-hidden="false" is rarely serialized in the DOM. The default is absence, meaning visible. A selector demanding the literal string “false” will not match most nodes. The display: block rule does nothing. The node stays hidden if it has a class that sets display: none.
The failure is silent. The CSS author intended to show the node, but the property is not present. The selector misses.
Hide with the Property, Show with the Base Rule
Write the visible state as the base rule and only hide with [aria-hidden="true"]. Never style the false value. The same logic applies to the visibility property. visibility: hidden removes the node from the accessibility tree. visibility: visible restores it. The CSS must pair the property with the hiding declaration, not the showing one.
ARIA Attribute State Selectors: The @supports Guard and Baseline Reality
Skip the Guard, Watch the Cascade
Attribute selectors are so old that a @supports guard is not needed. The feature is Baseline widely available, with all major engines supporting the basic forms since 2008. The only legacy gap is pre-IE7 browsers, which silently ignore a ruleset containing an attribute selector. Those browsers are functionally extinct. If you must support a truly ancient engine, the guard syntax is @supports selector([aria-expanded="true"]) { ... }. Writing it for production is a waste of bytes.
The real interop question is not the selector syntax itself. It is the ARIA property values. The i flag, which makes a selector case-insensitive, is dangerous here. ARIA values are case-sensitive per specification. For example, [aria-autocomplete="inline" i] would match “INLINE”, which the spec defines as a different token. The accessibility tree would treat the two as distinct. Do not use the i flag on ARIA property selectors. The spec does not allow it for any of the enumerated states.
Specificity matters here. [aria-hidden] and [aria-hidden="true"] have different specificity levels. A presence selector can override an exact match if it appears later in the stylesheet. That is a cascade order bug, not a selector bug. It shows up in a code review as a mysterious un-hiding node.
CSS Attribute Selector Accessibility: The Content Property and the Accessibility Tree
Generated Text Stays Out of the DOM
The content property is the boundary where CSS can help or harm the accessibility tree. When you generate text with content: "...", the rendered text is visible to sighted users. It is not reliably exposed to assistive technology. The CSS 2.1 specification says generated content is not part of the DOM. Screen readers treat it inconsistently.
If you use an ARIA property selector to add a visual label, like .icon[aria-hidden="true"]::before { content: "icon"; }, you have created a visual cue that the accessibility tree may or may not read.
Put the Label in the HTML, Hide the Decoration
The safe pattern: put the text in the DOM and use ARIA to hide the decorative part. A button with an icon and a text label should have both in the HTML. The icon node should carry aria-hidden="true" so the screen reader reads only the label. The CSS attribute selector can then style the icon node based on its state. It must not generate text that is the only label.
The custom component ARIA styling pattern that works: the ARIA property drives the visual state. The content property is reserved for purely decorative shapes that are hidden from the accessibility tree.
WCAG 2.2 Success Criterion 4.1.2 and the Legal Weight of the Technique
One Source of Truth for State
The Web Content Accessibility Guidelines 2.2, specifically Success Criterion 4.1.2 (Name, Role, Value), is the normative reason to use ARIA-driven CSS selectors. The criterion requires that the name and role of a user interface component, along with its states and values, can be programmatically determined by assistive technologies. When CSS reads the same property that the accessibility tree reads, the visual state and the semantic state are guaranteed to match. They share the same source of truth.
Where the Law References WCAG
The legal weight varies by jurisdiction. Section 508 in the United States, EN 301 549 in the European Union, and AODA in Ontario, Canada all reference WCAG conformance. Government procurement often requires it. The CSS specification itself has no jurisdiction. The accessibility tree does. It is the bridge between the DOM and the assistive technology.
A component that styles aria-pressed with a class instead of the property passes the visual test but fails the automated audit. The class is not exposed to the accessibility tree. This technique is not optional for compliance. It is the cheapest way to satisfy the criterion.
What to Do When the Attribute Is Not There: The Failure Case
Three Failures, One Debugging Routine
The normal route for an ARIA property selector is that the property is present in the DOM. When it is not, the selector silently fails. The visual state stays in its base form. This happens most often in React or Vue components that conditionally render the property. A button that only adds aria-expanded after the first click is unstyled on initial render. The base rule was written as [aria-expanded="false"].
Fix it. Render the property in every state, including the initial false value. Or write the base styles without the selector.
The debugging process is the same every time. Open the browser’s dev tools. Inspect the node. Check whether the property exists in the DOM. If it does not, the CSS selector cannot match. The syntax does not matter.
The second failure is a value mismatch. A component writes aria-expanded="true" but a selector reads aria-expanded="expanded". The serialized value must match exactly, including case. ARIA values are case-sensitive.
The third failure is a specificity collision. A later rule with a higher specificity overrides the property selector. Inspect the computed styles. Adjust the selector specificity. Do not add !important. That is a sledgehammer that breaks the cascade for everyone downstream.
Frequently Asked Questions
Why do ARIA property selectors work in CSS when the accessibility tree is separate from the DOM?
ARIA properties are standard DOM attributes. The accessibility tree is derived from the DOM. The WAI-ARIA 1.2 specification § 6.3 states that ARIA properties are exposed in the DOM as HTML attributes. A CSS attribute selector matches the same node that the accessibility tree sees. The two trees are not separate copies. The accessibility tree is a projection of the DOM plus computed ARIA semantics.
What is the difference between [aria-pressed] and [aria-pressed=”true”]?
The presence selector [aria-pressed] matches any node that has the property, regardless of its value, including aria-pressed="false". The exact match [aria-pressed="true"] matches only when the value is the string “true”. Presence does not test truthiness. Use exact match for state-specific styling.
Can I use the i flag on ARIA property selectors for case-insensitive matching?
No. ARIA property values are case-sensitive per the WAI-ARIA specification. Using the i flag, like [aria-autocomplete="inline" i], would match “INLINE”, which is a different token and would break the semantic mapping. Only use exact case for enumerated ARIA values.
What is the specificity of an ARIA property selector compared to a class?
An attribute selector has the same specificity as a class: (0, 1, 0). A compound selector like .button[aria-pressed="true"] has (0, 2, 0). An ID selector (1, 0, 0) overrides it. Match or exceed that if you have ID-based styles.
Why does [aria-hidden=”false”] not work for showing an element?
The property aria-hidden="false" is rarely present in the DOM. The default is absence, meaning visible. A selector demanding the literal string “false” will not match most nodes. Write the visible state as the base rule and only hide with [aria-hidden="true"].
Does the content property generate text that screen readers can read?
Not reliably. Generated content is not part of the DOM. The accessibility tree may or may not expose it, depending on the assistive technology. Use actual HTML text for meaningful labels. Reserve content for decorative shapes hidden with aria-hidden.
What is the legal requirement for using ARIA properties in CSS?
The legal requirement comes from WCAG 2.2 Success Criterion 4.1.2 (Name, Role, Value), which is referenced by Section 508 (US), EN 301 549 (EU), and AODA (Ontario). Meeting the criterion requires that states be programmatically determinable. ARIA properties in the DOM satisfy that. The CSS must not contradict the accessibility tree.
Open your component library. Replace every class-based state toggle with an ARIA property selector. Start with the toggle buttons and navigation links. Write the base styles for the default state. Add the exact-match property selector for the altered state. Verify in the browser’s dev tools that the property is present in the DOM on initial render. That one change aligns the visual layer with the accessibility tree. It satisfies Success Criterion 4.1.2 without extra JavaScript. It eliminates a whole class of state-sync bugs. If the button does not style, the property is not there. Fix the markup, not the selector.