usability.cat
Issue Wiki

Stateful Controls Missing ARIA States

Your toggles and tabs don't announce whether they're on or off. Screen reader users are flipping switches in the dark.

What is this?

Picture a light switch that looks identical whether the light is on or off. No click feel, no indicator, no change in position. You flip it and have no idea if anything happened. That is what toggles, tabs, accordions, and expandable menus feel like to screen reader users when they are missing ARIA state attributes. The control exists, but it does not communicate its current state.

ARIA states are HTML attributes like aria-expanded, aria-selected, aria-checked, and aria-pressed that tell assistive technology what state a control is in right now.

Why it matters

  • For your visitors: When a screen reader user clicks a toggle and nothing is announced, they do not know if the action succeeded. Did the menu open? Is dark mode on? Is the accordion expanded? They have to guess, or navigate away and back to check — which is exhausting and error-prone.
  • For your business: Stateful controls without ARIA states mean broken experiences for a significant user group. If your settings page has toggles that do not announce their state, users cannot configure your product, which leads to support tickets and churn.
  • The standard: WCAG 4.1.2 (Name, Role, Value) requires that for all user interface components, the name, role, and state can be programmatically determined. If a button toggles something, the toggle state must be communicated to assistive technology.
States properly communicated
<!-- Toggle button with pressed state -->
<button aria-pressed="true">Dark Mode</button>

<!-- Expandable section with expanded state -->
<button aria-expanded="false" aria-controls="faq-1">
  How do I cancel?
</button>
<div id="faq-1" hidden>You can cancel anytime...</div>

<!-- Tab with selected state -->
<button role="tab" aria-selected="true">Profile</button>
<button role="tab" aria-selected="false">Settings</button>
States missing — screen reader guessing game
<!-- Toggle: is dark mode on or off? No one knows -->
<button class="toggle active">Dark Mode</button>

<!-- Accordion: is this expanded? The class says so, but screen readers can't see CSS -->
<button class="expanded">How do I cancel?</button>
<div class="panel open">You can cancel anytime...</div>

<!-- Tabs: which one is selected? Only the CSS knows -->
<button class="tab active">Profile</button>
<button class="tab">Settings</button>

How to fix it

React / Next.js

Update your state attributes whenever the control's state changes.

// src/components/toggle-button.tsx
import { useState } from "react";

export function ToggleButton({ label }: { label: string }) {
  const [isPressed, setIsPressed] = useState(false);

  return (
    <button
      aria-pressed={isPressed}
      onClick={() => setIsPressed(!isPressed)}
      className={`rounded px-4 py-2 ${isPressed ? "bg-blue-600 text-white" : "bg-neutral-200"}`}
    >
      {label}
    </button>
  );
}

For expandable/collapsible content (accordion, FAQ, dropdown):

// src/components/accordion-item.tsx
import { useState } from "react";

export function AccordionItem({ question, answer }: { question: string; answer: string }) {
  const [isExpanded, setIsExpanded] = useState(false);
  const panelId = `panel-${question.replace(/\s+/g, "-").toLowerCase()}`;

  return (
    <div>
      <h3>
        <button
          aria-expanded={isExpanded}
          aria-controls={panelId}
          onClick={() => setIsExpanded(!isExpanded)}
          className="flex w-full items-center justify-between py-3 text-left font-medium"
        >
          {question}
          <span aria-hidden="true">{isExpanded ? "−" : "+"}</span>
        </button>
      </h3>
      <div id={panelId} role="region" hidden={!isExpanded} aria-labelledby={undefined}>
        <p className="pb-4">{answer}</p>
      </div>
    </div>
  );
}

For tabs:

// src/components/tabs.tsx
import { useState } from "react";

export function Tabs({ tabs }: { tabs: { label: string; content: React.ReactNode }[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <div>
      <div role="tablist" aria-label="Settings sections">
        {tabs.map((tab, i) => (
          <button
            key={tab.label}
            role="tab"
            aria-selected={i === activeIndex}
            aria-controls={`tabpanel-${i}`}
            id={`tab-${i}`}
            tabIndex={i === activeIndex ? 0 : -1}
            onClick={() => setActiveIndex(i)}
            className={i === activeIndex ? "border-b-2 border-blue-600 font-semibold" : ""}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab, i) => (
        <div
          key={tab.label}
          role="tabpanel"
          id={`tabpanel-${i}`}
          aria-labelledby={`tab-${i}`}
          hidden={i !== activeIndex}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Plain HTML

<!-- Toggle button -->
<button aria-pressed="false" onclick="toggleDarkMode(this)">Dark Mode</button>

<script>
  function toggleDarkMode(btn) {
    const isPressed = btn.getAttribute("aria-pressed") === "true";
    btn.setAttribute("aria-pressed", !isPressed);
    document.body.classList.toggle("dark");
  }
</script>

<!-- Collapsible section -->
<button aria-expanded="false" aria-controls="details-panel" onclick="togglePanel(this)">
  Show details
</button>
<div id="details-panel" hidden>
  <p>Here are the details.</p>
</div>

<script>
  function togglePanel(btn) {
    const isExpanded = btn.getAttribute("aria-expanded") === "true";
    btn.setAttribute("aria-expanded", !isExpanded);
    const panel = document.getElementById(btn.getAttribute("aria-controls"));
    panel.hidden = isExpanded;
  }
</script>

Quick reference for ARIA states:

ControlAttributeValues
Toggle buttonaria-pressed"true" / "false"
Expand/collapsearia-expanded"true" / "false"
Tabaria-selected"true" / "false"
Checkbox/switcharia-checked"true" / "false" / "mixed"
Menu itemaria-current"page" / "true"
High impactaccessibility~2 paws

A toggle without state is a broken toggle. The cat checks every interactive control and expects it to announce what it is doing. No excuses.

How the cat scores this

The cat identifies interactive controls that have state — buttons that toggle, sections that expand, tabs that activate — and checks for corresponding ARIA state attributes. CSS classes like .active, .open, or .selected are invisible to screen readers and do not count. The cat specifically looks for aria-pressed on toggle buttons, aria-expanded on collapsible triggers, aria-selected on tabs, and aria-checked on switch/checkbox controls.

Further reading

On this page