Creating custom elements
A custom element is your own HTML tag, like <rating-toggle>. Hiding its insides in a shadow root does
not excuse a missing name. Build it from real buttons and inputs, and handle focus and ids on purpose.
In one minute
- Put native elements inside: a real
<button>or<input>, not a div withrole="button". - Ids stop at the shadow edge.
aria-labelledbyandaria-describedbyonly find ids in the same root. - Delegate focus with
delegatesFocus: truesohost.focus()and clicks land on the inner control and the host is not an extra tab stop. - Test the element: keyboard, name/role/value, and two copies on one page.
A shadow root is a sealed box. Things inside can see each other, but a label outside the box cannot be pointed at from inside, and the other way around. Treat the box like a small separate page: give it everything it needs (a name, a role, focus) and decide what crosses the edge.
1. Focus delegation
Tabbing already reaches a button inside an open shadow root. The trouble starts when other code calls
host.focus() (for example to focus an invalid field), or when the host gets its own
tabindex="0". With delegatesFocus: true, focusing the host passes focus to the first focusable
thing inside, and clicking blank space in the component does the same.
Lab: focus the component from script
Step 1. Pick a version, then press "Focus the component" and read where focus went.
Keep the number of tab stops equal to the number of controls. A focusable host plus a focusable button inside means two stops for one control, which is slow for keyboard users and noisy for screen reader users.
2. Names across the shadow edge
aria-labelledby and aria-describedby look up ids inside the same root. A label in the
page (light DOM) cannot be found by an input inside a shadow root. Three fixes work. Slot the label into the
shadow tree so a label inside owns it. Copy the text onto the inner control with aria-label. Or use
form-associated custom elements with ElementInternals (internals.ariaLabel) for the host.
Lab: who names the input?
Step 2. Pick how the inner input finds its name. The lab checks whether the id can really be found.
3. Ids and two copies on one page
Shadow roots keep ids private, so two shadow copies can both use id="f". Components that render into the
normal page (light DOM) share one id space, and a repeated id means label for finds the first match.
Generate unique ids whenever a component can appear twice.
Lab: click the second label
Step 3. Choose a build, then press "Click second label". Which field receives focus?
4. Native inside, always
The same rule as lesson 05 applies inside your component. A <div role="button"> in a shadow root has
the role but no focus or key handling, so it announces as a button and then fails the keyboard (WCAG 2.1.1). Also
keep the pressed state in aria-pressed and let the host emit a normal event.
Lab: the rating-toggle
Step 4. Press Tab to the component, then Enter or Space.
Test the element, not the demo page
- Keyboard. Tab, Shift+Tab, Enter, Space, and Escape where a widget needs it. Watch for extra tab stops.
- Name, role, value. Check the accessibility tree in browser devtools. Is the name there? Is the state exposed?
- Two copies. Put two instances on one page so id clashes show up.
- Automation. A failing rule in CI is useful. A passing automated scan is not proof it works for people.
Common mistakes
| You see | Why it is a problem | Fix |
|---|---|---|
div role="button" inside the shadow root | No focus or keys. | Native <button>. |
aria-labelledby to an id outside the root | Id is not found, so the name is empty. | Slot the label or reflect the text. |
| Hard-coded ids in light DOM | Duplicate ids when used twice. | Generate unique ids. |
Host has tabindex="0" and inner button is focusable | Two tab stops for one control. | delegatesFocus: true, no host tabindex. |
| Closed shadow root with hidden labels | Hard to test and to inspect. | Prefer mode: "open". |
| State kept only in a CSS class | Screen readers cannot see it. | Reflect to aria-pressed and friends. |
- In lab 4, switch to "div role=button" and press Tab. Does the component ever receive focus?
- In lab 1, "Host tabindex" then press Tab from the "Focus the component" button. Count the stops.
- In lab 3, find the build where the second label focuses the wrong box.
Remember
- Build with native elements inside the shadow root.
- Ids and references do not cross the shadow edge: slot, copy text, or generate ids.
- Use
delegatesFocus: trueand avoid a tab stop on the host. - Test with keyboard, the accessibility tree, and two instances.