# aria-disabled in Composites, disabled in Forms

**MUST** · **ID:** `interactions-aria-disabled-vs-disabled` · **Category:** interactions
**Source:** [ARIA](https://www.w3.org/WAI/ARIA/apg/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-aria-disabled-vs-disabled

> MUST: Use `aria-disabled="true"` — not HTML `disabled` — for unavailable menu items, toolbar buttons, tabs, tree items and listbox options: `disabled` drops them out of the tab order and the arrow-key sequence, so users never discover the option exists. `aria-disabled` only changes the announcement, so YOUR handler must make activation a no-op. Keep HTML `disabled` for form controls whose state is already inferable from context.

Keep unavailable menu items, tabs and options focusable so users can discover they exist

The two spellings of "unavailable" behave completely differently, and picking the wrong one deletes information. HTML disabled makes the control unfocusable, unclickable and silent: it vanishes from the tab order, and any arrow-key handler in a composite widget is forced to skip over it, which is how a menu of six commands quietly becomes a menu of three for anyone not using a mouse. They never learn Delete or Move to… exist, so they cannot ask why the commands are unavailable or how to unlock them. aria-disabled="true" changes only the announcement — the element stays focusable and stays in the arrow-key sequence, assistive technology announces it as dimmed or unavailable, and it is YOUR handler that must make activation a no-op (the browser will not do it for you, so an aria-disabled button without a guard in its onClick is still live). Use HTML disabled where the state is already inferable from context — a form submit that is greyed out under a validation summary, a Next button on the last step. Use aria-disabled inside composite widgets: menu items, toolbar buttons, tabs, tree items, listbox options. Be aware of the overlap: "Never Put a Tooltip on a disabled Button" also reaches for aria-disabled in its good example, but its subject is tooltips and hover targets; this principle is about discoverability inside a composite, and it pairs with the one-tab-stop roving-tabindex rule — that arrow-key loop is exactly what disabled breaks.

## Rule snippet

```tsx
<li role="menuitem" tabIndex={-1} aria-disabled="true"
    onClick={(e) => { if (isDisabled) return; run(); }}>Move to…</li>
```

## Bad — do not do this

`interactions-aria-disabled-vs-disabled-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

const ITEMS = [
  { label: 'New file', unavailable: false },
  { label: 'Duplicate', unavailable: false },
  { label: 'Add comment', unavailable: true },
  { label: 'Rename', unavailable: false },
  { label: 'Move to…', unavailable: true },
  { label: 'Delete', unavailable: true },
];

export function AriaDisabledVsDisabledBad() {
  const menuRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [active, setActive] = useState(0);
  const [reachable, setReachable] = useState(0);
  const [visited, setVisited] = useState<string[]>([]);

  useEffect(() => {
    const nodes = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]');
    setReachable(Array.from(nodes ?? []).filter((el) => !el.disabled).length);
  }, []);

  // The classic "skip the disabled ones" loop. A disabled button cannot take
  // focus, so the only way to keep the arrows working is to jump over it —
  // which is exactly how the user never learns those commands exist.
  const moveTo = (from: number, step: number) => {
    let i = from + step;
    while (i >= 0 && i < ITEMS.length && ITEMS[i].unavailable) i += step;
    if (i < 0 || i >= ITEMS.length) return;
    setActive(i);
    itemRefs.current[i]?.focus();
  };

  return (
    <div className="w-full max-w-sm">
      <div
        ref={menuRef}
        role="menu"
        aria-label="File actions"
        onKeyDown={(e) => {
          if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
          e.preventDefault();
          e.stopPropagation();
          moveTo(active, e.key === 'ArrowDown' ? 1 : -1);
        }}
        className="space-y-0.5 rounded-lg border border-border bg-card p-2"
      >
        {ITEMS.map((item, index) => (
          <button
            key={item.label}
            ref={(el) => {
              itemRefs.current[index] = el;
            }}
            type="button"
            role="menuitem"
            // `disabled` drops the item out of the tab order AND makes it
            // unfocusable, so it is unreachable by any keyboard route.
            disabled={item.unavailable}
            tabIndex={index === active ? 0 : -1}
            onFocus={() =>
              setVisited((prev) =>
                prev.includes(item.label) ? prev : [...prev, item.label]
              )
            }
            className="block w-full rounded px-2 py-1 text-left text-sm text-foreground hover:bg-accent focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
          >
            {item.label}
          </button>
        ))}
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        Items the arrow keys can reach:{' '}
        <span className="font-medium text-destructive">{reachable}</span> of {ITEMS.length}
        <br />
        Reached so far: <span className="font-medium">{visited.length}</span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Tab into the menu, then hold ArrowDown to the bottom. You will land on{' '}
        <em>New file</em>, <em>Duplicate</em>, <em>Rename</em> — and stop. A screen
        reader user is never told that <em>Add comment</em>, <em>Move to…</em> and{' '}
        <em>Delete</em> are even part of this menu, so they cannot ask why the commands
        are unavailable or how to unlock them.
      </p>

      <p className="mt-2 text-xs text-destructive">
        HTML disabled inside a composite widget silently deletes the option from the keyboard
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-aria-disabled-vs-disabled-good`

```tsx
import { useEffect, useRef, useState } from 'react';

const ITEMS = [
  { label: 'New file', unavailable: false },
  { label: 'Duplicate', unavailable: false },
  { label: 'Add comment', unavailable: true },
  { label: 'Rename', unavailable: false },
  { label: 'Move to…', unavailable: true },
  { label: 'Delete', unavailable: true },
];

export function AriaDisabledVsDisabledGood() {
  const menuRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [active, setActive] = useState(0);
  const [reachable, setReachable] = useState(0);
  const [visited, setVisited] = useState<string[]>([]);
  const [log, setLog] = useState<string | null>(null);

  useEffect(() => {
    const nodes = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]');
    setReachable(Array.from(nodes ?? []).filter((el) => !el.disabled).length);
  }, []);

  // No skipping. Every item is focusable, so the arrows walk the real menu.
  const moveTo = (index: number) => {
    const next = Math.min(Math.max(index, 0), ITEMS.length - 1);
    setActive(next);
    itemRefs.current[next]?.focus();
  };

  return (
    <div className="w-full max-w-sm">
      <div
        ref={menuRef}
        role="menu"
        aria-label="File actions"
        onKeyDown={(e) => {
          if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
          e.preventDefault();
          e.stopPropagation();
          moveTo(active + (e.key === 'ArrowDown' ? 1 : -1));
        }}
        className="space-y-0.5 rounded-lg border border-border bg-card p-2"
      >
        {ITEMS.map((item, index) => (
          <button
            key={item.label}
            ref={(el) => {
              itemRefs.current[index] = el;
            }}
            type="button"
            role="menuitem"
            // aria-disabled keeps the item focusable and announced ("dimmed" /
            // "unavailable"), so the option is discoverable. The handler — not
            // the browser — is responsible for making activation a no-op.
            aria-disabled={item.unavailable || undefined}
            tabIndex={index === active ? 0 : -1}
            onFocus={() =>
              setVisited((prev) =>
                prev.includes(item.label) ? prev : [...prev, item.label]
              )
            }
            onClick={() => {
              if (item.unavailable) {
                setLog(`“${item.label}” is unavailable — you have view-only access`);
                return;
              }
              setLog(`Ran “${item.label}”`);
            }}
            className={`block w-full rounded px-2 py-1 text-left text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
              item.unavailable
                ? 'cursor-default text-muted-foreground'
                : 'text-foreground hover:bg-accent'
            }`}
          >
            {item.label}
          </button>
        ))}
      </div>

      <p className="mt-3 rounded border border-border bg-muted px-3 py-2 text-xs text-foreground">
        Items the arrow keys can reach:{' '}
        <span className="font-medium text-success">{reachable}</span> of {ITEMS.length}
        <br />
        Reached so far: <span className="font-medium">{visited.length}</span>
        <br />
        <span className="text-muted-foreground">{log ?? 'Nothing activated yet.'}</span>
      </p>

      <p className="mt-3 text-xs text-muted-foreground">
        Tab into the menu and hold ArrowDown — focus lands on all six, including the
        three that are dimmed. Activate one: nothing happens to the file, and you are
        told <em>why</em> it is unavailable. Keep plain <code>disabled</code> for form
        controls whose state a label already explains; reach for{' '}
        <code>aria-disabled</code> in menus, toolbars, tabs, tree items and listbox
        options, where the option itself is the information.
      </p>

      <p className="mt-2 text-xs text-success">
        aria-disabled keeps the option discoverable while making activation a no-op
      </p>
    </div>
  );
}
```

## References

- [APG: Developing a Keyboard Interface — Focusability of disabled controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
- [APG: Menu and Menubar pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/)
