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:

Join the conversation! Your thoughts help the community grow.