DEV Community

Cover image for aria-selected on tabs: the selected tab that lies (WCAG 4.1.2)
A11Y Practice
A11Y Practice

Posted on Originally published at wasexamprep.com

aria-selected on tabs: the selected tab that lies (WCAG 4.1.2)

The Notifications tab is open. Its panel is on screen: "Choose which email and push alerts you receive." Then VoiceOver lands on the other tab and says Account, selected, tab, 1 of 2, Alert preferences, tab group. On Notifications, the tab you are looking at, it says Notifications, tab, 2 of 2. No selected. The tab you see is not the tab the code says.

WCAG 4.1.2 Name, Role, Value has three parts. In E07 (A Custom Div Is Not a Checkbox), a custom control never told VoiceOver it was a checkbox - that was the role. This breakdown is the value. The name and the role are already right here (Account, tab, 1 of 2); the state is wrong.

This breakdown uses the Selected State lab (E10): Case 1 (no aria-selected at all) and Case 2 (aria-selected hard-coded). The evidence comes from real accessibility-tree and VoiceOver captures.

The problem

Case 1 - role="tab" with no aria-selected

The tab list is real: role="tablist" with an aria-label, and two buttons with role="tab". The active tab only gets a different background class. Nothing in the markup says which tab is selected:

<div role="tablist" aria-label="Account settings" class="tab-bar">
  <button type="button" role="tab" id="selected-state-broken-case1-account-tab">
    Account
  </button>
  <button
    type="button"
    role="tab"
    id="selected-state-broken-case1-notifications-tab"
  >
    Notifications
  </button>
</div>
Enter fullscreen mode Exit fullscreen mode

The selection lives in a CSS class. Assistive technology cannot read a class name.

What VoiceOver announced (broken, Case 1)

On first load, with Account visually active:

Account, selected, tab, 1 of 2, Account settings, tab group

You are currently on a selected tab, 1 of 2.

That sounds right, but there is no aria-selected in the markup. The capture notes say VoiceOver may announce selected here without a matching programmatic value in the tree. Do not build on it.

After activating Notifications, with the Notifications panel visible:

Notifications, tab, 2 of 2

You are currently on a tab, 2 of 2. To select this option, press Control-Option-Space.

It is asking you to select the tab you just selected.

What the accessibility tree showed (broken, Case 1)

Captured after activating Notifications:

  • tablist "Account settings", multiselectable: false
  • tab "Account" - focusable: true, selected: false
  • tab "Notifications" - focusable: true, selected: false

Two tabs. Neither one is selected. The screen shows an open tab; the code exposes none. Without aria-selected, the tree's selected state does not track the visual active tab.

Case 2 - aria-selected hard-coded

This is the partial fix. Someone read "add aria-selected" and added it - as literal values:

<button
  type="button"
  role="tab"
  aria-selected={true}
  onClick={() => setCase2Active("account")}
  className={...}
>
  Account
</button>
<button
  type="button"
  role="tab"
  aria-selected={false}
  onClick={() => setCase2Active("notifications")}
  className={...}
>
  Notifications
</button>
Enter fullscreen mode Exit fullscreen mode

The click updates case2Active, which switches the styling and the panel. aria-selected never reads it. On first load that even looks correct, because Account is the starting tab. The bug only shows up when you switch.

What VoiceOver announced (broken, Case 2)

With Notifications focused and its panel visible:

Notifications, tab, 2 of 2

And on Account, which is no longer the open tab:

Account, selected, tab, 1 of 2, Alert preferences, tab group

What the accessibility tree showed (broken, Case 2)

  • tablist "Alert preferences", multiselectable: false
  • tab "Account" - focusable: true, selected: true
  • tab "Notifications" - focusable: true, focused: true, selected: false

The tree agrees with VoiceOver, and both disagree with the screen. Focus moved. Selection didn't.

