Web Components allow developers to create reusable interface elements using standard browser technologies. They are useful when an application needs consistent controls across multiple pages, products, or frontend frameworks.
For example, a project might need a custom dropdown, a searchable selection field, or a reusable toggle switch. A Web Component can package the markup, styling, and behavior into a single element that works across different parts of an application.
However, building a control that looks correct is not the same as building one that works for everyone.
A custom control must support keyboard navigation, communicate its purpose and state to assistive technologies, respond to focus correctly, and behave predictably when users interact with it. It should also integrate with forms when it is intended to replace a standard HTML form control.
Native HTML elements already provide much of this functionality. A custom component should therefore be used when it solves a real requirement that native controls cannot meet easily.
This article explains how to build accessible Web Components and how to avoid common implementation problems.
Why Accessibility Matters in Custom Form Controls
Consider a custom dropdown that looks and behaves like a standard HTML select element. A mouse user may be able to open it, choose an option, and submit the form without any problems.
A keyboard user may have a different experience if the component does not support the expected keys. A screen-reader user may not know whether the control is a dropdown, which option is selected, or whether the control is disabled.
Accessibility requires more than adding a label to the component. Developers must consider the control's semantic role, accessible name, state, keyboard interaction, and focus behavior.
Native controls versus custom controls
Before building a custom component, compare the available options.
Requirement | Native HTML control | Custom Web Component |
|---|---|---|
Basic text input | Usually the best choice | Adds unnecessary complexity in most cases |
Standard selection list | Built-in keyboard and form behavior | Requires additional interaction logic |
Application-specific interaction | May require surrounding JavaScript | Can encapsulate reusable behavior |
Consistent design across projects | Styling can have browser limitations | Can provide a shared visual system |
Accessibility | Many behaviors are provided by the browser | Must be designed and tested deliberately |
Form integration | Built in | Requires additional work unless using a form-associated custom element |
Native controls should remain the default choice. A custom control is justified when the application needs interaction or presentation that cannot be achieved reasonably with standard elements.
Creating a Reusable Custom Toggle
A toggle switch is a useful example because it has a clear purpose and a small number of states.
The component should communicate whether the setting is enabled, support keyboard interaction, and expose its state to assistive technologies.
For a simple toggle, a native checkbox styled with CSS is often the most accessible implementation. The following example uses a Web Component to encapsulate that checkbox.
Define the component
<settings-toggle></settings-toggle>
<script>
class SettingsToggle extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host {
display: inline-block;
font-family: Arial, sans-serif;
}
label {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
input {
width: 20px;
height: 20px;
cursor: pointer;
}
input:focus-visible {
outline: 3px solid #2459c4;
outline-offset: 3px;
}
</style>
<label>
<input type="checkbox">
<span part="label">Enable notifications</span>
</label>
`;
this.checkbox = shadow.querySelector("input");
this.label = shadow.querySelector("span");
this.checkbox.addEventListener("change", () => {
this.dispatchEvent(new CustomEvent("toggle-change", {
detail: {
checked: this.checkbox.checked
},
bubbles: true,
composed: true
}));
});
}
connectedCallback() {
this.checkbox.checked = this.hasAttribute("checked");
this.checkbox.disabled = this.hasAttribute("disabled");
if (this.hasAttribute("label")) {
this.label.textContent = this.getAttribute("label");
}
}
static get observedAttributes() {
return ["checked", "disabled", "label"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (!this.checkbox) {
return;
}
if (name === "checked") {
this.checkbox.checked = newValue !== null;
}
if (name === "disabled") {
this.checkbox.disabled = newValue !== null;
}
if (name === "label") {
this.label.textContent = newValue ?? "";
}
}
get checked() {
return this.checkbox.checked;
}
set checked(value) {
this.checkbox.checked = Boolean(value);
}
get disabled() {
return this.checkbox.disabled;
}
set disabled(value) {
this.checkbox.disabled = Boolean(value);
}
}
customElements.define("settings-toggle", SettingsToggle);
</script>Use the component like this:
<settings-toggle
label="Enable email notifications"
checked>
</settings-toggle>The component uses a real checkbox instead of recreating checkbox behavior with a generic element. As a result, the browser provides native keyboard interaction, focus behavior, and checkbox semantics.
The toggle-change event allows the parent application to respond when the setting changes:
document.addEventListener("toggle-change", (event) => {
console.log("Notifications enabled:", event.detail.checked);
});This example demonstrates basic encapsulation, but it is not a complete replacement for a native form control. In particular, the internal checkbox is not automatically submitted as a field in the containing form. If form submission is required, use a native control directly or implement the form-associated custom element pattern described later.
Providing an Accessible Name
Every form control needs an accessible name that explains its purpose. Without one, a screen reader may announce a control without enough information for the user to understand it.
For example, a search field should have a label such as "Search products" rather than relying only on placeholder text.
Use visible labels where possible
For a native input, the simplest approach is to associate a label with the input.
<label for="emailAddress">Email address</label>
<input
id="emailAddress"
name="emailAddress"
type="email"
autocomplete="email"
required>The label remains available when the user starts typing, unlike a placeholder that disappears or becomes less visible.
For a custom element, there are two main approaches:
Place a native label and input inside the component when the component owns the internal control.
Define a clear labeling API when the component must behave as a single control from the outside.
Do not assume that a label placed next to a custom element automatically labels an input inside its shadow root. Shadow DOM boundaries affect how labels and form controls are associated.
Use ARIA only when necessary
ARIA attributes can provide accessibility information that is missing from generic elements, but they do not automatically implement keyboard behavior or state management.
For example, adding role="switch" to a generic element does not make the element work like a switch. The developer must still implement focusability, keyboard input, and state updates.
Whenever a native HTML element provides the required semantics, prefer it over a generic element with manually added ARIA attributes.
Supporting Keyboard Navigation
Keyboard support is one of the most important requirements for custom controls.
Users should be able to reach interactive elements with the keyboard, understand which element has focus, and activate controls without a mouse.
For common controls, follow the browser's native behavior whenever possible:
Tab moves focus between interactive elements.
Space toggles a focused checkbox or activates many buttons.
Enter activates buttons and submits forms where appropriate.
Arrow keys are used by certain composite widgets, such as radio groups and listboxes.
Do not automatically assign arrow-key behavior to every custom component. Keyboard interactions should match the control's established pattern.
Keep focus visible
A custom component should never remove the browser's focus outline without providing a clear alternative.
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 3px solid #2459c4;
outline-offset: 2px;
}The exact colors and dimensions should be checked against the application's contrast requirements and design system.
Focus indicators should remain visible against the surrounding background. They should not be hidden by overflow, overlapping elements, or styling that removes outlines.
Avoid unnecessary keyboard handlers
If a native checkbox already supports Space, there is usually no reason to implement another key handler for that action.
Adding custom keyboard listeners to native controls can cause duplicate state changes or unexpected behavior.
When a component genuinely needs custom keyboard interaction, define the supported keys explicitly and test the behavior with both keyboard-only navigation and assistive technology.
Integrating Custom Elements With HTML Forms
A common mistake is assuming that any custom element placed inside a form will automatically submit its value.
Consider this example:
<form id="settingsForm">
<settings-toggle
name="notifications"
checked>
</settings-toggle>
<button type="submit">Save settings</button>
</form>The custom element does not automatically become a successful form control merely because it has a name attribute.
There are two practical approaches.
Option 1: Use a native input inside the form
If the custom element is mainly a visual wrapper, keep a native input in the form and synchronize the component with it.
This approach can work well when the application does not need the custom element itself to participate directly in form submission.
However, avoid maintaining two independent values for the same setting. A single source of truth reduces synchronization errors.
Option 2: Use form-associated custom elements
Modern browsers support form-associated custom elements through ElementInternals. This API allows a custom element to participate in form submission, constraint validation, and form lifecycle behavior.
A simplified example looks like this:
class FormToggle extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
this.checked = false;
}
connectedCallback() {
this.updateFormValue();
}
setChecked(value) {
this.checked = Boolean(value);
this.updateFormValue();
}
updateFormValue() {
this.internals.setFormValue(
this.checked ? "on" : null
);
}
formResetCallback() {
this.checked = this.hasAttribute("checked");
this.updateFormValue();
}
formDisabledCallback(disabled) {
this.toggleAttribute("disabled", disabled);
}
}
customElements.define("form-toggle", FormToggle);This example demonstrates the form integration mechanism, not a complete visual toggle. A production implementation must include an actual interactive control, synchronize its checked state with setFormValue(), expose appropriate accessibility semantics, and implement the required disabled and reset behavior.
The form-associated custom element feature also depends on browser support, so check the browsers targeted by your application before adopting it.
Handling Disabled and Invalid States
A custom control should communicate whether it is available for interaction and whether its value is valid.
For native controls, the browser already provides much of this behavior.
<label for="accountName">Account name</label>
<input
id="accountName"
name="accountName"
type="text"
required
minlength="3">The browser can validate the required field and minimum length when the form is submitted.
For custom elements, validation behavior must be designed explicitly. Form-associated custom elements can use ElementInternals.setValidity() to report validation errors to the browser's form validation system.
When presenting validation errors:
Explain what needs to be corrected.
Associate the error message with the relevant control.
Do not communicate an error through color alone.
Preserve the user's input when validation fails.
Ensure that errors are available to screen-reader users.
Also remember that adding a disabled attribute to a custom element does not automatically disable controls inside its shadow root. The component must propagate that state to its internal interactive elements.
Common Accessibility Mistakes
Mistake | Why it causes problems | Better approach |
|---|---|---|
Using a clickable | Native keyboard and button semantics are missing | Use a button where possible |
Relying only on placeholder text | The control may have no persistent accessible name | Provide a proper label |
Removing focus outlines | Keyboard users cannot reliably identify focus | Add a visible |
Adding ARIA without implementing behavior | The control may announce a role but remain unusable | Implement the complete interaction pattern |
Assuming Shadow DOM handles form submission | Custom elements are not automatically successful form controls | Use native inputs or form-associated custom elements |
Ignoring disabled-state changes | The visual state and actual behavior may disagree | Synchronize the disabled state with internal controls |
Testing only with a mouse | Keyboard and assistive-technology problems remain hidden | Include keyboard and screen-reader testing |
How to Test a Custom Form Control
Accessibility testing should happen during development, not only before release.
Start with a keyboard-only test. Use Tab and Shift+Tab to move through the page. Check that the control receives focus in a sensible order and that the focus indicator is visible. Operate it using the expected keys without touching the mouse.
Next, test the control with a screen reader. Confirm that its name, role, current value, and disabled state are announced correctly.
Test form behavior as well. Submit valid and invalid data, reset the form, and confirm that disabled controls behave as expected. If the component uses Shadow DOM, verify that events reach the parent application when required.
Finally, test the component in the browsers and frontend frameworks supported by the product. Automated accessibility tools can detect some issues, but they cannot prove that a custom interaction is understandable or usable.
Advantages and Limitations of Web Components
Web Components provide reusable behavior, style encapsulation, and a framework-independent way to share UI elements. They are particularly useful for design systems used by several applications.
However, encapsulation also introduces additional responsibilities. Developers must carefully manage labels, events, form submission, focus, and accessible states. Shadow DOM can make some integrations less straightforward than using ordinary HTML.
The best approach is to build on native browser behavior rather than recreate it unnecessarily. A custom element should add value without removing functionality users already expect.
Summary
Accessible custom form controls require more than a consistent design. They must provide meaningful names, correct semantics, keyboard support, visible focus, reliable state management, and predictable form behavior.
Start with native HTML controls wherever possible. When a Web Component is necessary, encapsulate the behavior carefully, handle disabled and validation states, and use form-associated custom elements when direct form integration is required.
Test the component with a keyboard, a screen reader, and real form submissions before releasing it. These steps help ensure that a reusable component remains usable across different browsers, input methods, and accessibility needs.


Join the conversation! Your thoughts help the community grow.