Accessibility in Web Components
Ensuring Inclusivity in Custom Elements

I am an aspiring web developer on a mission to kick down the door into tech. Join me as I take the essential steps toward this goal and hopefully inspire others to do the same!
Search for a command to run...
Ensuring Inclusivity in Custom Elements

I am an aspiring web developer on a mission to kick down the door into tech. Join me as I take the essential steps toward this goal and hopefully inspire others to do the same!
No comments yet. Be the first to comment.
Step-by-step guide: Master web dev from HTML/CSS basics to Web Components. Learn semantic markup, accessibility, responsive design, DOM manipulation, custom elements, Shadow DOM, and reusable UI patterns for real-world apps.
Leveraging Custom Elements for Scalable and Maintainable Web Applications
My Life-Long PC Building Adventure

Where Innovation Meets Standardization

Advanced Patterns for Web Components in Modern Frameworks

Leveraging Custom Elements for Scalable and Maintainable Web Applications

Meet Sarah, a web user who navigates the internet using a screen reader. When she encounters custom elements on websites, her experience isn't determined by visual design—it's determined by whether developers prioritized accessibility. When developers overlook the needs of millions of users with disabilities, they transform bridges into barriers, creating frustration instead of inclusive solutions.
Web components unlock a world of possibilities, empowering developers to craft highly customizable, reusable elements that elevate both modularity and maintainability. Yet their true power lies not just in technical elegance, but in their ability to serve all users. This guide will take you through essential strategies for creating truly inclusive web components—exploring semantic HTML, ARIA roles, keyboard navigation, and robust testing methodologies. You'll find interactive examples throughout that invite you to experiment, transforming abstract concepts into tangible skills you can immediately apply.
With these fundamentals in mind, let's see accessibility in action.
The following examples demonstrate how seemingly small details create dramatically different user experiences:
Before diving into details, let's visualize the difference accessibility makes:
Try using only your keyboard (Tab and Enter) to interact with both buttons above. Notice how the accessible version provides:
These seemingly small details make a tremendous difference for users relying on assistive technologies.
Now that we've seen the tangible impact of accessibility features, let's explore the core principles that make web components truly inclusive for all users.
Accessibility (often abbreviated as A11y) involves designing products usable by people with disabilities. For Web Components, this means ensuring custom elements are perceivable, operable, understandable, and robust for all users.
Key Considerations:
While these accessibility considerations apply to all web development, Web Components introduce unique challenges through their use of Shadow DOM—an essential feature that requires thoughtful implementation.
Web Components use Shadow DOM to encapsulate styles and functionality, creating unique accessibility challenges:
Despite these challenges, you can create fully accessible Web Components with the right approach. Let's explore practical implementation strategies that address these concerns while maintaining the benefits of encapsulation.
These challenges can be overcome with proper implementation, as demonstrated in the examples below.
Start with native HTML elements that inherently provide accessibility. When creating custom elements, assign appropriate ARIA roles and properties to convey their purpose and state.
class AccessibleToggle extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.pressed = false;
this.render();
}
connectedCallback() {
this.shadowRoot.querySelector('button').addEventListener('click', () => this.toggle());
this.shadowRoot.querySelector('button').addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.toggle();
}
});
}
toggle() {
this.pressed = !this.pressed;
this.render();
this.dispatchEvent(new CustomEvent('toggle', {
bubbles: true,
detail: { pressed: this.pressed }
}));
}
render() {
const button = this.shadowRoot.querySelector('button') || document.createElement('button');
button.setAttribute('role', 'button');
button.setAttribute('aria-pressed', this.pressed);
button.setAttribute('aria-label', this.getAttribute('label') || 'Toggle');
button.innerHTML = this.pressed ? 'ON' : 'OFF';
button.className = this.pressed ? 'pressed' : '';
if (!this.shadowRoot.querySelector('button')) {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 8px 16px;
border: 2px solid #333;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s;
}
button:focus-visible {
outline: 3px solid blue;
outline-offset: 2px;
}
button.pressed {
background-color: #4CAF50;
color: white;
}
button:not(.pressed) {
background-color: #f1f1f1;
}
</style>
`;
this.shadowRoot.appendChild(button);
}
}
}
customElements.define('accessible-toggle', AccessibleToggle);
Key accessibility features:
aria-pressed to convey state to assistive technologiesTry navigating to the button with Tab and activating it with Enter or Space. A screen reader would announce: "Toggle button, not pressed" and after activation: "Toggle button, pressed."
With the foundation of proper semantics established, let's focus on how users navigate through interactive components without a mouse.
Proper focus management is crucial, especially with dynamic content or modal dialogs. Users navigating with keyboards need clear focus indication and logical tab order.
class AccessibleModal extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._previouslyFocusedElement = null;
this.render();
}
connectedCallback() {
const trigger = this.shadowRoot.querySelector('#modal-trigger');
const closeBtn = this.shadowRoot.querySelector('#modal-close');
const dialog = this.shadowRoot.querySelector('[role="dialog"]');
trigger.addEventListener('click', () => this.open());
closeBtn.addEventListener('click', () => this.close());
// Close on Escape key
this.addEventListener('keydown', e => {
if (e.key === 'Escape' && !dialog.hasAttribute('aria-hidden')) {
this.close();
}
});
// Close when clicking outside the modal
this.shadowRoot.querySelector('.modal-overlay').addEventListener('click', e => {
if (e.target.classList.contains('modal-overlay')) {
this.close();
}
});
}
open() {
this._previouslyFocusedElement = document.activeElement;
const dialog = this.shadowRoot.querySelector('[role="dialog"]');
dialog.removeAttribute('aria-hidden');
this.shadowRoot.querySelector('.modal-overlay').style.display = 'flex';
this.shadowRoot.querySelector('#modal-close').focus();
// Trap focus inside modal
this._handleFocusTrap();
}
close() {
this.shadowRoot.querySelector('[role="dialog"]').setAttribute('aria-hidden', 'true');
this.shadowRoot.querySelector('.modal-overlay').style.display = 'none';
// Restore focus
if (this._previouslyFocusedElement) {
this._previouslyFocusedElement.focus();
}
}
_handleFocusTrap() {
const focusableElements = this.shadowRoot.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
this.shadowRoot.addEventListener('keydown', e => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
});
}
render() {
this.shadowRoot.innerHTML = `
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 20px;
border-radius: 4px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
button {
padding: 8px 16px;
cursor: pointer;
border-radius: 4px;
}
button:focus-visible {
outline: 3px solid blue;
outline-offset: 2px;
}
#modal-close {
float: right;
background: none;
border: 1px solid #ccc;
font-size: 16px;
}
#modal-trigger {
background-color: #4CAF50;
color: white;
border: none;
font-size: 16px;
}
</style>
<button id="modal-trigger">Open Modal</button>
<div class="modal-overlay">
<div role="dialog" aria-labelledby="modal-title" aria-modal="true" aria-hidden="true" class="modal-content">
<button id="modal-close" aria-label="Close dialog">×</button>
<h2 id="modal-title">Accessible Modal Dialog</h2>
<p>This is an example of an accessible modal dialog component.</p>
<p>It traps focus inside the modal, can be closed with the ESC key, and restores focus when closed.</p>
<button>Test Button 1</button>
<button>Test Button 2</button>
</div>
</div>
`;
}
}
customElements.define('accessible-modal', AccessibleModal);
Key accessibility features:
dialog, aria-labelledby, aria-modal)Try opening the modal and navigating with Tab. Notice how focus is trapped inside the modal and restored to the trigger button when closed.
While proper keyboard navigation creates a solid foundation, complex components like tabs, accordions, and carousels require specialized interaction patterns to maintain usability.
class AccessibleTabs extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}
connectedCallback() {
const tabs = Array.from(this.shadowRoot.querySelectorAll('[role="tab"]'));
const panels = Array.from(this.shadowRoot.querySelectorAll('[role="tabpanel"]'));
// Select first tab by default
this.selectTab(tabs[0]);
tabs.forEach(tab => {
tab.addEventListener('click', () => this.selectTab(tab));
tab.addEventListener('keydown', e => {
// Arrow key navigation
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
const currentIndex = tabs.indexOf(tab);
let nextIndex;
if (e.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length;
} else {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
}
tabs[nextIndex].focus();
this.selectTab(tabs[nextIndex]);
}
// Home and End keys for first/last tabs
if (e.key === 'Home') {
e.preventDefault();
tabs[0].focus();
this.selectTab(tabs[0]);
}
if (e.key === 'End') {
e.preventDefault();
tabs[tabs.length - 1].focus();
this.selectTab(tabs[tabs.length - 1]);
}
});
});
}
selectTab(selectedTab) {
const tabs = Array.from(this.shadowRoot.querySelectorAll('[role="tab"]'));
const panels = Array.from(this.shadowRoot.querySelectorAll('[role="tabpanel"]'));
tabs.forEach(tab => {
const selected = tab === selectedTab;
tab.setAttribute('aria-selected', selected);
tab.setAttribute('tabindex', selected ? '0' : '-1');
});
panels.forEach(panel => {
const isSelected = panel.id === selectedTab.getAttribute('aria-controls');
panel.hidden = !isSelected;
});
}
render() {
this.shadowRoot.innerHTML = `
<style>
.tabs {
display: flex;
border-bottom: 1px solid #ccc;
}
[role="tab"] {
padding: 10px 15px;
margin-right: 5px;
background-color: #f1f1f1;
border: 1px solid #ccc;
border-bottom: none;
border-radius: 4px 4px 0 0;
cursor: pointer;
position: relative;
top: 1px;
}
[role="tab"][aria-selected="true"] {
background-color: white;
border-bottom: 1px solid white;
}
[role="tab"]:focus-visible {
outline: 3px solid blue;
outline-offset: 2px;
}
[role="tabpanel"] {
padding: 20px;
border: 1px solid #ccc;
border-top: none;
}
</style>
<div class="tabs-container">
<div role="tablist" class="tabs" aria-label="Programming information">
<button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1" tabindex="0">HTML</button>
<button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">CSS</button>
<button role="tab" id="tab-3" aria-selected="false" aria-controls="panel-3" tabindex="-1">JavaScript</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
<h3>HTML</h3>
<p>HTML (HyperText Markup Language) is the standard markup language for documents designed to be displayed in a web browser.</p>
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<h3>CSS</h3>
<p>CSS (Cascading Style Sheets) is a style sheet language used for describing the presentation of a document written in HTML.</p>
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
<h3>JavaScript</h3>
<p>JavaScript is a programming language that enables interactive web pages and is an essential part of web applications.</p>
</div>
</div>
`;
}
}
customElements.define('accessible-tabs', AccessibleTabs);
Key accessibility features:
tablist, tab, and tabpanelaria-selected and tabindexaria-controls and aria-labelledbyTry using the arrow keys to navigate between tabs. Notice how this follows the expected keyboard interaction pattern for tabs.
Beyond static structure and interaction, many modern interfaces feature dynamic content that changes without page reloads. This presents another accessibility challenge: how do we keep non-visual users informed of these changes?
class LiveRegion extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}
connectedCallback() {
const button = this.shadowRoot.querySelector('button');
const liveRegion = this.shadowRoot.querySelector('[aria-live]');
let count = 0;
button.addEventListener('click', () => {
count++;
liveRegion.textContent = `Button clicked ${count} time${count === 1 ? '' : 's'}`;
});
}
render() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:focus-visible {
outline: 3px solid blue;
outline-offset: 2px;
}
.live-region {
margin-top: 10px;
padding: 10px;
background-color: #f8f8f8;
border-radius: 4px;
}
</style>
<div>
<button>Increment Counter</button>
<div class="live-region" aria-live="polite">Counter starts at 0</div>
</div>
`;
}
}
customElements.define('live-region', LiveRegion);
Key accessibility feature:
aria-live="polite" to announce changes to screen readers without interrupting current announcementsImplementing these accessibility features is only half the journey. To ensure your components work reliably for all users, you need a systematic approach to testing. Let's explore the tools and techniques that will help you verify your implementation meets accessibility standards.
Utilize tools like axe-core or Lighthouse:
// Example of using axe-core for accessibility testing
import axe from 'axe-core';
// Test a specific web component
axe.run(document.querySelector('accessible-tabs'), (err, results) => {
if (err) {
console.error('Accessibility testing failed:', err);
} else {
console.log('Accessibility test results:', results);
}
});
Lighthouse is an open-source automated tool developed by Google that audits web pages for performance, accessibility, progressive web apps, SEO, and more.
# Install Lighthouse globally
npm install -g lighthouse
# Run Lighthouse accessibility audit
lighthouse https://example.com --only-categories=accessibility --output=html --output-path=./report.html
Automated tests can't catch everything. Manual testing should include:
Keyboard Testing:
Screen Reader Testing:
High Contrast Mode:
This interactive CodePen demonstrates how screen readers interpret web content, without requiring any software installation. Simply enter your HTML or use the built-in generator to compare non-semantic versus semantic markup. See firsthand how thoughtful HTML structure dramatically changes the experience for users who rely on assistive technology. Test your own code to identify potential accessibility improvements. Note: This simulator provides educational insights only and should not replace testing with actual screen reader technology when evaluating web accessibility.
To test with NVDA (NonVisual Desktop Access), download and install it from nvaccess.org. After installation, you'll hear a startup sound and voice feedback will begin. Use the NVDA key (usually Insert) combined with other keys to navigate. Press NVDA+Space to toggle focus and browse modes when testing web applications.
Pay attention to how NVDA announces your interface elements. Ensure all interactive elements are properly labeled and keyboard-accessible. Test different scenarios: form completion, menu navigation, and custom component interaction. Listen for proper announcements of headings (NVDA+H to navigate), landmarks (NVDA+D), and alerts. Verify that dynamic content changes are announced and error messages are accessible.
Even with thorough testing, certain accessibility issues frequently emerge in Web Component development. By understanding these common pitfalls, you can address them proactively in your implementation.
Problem: ARIA relationships that cross shadow boundaries don't work as expected.
Solution: Keep related elements within the same shadow root or use alternative techniques like aria-live regions for updates.
Problem: Components only work with mouse events (click, hover).
Solution: Include keyboard event handlers and touch events for mobile accessibility.
// Bad example - only mouse support
button.addEventListener('click', handleAction);
// Good example - comprehensive interaction support
button.addEventListener('click', handleAction);
button.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleAction();
}
});
Problem: Focus gets lost during dynamic updates or when elements are removed.
Solution: Always manage focus explicitly, especially when showing/hiding content.
Problem: Text or UI controls don't have sufficient contrast with their backgrounds.
Solution: Follow WCAG guidelines for contrast ratios (4.5:1 for normal text, 3:1 for large text).
Now that we've identified potential problems, let's consolidate what we've learned into actionable best practices that will guide your development process from ideation through implementation.
aria-label, aria-labelledby, or semantic HTML.These best practices come together most effectively when applied to real-world scenarios. Let's examine a comprehensive example that integrates multiple accessibility features into a practical, commonly-used component:
Let's combine everything we've learned into a practical example:
class AccessibleSearch extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.results = [];
this.render();
}
connectedCallback() {
const input = this.shadowRoot.querySelector('input');
const button = this.shadowRoot.querySelector('button');
const results = this.shadowRoot.querySelector('.results');
// Example data - in a real component, this might come from an API
const sampleData = [
'JavaScript', 'TypeScript', 'HTML', 'CSS', 'React',
'Angular', 'Vue', 'Svelte', 'Web Components', 'Accessibility'
];
input.addEventListener('input', () => {
const query = input.value.toLowerCase();
if (query.length < 2) {
this.results = [];
} else {
this.results = sampleData.filter(item =>
item.toLowerCase().includes(query)
);
}
this.updateResults();
});
button.addEventListener('click', () => {
this.performSearch();
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
this.performSearch();
}
// Allow keyboard navigation through results
if (e.key === 'ArrowDown' && this.results.length > 0) {
e.preventDefault();
this.shadowRoot.querySelector('.result-item').focus();
}
});
// Delegate event listener for result items
results.addEventListener('keydown', e => {
if (!e.target.classList.contains('result-item')) return;
const resultItems = Array.from(this.shadowRoot.querySelectorAll('.result-item'));
const currentIndex = resultItems.indexOf(e.target);
if (e.key === 'ArrowDown' && currentIndex < resultItems.length - 1) {
e.preventDefault();
resultItems[currentIndex + 1].focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (currentIndex === 0) {
input.focus();
} else {
resultItems[currentIndex - 1].focus();
}
}
if (e.key === 'Enter') {
e.preventDefault();
this.selectResult(e.target.textContent);
}
});
}
performSearch() {
const announcement = this.shadowRoot.querySelector('.screen-reader-announcement');
const resultCount = this.results.length;
announcement.textContent = `Found ${resultCount} result${resultCount === 1 ? '' : 's'}`;
}
selectResult(text) {
const input = this.shadowRoot.querySelector('input');
input.value = text;
this.results = [];
this.updateResults();
input.focus();
// Announce selection to screen readers
const announcement = this.shadowRoot.querySelector('.screen-reader-announcement');
announcement.textContent = `Selected ${text}`;
}
updateResults() {
const resultsContainer = this.shadowRoot.querySelector('.results');
if (this.results.length === 0) {
resultsContainer.innerHTML = '';
resultsContainer.setAttribute('aria-hidden', 'true');
return;
}
resultsContainer.removeAttribute('aria-hidden');
resultsContainer.innerHTML = this.results.map(result =>
`<button class="result-item" tabindex="0">${result}</button>`
).join('');
}
render() {
this.shadowRoot.innerHTML = `
<style>
.search-container {
position: relative;
width: 300px;
}
.search-input {
display: flex;
}
input {
flex: 1;
padding: 8px;
border: 2px solid #ccc;
border-radius: 4px 0 0 4px;
font-size: 16px;
}
input:focus {
outline: none;
border-color: blue;
}
button {
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
}
button:focus-visible {
outline: 3px solid blue;
outline-offset: 2px;
}
.results {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
background: white;
border: 1px solid #ccc;
border-top: none;
max-height: 200px;
overflow-y: auto;
}
.result-item {
display: block;
width: 100%;
text-align: left;
padding: 8px;
background: none;
border: none;
border-bottom: 1px solid #eee;
cursor: pointer;
}
.result-item:hover, .result-item:focus {
background-color: #f1f1f1;
}
.screen-reader-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>
<div class="search-container">
<label id="search-label" for="search-input">Search</label>
<div class="search-input">
<input
id="search-input"
type="text"
aria-labelledby="search-label"
aria-autocomplete="list"
aria-controls="search-results"
autocomplete="off"
/>
<button aria-label="Search">Go</button>
</div>
<div
id="search-results"
class="results"
role="listbox"
aria-label="Search suggestions"
aria-hidden="true"
></div>
<div
class="screen-reader-announcement screen-reader-only"
aria-live="polite"
></div>
</div>
`;
}
}
customElements.define('accessible-search', AccessibleSearch);
Key accessibility features:
As this search component demonstrates, accessible Web Components require attention to multiple factors—from semantic structure to keyboard interactions and dynamic announcements. By applying these techniques to your own components, you'll create experiences that work for everyone.
Creating accessible Web Components isn't just about compliance—it's about creating a better web for everyone. By incorporating these practices from the start, you'll build components that are more robust, user-friendly, and inclusive.
Remember these key principles:
By building accessibility into your Web Components from the ground up, you're not just helping users with disabilities—you're creating a better experience for everyone.