Why this fails exam-style

  • 4.1.2 Name, Role, Value (Level A) requires that the name and role of user interface components can be programmatically determined, that states the user can set can be programmatically set, and that changes to these items are available to assistive technologies. Which tab is selected is a state the user sets by clicking a tab. If the code never updates it, assistive technology is told the wrong thing.
  • The WAS rule for this (Custom Widgets, "Bound ARIA States"): "States such as aria-expanded, aria-selected, aria-checked, and aria-pressed MUST update when the UI changes - stale values mislead screen reader users."
  • Case 1 exposes no selected tab: both tabs are selected: false in the tree while one is visibly open.
  • Case 2 exposes the wrong selected tab. The attribute is present and its values are valid, so a quick check for the attribute finds it - but the value is stale.
  • Exam signal for missing state: a tab list where no tab has selected: true while one panel is visible. Exam signal for stale state: selected: true on a tab that is not the open one, often next to focused: true on the tab that is. In both cases, test after switching, and check both tabs - not only the first one.
  • Name and role problems (a control that does not expose what it is) are the E07 side of 4.1.2. role="tabpanel" / aria-controls and arrow-key navigation are separate topics (Panel Association and Full Tabs Pattern labs).

The fix

Bind aria-selected to the same state that drives the active styling and the panel: true on the active tab, false on every other tab. The same fix repairs both cases. In React:

<button
  type="button"
  role="tab"
  id="selected-state-fixed-case1-account-tab"
  aria-selected={case1Active === "account"}
  onClick={() => setCase1Active("account")}
  className={...}
>
  Account
</button>
<button
  type="button"
  role="tab"
  id="selected-state-fixed-case1-notifications-tab"
  aria-selected={case1Active === "notifications"}
  onClick={() => setCase1Active("notifications")}
  className={...}
>
  Notifications
</button>
Enter fullscreen mode Exit fullscreen mode

Without a framework, update the attribute in the same place you switch the tab:

const accountTab = document.querySelector(
  "#selected-state-fixed-case1-account-tab",
);
const notificationsTab = document.querySelector(
  "#selected-state-fixed-case1-notifications-tab",
);

function setActiveTab(id) {
  accountTab.setAttribute("aria-selected", String(id === "account"));
  notificationsTab.setAttribute("aria-selected", String(id === "notifications"));
}

accountTab.addEventListener("click", () => setActiveTab("account"));
notificationsTab.addEventListener("click", () => setActiveTab("notifications"));
Enter fullscreen mode Exit fullscreen mode

Not a CSS class. Not a hard-coded true.

What changed in the tree (fixed)

With the Notifications panel visible in both cases:

  • Case 1: tab "Account" - focusable: true, selected: false; tab "Notifications" - focusable: true, selected: true. The Notifications node lists aria-selected: true, the Account node aria-selected: false.
  • Case 2: tab "Account" - focusable: true, selected: false; tab "Notifications" - focusable: true, focused: true, selected: true.
  • Contrast broken Case 2, where Account stayed selected: true after Notifications became active.

Exactly one tab is selected, and it is the one on screen. The tree finally matches the page.

What VoiceOver announced (fixed)

Case 2, with Account active:

Account, selected, tab, 1 of 2, Alert preferences, tab group

The hint on the active Account tab (Case 1):

You are currently on a selected tab, 1 of 2.

The capture also records a state-only reading for Case 2 (the lab's evidence caption places it on the active Notifications tab):

selected

To be honest about the evidence: the lab capture does not include a full VoiceOver announcement for the active Notifications tab after the fix, so this post does not quote one. The proof for the fix is the tree above - selected: true on exactly the visible tab. While Account is active, the inactive Notifications tab reads:

Notifications, tab, 2 of 2

You are currently on a tab, 2 of 2. To select this option, press Control-Option-Space.

Behaviors to verify

  1. Exactly one tab in each tab list has aria-selected="true"; every other tab has aria-selected="false".
  2. aria-selected reads the same state that drives the active styling and the visible panel - no literal true or false.
  3. Switch tabs, then check the accessibility tree: selected: true must follow the visible tab. Then move VoiceOver to both tabs, not just the first one, and confirm only the open tab is announced as selected. Do not trust a first-load listen.

Practice it

Takeaway: present is not correct. A tab's aria-selected has to follow the tab that is actually open. Switch tabs, and check the tree, not just your ears.

If this was useful, subscribe to A11Y in Practice on YouTube (@a11yinpractice) for more hands-on accessibility episodes, and visit wasexamprep.com for WAS study notes and labs.

Independent study notes - not affiliated with IAAP or W3C.

Top comments (0)