Building Accessible Web Components with Reactive Shadow DOM Techniques
Building Accessible Web Components with Reactive Shadow DOM Techniques
Accessibility and performance often sit at odds in frontend work. This guide shows a practical, code-heavy approach to building accessible web components (custom elements) that render fast, stay responsive under load, and remain usable by people who rely on assistive technologies. You’ll learn patterns for ARIA, keyboard interaction, focus management, progressive enhancement, and lightweight reactivity without pulling in heavy frameworks.
Why web components?
- Encapsulation: Styles and markup don’t leak or clash with host apps.
- Reusability: Custom elements can be dropped into any modern framework or vanilla app.
- Progressive enhancement: Works with plain HTML and gradually adds interactivity.
This tutorial uses native Web Components with the Shadow DOM, without frameworks, and adds a small, thoughtful light-weight reactivity layer to keep state management predictable.
Goals and prerequisites
- Build a small, accessible UI component: a collapsible, labeled panel (accordion item) with keyboard navigation, focus rings, and ARIA attributes.
- Use Shadow DOM for encapsulation while exposing a clean API to host pages.
- Implement a lightweight internal reactivity pattern to update UI efficiently.
- Ensure accessibility: semantic roles, ARIA, keyboard support, and focus management.
- Provide patterns for testing accessibility and performance.
Prereqs:
- Basic HTML/CSS/JavaScript familiarity
- Understanding of ARIA basics (roles, aria-expanded, aria-controls, aria-labelledby)
- Knowledge of the Shadow DOM ### Step 1: Define the component surface
We’ll implement a single custom element: . It will render a header button and a content panel. The host can create multiple items, but each item manages its own internal state.
Key surface features:
- Accessible button with proper aria attributes
- Collapsible content region with smooth height animation
- Keyboard navigation: Enter/Space toggle, Arrow keys to move focus between items (if used in a group)
- Focus management so the user isn’t disoriented when expanding/collapsing
Code skeleton (plain JS, no build tooling required):
<! example usage >
<accessible-accordion-item id="faq1" title="What is accessible web components?">
<p>Accessible web components are ...</p>
</accessible-accordion-item>
<script type="module" src="./accordion-item.js"></script>
<script>
// Optional: set up a group navigation if you render multiple items
// to demonstrate Arrow key focus movement
</script>
// accordion-item.js
class AccessibleAccordionItem extends HTMLElement {
constructor() {
super();
this._open = false;
// Attach shadow DOM
this.attachShadow({ mode: 'open' });
// Bind methods
this._toggle = this._toggle.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
}
connectedCallback() {
// Read attributes
const title = this.getAttribute('title') || 'Item';
const content = this.innerHTML;
// Basic structure
this.shadowRoot.innerHTML = `
<style>
:host { display: block; border: 1px solid #ddd; border-radius: 6px; margin: 0.5rem 0; }
.header { display: flex; align-items: center; width: 100%; background: #f5f5f5; border: none; padding: 0.75rem 1rem; cursor: pointer; font: inherit; }
.header:focus { outline: 2px solid #0066cc; outline-offset: 2px; }
.indicator { margin-left: auto; transition: transform 0.2s ease; }
.indicator[aria-pressed="true"] { transform: rotate(90deg); }
.panel { height: 0; overflow: hidden; transition: height 0.25s ease; padding: 0 1rem; }
.panel[data-state="open"] { height: auto; padding: 0.75rem 1rem; }
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.panel { transition: none; }
}
</style>
<div class="header" role="button" aria-expanded="false" aria-controls="panel">
<span class="title">${escapeHTML(title)}</span>
<span class="indicator" aria-hidden="true">▶</span>
</div>
<div id="panel" class="panel" role="region" aria-labelledby="title"></div>
`;
// Move content into panel
const panel = this.shadowRoot.getElementById('panel');
panel.innerHTML = content;
// Set IDs for ARIA
const header = this.shadowRoot.querySelector('.header');
const titleEl = this.shadowRoot.querySelector('.title');
// Ensure title has an id for aria-labelledby
if (!titleEl.id) titleEl.id = this.id ? this.id + '-title' : 'accordion-item-title';
header.setAttribute('aria-labelledby', titleEl.id);
// Set panel ID
panel.id = panel.id || (this.id ? this.id + '-panel' : 'accordion-item-panel');
// Export initial state
this._open = false;
}
_toggle() {
this._open = !this._open;
const header = this.shadowRoot.querySelector('.header');
const panel = this.shadowRoot.querySelector('.panel');
header.setAttribute('aria-expanded', String(this._open));
panel.setAttribute('data-state', this._open ? 'open' : 'closed');
}
_onKeyDown(e) {
// Space/Enter toggle, Up/Down could be used in a group
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this._toggle();
}
// You can extend with navigation between items if placed in a group
}
disconnectedCallback() {
// cleanup if needed
}
}
customElements.define('accessible-accordion-item', AccessibleAccordionItem);
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
Notes:
- The component uses a shadow DOM to encapsulate styles, reducing style conflicts.
- ARIA attributes: aria-expanded on the header button, aria-controls points to the panel, panel has role="region" and aria-labelledby linking to the header.
- The header is a div with role="button" for semantics and keyboard handling.
- A minimal inline style keeps the example self-contained. ### Step 2: Add robust keyboard interactions
Accessibility demands keyboard operability. Improve the component with explicit keyboard handling and proper focus management.
Enhanced behavior:
- Enter/Space toggles the panel
- Home/End to jump to first/last item if used in a group
- Escape to collapse if expanded (optional)
Updated code excerpt (within the class):
connectedCallback() {
// ... existing setup
this.shadowRoot.addEventListener('keydown', this._onKeyDown);
// Set initial IDs as before
}
_onKeyDown(e) {
switch (e.key) {
case ' ':
case 'Enter':
e.preventDefault();
this._toggle();
break;
case 'Escape':
if (this._open) {
e.preventDefault();
this._toggle();
}
break;
// Optional: group navigation
case 'Home':
e.preventDefault();
this.dispatchEvent(new CustomEvent('accordion-request-focus-first', { bubbles: true }));
break;
case 'End':
e.preventDefault();
this.dispatchEvent(new CustomEvent('accordion-request-focus-last', { bubbles: true }));
break;
}
}
If you implement a group wrapper component (see Step 4), you can handle Home/End to move focus between items.
Step 3: Lightweight reactivity for state changes
We want predictable UI updates without a heavy framework. A small internal state machine plus direct DOM updates is enough.
Pattern:
- Keep a private state flag (this._open)
- Centralize DOM updates in a single method (render)
- Debounce or batch updates if you plan async state changes
Example render method:
_render() {
const header = this.shadowRoot.querySelector('.header');
const panel = this.shadowRoot.querySelector('.panel');
const indicator = this.shadowRoot.querySelector('.indicator');
header.setAttribute('aria-expanded', String(this._open));
panel.setAttribute('data-state', this._open ? 'open' : 'closed');
indicator.style.transform = this._open ? 'rotate(90deg)' : 'rotate(0deg)';
}
Call this._render() after toggling state.
Step 4: Build a small accordion group (optional)
If you render multiple items in a page, a group wrapper can provide arrow-key navigation between items and coordinated ARIA attributes.
Example group component (vanilla):
class AccessibleAccordionGroup extends HTMLElement {
connectedCallback() {
this.setAttribute('role', 'region');
this.tabIndex = 0;
this._items = Array.from(this.querySelectorAll('accessible-accordion-item'));
this.addEventListener('accordion-request-focus-first', () => this._focusItem(0));
this.addEventListener('accordion-request-focus-last', () => this._focusItem(this._items.length - 1));
}
_focusItem(index) {
if (this._items[index]) {
const header = this._items[index].shadowRoot.querySelector('.header');
header.focus();
}
}
}
customElements.define('accessible-accordion-group', AccessibleAccordionGroup);
Usage:
<accessible-accordion-group>
<accessible-accordion-item title="First item">
Content 1
</accessible-accordion-item>
<accessible-accordion-item title="Second item">
Content 2
</accessible-accordion-item>
<accessible-accordion-item title="Third item">
Content 3
</accessible-accordion-item>
</accessible-accordion-group>
In a real-world scenario, you’d implement more precise focus management to cycle through headers with ArrowUp/ArrowDown, respecting disabled items, and ensuring only one item is open at a time if desired.
Step 5: Accessibility testing patterns
- Manual checks:
- Use a screen reader (NVDA/VoiceOver) to ensure announcements occur on expand/collapse.
- Verify keyboard navigation: Tab to header, Enter/Space to toggle, Shift+Tab back.
- Semantics:
- aria-expanded reflects state
- aria-controls correctly references panel ID
- role="button" on header element makes semantics clear
- Visual focus:
- Visible focus indicator (outline or custom style)
- Focus order matches DOM order
Automated checks:
- Lighthouse accessibility audits often flag missing ARIA attributes and keyboard traps.
- Unit tests: simulate keyboard events and verify state transitions and ARIA attributes.
Example unit test (pseudo-JS):
test('toggles aria-expanded on keyboard toggle', async () => {
document.body.appendChild(accordionGroup);
const item = document.querySelector('accessible-accordion-item');
const header = item.shadowRoot.querySelector('.header');
header.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(item._open).toBe(true);
expect(header.getAttribute('aria-expanded')).toBe('true');
});
Step 6: Styling for theming and accessibility
- Use CSS variables to enable theming from host pages without breaking encapsulation.
- Respect prefers-reduced-motion for transitions.
- Ensure sufficient color contrast between header background and text.
Example CSS tokens (inside shadow DOM):
:host {
bg: #fff;
border: #ddd;
header: #f5f5f5;
text: #111;
focus: #005fcc;
}
Host pages can override by setting inline styles on the host or via CSS custom properties in a parent stylesheet.
Step 7: Packaging and distribution tips
- Publish as a small, framework-agnostic utility. The web component approach is ideal for library authors who want broad compatibility.
-
Include a minimal README with:
- Usage examples
- Accessibility highlights
- API surface (attributes and events)
- Demo codesandbox or simple live demo
-
Consider providing a no-JS fallback for users with JS disabled (progressive enhancement). For example, the header could render as a simple details/summary when JS is unavailable.
Step 8: Performance considerations
Shadow DOM limits style recalculation to the component scope, reducing global reflows.
Keep the panel height animation efficient by toggling height from 0 to auto. If you need smoother animation on large contents, compute the scrollHeight and animate from 0 to that value:
panel.style.height = this._open ? `${panel.scrollHeight}px` : '0px';
- Debounce expensive layout changes and batch DOM mutations to reduce layout thrashing. ### Step-by-step recap
- Create a self-contained web component using Shadow DOM to encapsulate styles and markup.
- Build accessible structure with proper ARIA attributes and keyboard support.
- Implement a lightweight internal state and a single render/update path.
- Optional: add a group wrapper for coordinated navigation among multiple items.
- Validate accessibility with manual and automated tests.
- Expose theming via CSS variables and ensure good performance with careful animation choices. ### Real-world example: complete item and usage
Here is a single complete item class with a minimal API:
class AccessibleAccordionItem extends HTMLElement {
constructor() {
super();
this._open = false;
this.attachShadow({ mode: 'open' });
this._toggle = this._toggle.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
}
static get observedAttributes() { return ['title']; }
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'title' && this.shadowRoot) {
const header = this.shadowRoot.querySelector('.title');
if (header) header.textContent = newVal;
}
}
connectedCallback() {
const title = this.getAttribute('title') || 'Item';
const content = this.innerHTML;
this.shadowRoot.innerHTML = `
<style>
:host { display: block; border: 1px solid #ddd; border-radius: 6px; margin: 0.5rem 0; }
.header { display: flex; align-items: center; width: 100%; background: #f5f5f5; border: none; padding: 0.75rem 1rem; cursor: pointer; }
.header:focus { outline: 2px solid #0066cc; outline-offset: 2px; }
.indicator { margin-left: auto; transition: transform 0.2s ease; }
.panel { height: 0; overflow: hidden; transition: height 0.25s ease; padding: 0 1rem; }
.panel[data-state="open"] { height: auto; padding: 0.75rem 1rem; }
@media (prefers-reduced-motion: reduce) { .panel { transition: none; } }
</style>
<button class="header" aria-expanded="false" aria-controls="panel" id="${this._idForTitle()}">
<span class="title">${escapeHTML(title)}</span>
<span class="indicator" aria-hidden="true">▶</span>
</button>
<div id="panel" class="panel" role="region" aria-labelledby="${this._idForTitle()}"></div>
`;
const panel = this.shadowRoot.getElementById('panel');
panel.innerHTML = content;
this._render();
this.shadowRoot.querySelector('.header').addEventListener('click', this._toggle);
this.shadowRoot.addEventListener('keydown', this._onKeyDown);
}
_idForTitle() {
if (this._titleId) return this._titleId;
this._titleId = (this.id ? this.id + '-title' : 'accordion-item-title');
return this._titleId;
}
_render() {
const header = this.shadowRoot.querySelector('.header');
const panel = this.shadowRoot.querySelector('.panel');
header.setAttribute('aria-expanded', String(this._open));
panel.setAttribute('data-state', this._open ? 'open' : 'closed');
}
_toggle() {
this._open = !this._open;
this._render();
}
_onKeyDown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._toggle();
} else if (e.key === 'Escape' && this._open) {
e.preventDefault();
this._toggle();
} else if (e.key === 'Home') {
e.preventDefault();
this.dispatchEvent(new CustomEvent('accordion-request-focus-first', { bubbles: true }));
} else if (e.key === 'End') {
e.preventDefault();
this.dispatchEvent(new CustomEvent('accordion-request-focus-last', { bubbles: true }));
}
}
}
customElements.define('accessible-accordion-item', AccessibleAccordionItem);
Usage example:
<accessible-accordion-item title="Why choose accessibility?">
Accessibility ensures your content reaches more people and improves SEO, usability, and inclusivity.
</accessible-accordion-item>
If you’d like, I can tailor this to a specific framework-free component library you’re building (e.g., a set of common components like tabs, dropdowns, or modals) and adapt the patterns for a group context. Do you want a follow-up with a small, runnable CodeSandbox live demo and a minified production build script?
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)