Lesson 13 · Part 2 · Custom elements

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

In plain words

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.


          
Press the button above.
Press the button above.

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.

Work email

          

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?

Press the button above.
Press the button above.

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

  1. Keyboard. Tab, Shift+Tab, Enter, Space, and Escape where a widget needs it. Watch for extra tab stops.
  2. Name, role, value. Check the accessibility tree in browser devtools. Is the name there? Is the state exposed?
  3. Two copies. Put two instances on one page so id clashes show up.
  4. Automation. A failing rule in CI is useful. A passing automated scan is not proof it works for people.

Common mistakes

You seeWhy it is a problemFix
div role="button" inside the shadow rootNo focus or keys.Native <button>.
aria-labelledby to an id outside the rootId is not found, so the name is empty.Slot the label or reflect the text.
Hard-coded ids in light DOMDuplicate ids when used twice.Generate unique ids.
Host has tabindex="0" and inner button is focusableTwo tab stops for one control.delegatesFocus: true, no host tabindex.
Closed shadow root with hidden labelsHard to test and to inspect.Prefer mode: "open".
State kept only in a CSS classScreen readers cannot see it.Reflect to aria-pressed and friends.
Try it yourself
  1. In lab 4, switch to "div role=button" and press Tab. Does the component ever receive focus?
  2. In lab 1, "Host tabindex" then press Tab from the "Focus the component" button. Count the stops.
  3. In lab 3, find the build where the second label focuses the wrong box.

Remember

Edit this example in the playground