Advanced

Custom controls and ARIA, honestly

You can build a control from divs and ARIA, and it will usually pass an automated audit while still being worse than the native element. The honest rule: build custom only when the native element cannot do the job, and know the state you must maintain.

What you will see

The second count must be 0. A focusable element inside aria-hidden is a ghost control.

About 1 min of reading

Try It

Build one custom control correctly

1

Write the markup

printf '
\n' | tee -a widget.html

Before you run it, say out loud what it will print. Then open the answer.

What it prints
A control with role, name, focusability, and a state attribute.
2

Add the handlers

printf 'el.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { act(); el.setAttribute("aria-pressed", "true"); } });\n' | tee -a widget.js

Before you run it, say out loud what it will print. Then open the answer.

What it prints
Enter and Space both act, and the pressed state is recorded in the attribute.
3

Add the focus ring

printf '.copy-btn:focus-visible { outline: 3px solid #0b5; outline-offset: 2px; }\n' | tee -a public/css/main.css

Before you run it, say out loud what it will print. Then open the answer.

What it prints
The control shows a ring when focused, like every other control.
4

Check for ghosts

grep -c 'aria-hidden' index.html; grep -A20 'aria-hidden' index.html | grep -cE 'tabindex|

Before you run it, say out loud what it will print. Then open the answer.

What it prints
The second count must be 0. A focusable element inside aria-hidden is a ghost control.
Do not set aria-hidden on a subtree that contains a control you want reachable. Remove the control from the subtree instead.

Data Flow

Custom control to announced state
1 Set role and name in markup
↓
2 Make the element focusable
↓
3 Handle Enter and Space in JS
↓
4 Toggle aria attributes on state change
↓
5 Browser announces the new state
Why this works
The state list to keep

For each custom control write down: its role, its name, its focusable state, its pressed state, and how each is announced. If you cannot write the list, use the native element.

Where ARIA is misused

Adding a role to change a native one, or adding a label to duplicate visible text, both create mismatches. Some technology announces the mismatch instead of the intent.

Examples

The minimum a custom button needs

role, a name, tabindex 0, Enter and Space handlers, a focus style, and the disabled state. That is six things, and forgetting any one of them is invisible to an automated checker.

Native wins by default

<button> gives you all six. A custom one costs about forty lines and can drift from the platform's behavior. Custom is for things with no native equivalent, like a drag handle or a slider.

aria-hidden is a promise

Anything aria-hidden must contain nothing focusable. Hiding a control that is still in the tab order creates a ghost: the user can focus and press something they cannot hear.

Words this lesson uses

Each one is defined here on this site. Open the entry for the full definition and the other words it points at.

← Forms that work without a mouse
Previous Lesson
Forms that work without a mouse
Next Lesson
Keyboard paths through interactive regions
Keyboard paths through interactive regions →