Framework-Specific Accessibility
1. React Accessibility Patterns
| Pattern | React Approach | Common Pitfalls | Best Practice |
|---|---|---|---|
| Fragment (<React.Fragment>) | Use to avoid unnecessary wrapper divs | Breaks accessibility when landmarks needed | Use semantic elements or fragments; don't wrap landmarks in divs |
| Refs for focus management | useRef + useEffect to manage focus | Focus set too early (before render) | Set focus in useEffect after component mounts/updates |
| Event handlers | onClick works for keyboard (Enter/Space) | Using onMouseDown/onMouseUp only | Use onClick on buttons; it handles keyboard automatically |
| ARIA attributes | Use camelCase: aria-label → aria-label (exception) | Using ariaLabel instead of aria-label | ARIA attributes stay lowercase with hyphens in JSX |
| Live regions | Create with useRef, update with state | Creating/destroying live regions on every update | Create once on mount, update content only |
| Dynamic content | Use keys for list items | Using index as key in dynamic lists | Use stable unique IDs; index only for static lists |
| Forms | Controlled components with labels | Missing label association | Use htmlFor on labels or wrap input in label |
| Portals | ReactDOM.createPortal for modals | Focus trap broken in portals | Manage focus explicitly; use focus-trap-react |
Example: React accessibility patterns
import { useRef, useEffect, useState } from 'react';
// Focus management with refs
function AccessibleModal({ isOpen, onClose, children }) {
const dialogRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Store previous focus
previousFocusRef.current = document.activeElement;
// Focus dialog
dialogRef.current?.focus();
} else if (previousFocusRef.current) {
// Restore focus when closed
previousFocusRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabIndex={-1}
>
<h2 id="dialog-title">Modal Title</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
// Live region announcer hook
function useAnnouncer() {
const announcerRef = useRef(null);
useEffect(() => {
// Create live region once on mount
if (!announcerRef.current) {
const div = document.createElement('div');
div.setAttribute('role', 'status');
div.setAttribute('aria-live', 'polite');
div.setAttribute('aria-atomic', 'true');
div.className = 'sr-only';
document.body.appendChild(div);
announcerRef.current = div;
}
return () => {
// Cleanup on unmount
announcerRef.current?.remove();
};
}, []);
const announce = (message) => {
if (announcerRef.current) {
announcerRef.current.textContent = '';
setTimeout(() => {
announcerRef.current.textContent = message;
}, 100);
}
};
return announce;
}
// Usage
function ShoppingCart() {
const [items, setItems] = useState([]);
const announce = useAnnouncer();
const addItem = (item) => {
setItems([...items, item]);
announce(`${item.name} added to cart. ${items.length + 1} items total.`);
};
return (
<div>
<h2>Shopping Cart</h2>
{/* Cart content */}
</div>
);
}
// Accessible form with proper labels
function ContactForm() {
const [formData, setFormData] = useState({ name: '', email: '' });
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
// Validation logic
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" role="alert">
{errors.name}
</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
// Accessible list with proper keys
function ItemList({ items }) {
return (
<ul role="list">
{items.map((item) => (
<li key={item.id} role="listitem">
{item.name}
</li>
))}
</ul>
);
}
// React Router - accessible route changes
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function RouteAnnouncer() {
const location = useLocation();
const announce = useAnnouncer();
useEffect(() => {
// Announce page changes
const title = document.title;
announce(`Navigated to ${title}`);
// Focus main heading
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
}, [location, announce]);
return null;
}
React A11y Tools: eslint-plugin-jsx-a11y, @axe-core/react, react-aria hooks, @reach/ui
components, Testing Library with accessible queries (getByRole, getByLabelText). Always test with screen
readers.
2. Vue.js Accessibility Implementation
| Vue Feature | Accessibility Usage | Example | Common Issues |
|---|---|---|---|
| Template refs | Access DOM for focus management | ref="inputRef" then this.$refs.inputRef.focus() |
Refs null until mounted |
| v-bind for ARIA | Bind ARIA attributes dynamically | :aria-expanded="isOpen" |
Vue 2 required .prop modifier for some ARIA |
| Teleport (Vue 3) | Move modals to body while maintaining component state | <Teleport to="body"> |
Focus trap needs manual management |
| Transition component | Announce state changes with ARIA live | Use @after-enter hook to announce | Animation-only, no automatic announcements |
| v-model on custom inputs | Two-way binding for accessible forms | v-model="formData.email" |
Need to emit 'update:modelValue' in Vue 3 |
| Watchers | Announce changes to screen readers | Watch data, update live region | Can cause announcement spam if not debounced |
| Directives | Create custom v-focus, v-trap-focus directives | Reusable focus management logic | Lifecycle hooks need proper cleanup |
Example: Vue.js accessibility patterns
<!-- Vue 3 Composition API -->
<template>
<div>
<button
@click="toggleDialog"
:aria-expanded="isDialogOpen"
aria-controls="dialog"
>
Open Dialog
</button>
<Teleport to="body">
<div
v-if="isDialogOpen"
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
@keydown.esc="closeDialog"
>
<h2 id="dialog-title">Dialog Title</h2>
<button @click="closeDialog">Close</button>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref, watch, onMounted, nextTick } from 'vue';
const isDialogOpen = ref(false);
const dialogRef = ref(null);
let previousFocus = null;
const toggleDialog = () => {
isDialogOpen.value = !isDialogOpen.value;
};
const closeDialog = () => {
isDialogOpen.value = false;
};
// Focus management
watch(isDialogOpen, async (newValue) => {
if (newValue) {
previousFocus = document.activeElement;
await nextTick();
dialogRef.value?.focus();
} else if (previousFocus) {
previousFocus.focus();
}
});
</script>
<!-- Accessible form component -->
<template>
<form @submit.prevent="handleSubmit">
<div>
<label :for="inputId">{{ label }}</label>
<input
:id="inputId"
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
:aria-required="required"
:aria-invalid="hasError"
:aria-describedby="hasError ? `${inputId}-error` : undefined"
/>
<span
v-if="hasError"
:id="`${inputId}-error`"
role="alert"
>
{{ errorMessage }}
</span>
</div>
</form>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
modelValue: String,
label: String,
type: { type: String, default: 'text' },
required: Boolean,
errorMessage: String,
inputId: String
});
const emit = defineEmits(['update:modelValue']);
const hasError = computed(() => !!props.errorMessage);
</script>
<!-- Announcer composable -->
<script>
import { onMounted, onUnmounted } from 'vue';
export function useAnnouncer() {
let announcerElement = null;
onMounted(() => {
announcerElement = document.createElement('div');
announcerElement.setAttribute('role', 'status');
announcerElement.setAttribute('aria-live', 'polite');
announcerElement.setAttribute('aria-atomic', 'true');
announcerElement.className = 'sr-only';
document.body.appendChild(announcerElement);
});
onUnmounted(() => {
announcerElement?.remove();
});
const announce = (message) => {
if (announcerElement) {
announcerElement.textContent = '';
setTimeout(() => {
announcerElement.textContent = message;
}, 100);
}
};
return { announce };
}
</script>
<!-- Custom focus directive -->
<script>
export const vFocus = {
mounted(el) {
el.focus();
}
};
// Usage: <input v-focus />
</script>
<!-- Vue Router - route announcements -->
<script setup>
import { watch } from 'vue';
import { useRoute } from 'vue-router';
import { useAnnouncer } from '@/composables/useAnnouncer';
const route = useRoute();
const { announce } = useAnnouncer();
watch(() => route.path, () => {
// Announce route change
announce(`Navigated to ${route.meta.title || route.name}`);
// Focus main heading
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
</script>
Vue A11y Resources: vue-axe for development warnings, vue-announcer for screen reader
announcements, @vue-a11y/eslint-config-vue for linting. Use Vue DevTools to inspect component accessibility
tree.
3. Angular A11y Module Usage
| Angular CDK Feature | Purpose | Usage | Key Directives/Services |
|---|---|---|---|
| A11yModule | Core accessibility utilities | Import from @angular/cdk/a11y | Complete Angular CDK accessibility toolkit |
| LiveAnnouncer | ARIA live region announcements | Inject service, call announce() | Manages live region creation/updates |
| FocusTrap | Trap focus within element (modals) | cdkTrapFocus directive | Automatic focus cycling in dialogs |
| FocusMonitor | Track focus origin (keyboard/mouse/touch) | Inject service, monitor(element) | Different styling for keyboard vs mouse focus |
| ListKeyManager | Keyboard navigation in lists | ActiveDescendantKeyManager or FocusKeyManager | Arrow key navigation, Home/End support |
| cdkAriaLive | Declarative live regions | <div cdkAriaLive="polite"> |
Alternative to LiveAnnouncer service |
| InteractivityChecker | Check if element is focusable/visible | isFocusable(), isVisible() | Utility for focus management logic |
Example: Angular CDK accessibility features
// app.module.ts
import { A11yModule } from '@angular/cdk/a11y';
@NgModule({
imports: [A11yModule],
// ...
})
export class AppModule { }
// modal.component.ts
import { Component } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
@Component({
selector: 'app-modal',
template: `
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
cdkTrapFocus
[cdkTrapFocusAutoCapture]="true"
>
<h2 id="dialog-title">Modal Title</h2>
<p>Modal content</p>
<button (click)="close()">Close</button>
</div>
`
})
export class ModalComponent {
constructor(private liveAnnouncer: LiveAnnouncer) {}
ngOnInit() {
this.liveAnnouncer.announce('Modal opened');
}
close() {
this.liveAnnouncer.announce('Modal closed');
// Close logic
}
}
// focus-monitor.component.ts
import { Component, ElementRef, OnInit, OnDestroy } from '@angular/core';
import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
@Component({
selector: 'app-button',
template: `
<button
#button
[class.keyboard-focus]="focusOrigin === 'keyboard'"
>
Click me
</button>
`
})
export class ButtonComponent implements OnInit, OnDestroy {
@ViewChild('button') buttonRef: ElementRef;
focusOrigin: FocusOrigin = null;
constructor(private focusMonitor: FocusMonitor) {}
ngOnInit() {
this.focusMonitor.monitor(this.buttonRef)
.subscribe(origin => {
this.focusOrigin = origin;
});
}
ngOnDestroy() {
this.focusMonitor.stopMonitoring(this.buttonRef);
}
}
// list-key-manager.component.ts
import { Component, QueryList, ViewChildren, AfterViewInit } from '@angular/core';
import { FocusKeyManager } from '@angular/cdk/a11y';
import { ListItemComponent } from './list-item.component';
@Component({
selector: 'app-list',
template: `
<ul
role="listbox"
(keydown)="onKeydown($event)"
>
<app-list-item
*ngFor="let item of items"
[item]="item"
role="option"
></app-list-item>
</ul>
`
})
export class ListComponent implements AfterViewInit {
@ViewChildren(ListItemComponent) listItems: QueryList<ListItemComponent>;
private keyManager: FocusKeyManager<ListItemComponent>;
items = ['Item 1', 'Item 2', 'Item 3'];
ngAfterViewInit() {
this.keyManager = new FocusKeyManager(this.listItems)
.withWrap()
.withHomeAndEnd();
}
onKeydown(event: KeyboardEvent) {
this.keyManager.onKeydown(event);
}
}
// list-item.component.ts
import { Component, Input, HostBinding } from '@angular/core';
import { FocusableOption } from '@angular/cdk/a11y';
@Component({
selector: 'app-list-item',
template: `<li>{{ item }}</li>`
})
export class ListItemComponent implements FocusableOption {
@Input() item: string;
@HostBinding('attr.tabindex') tabindex = '-1';
focus() {
// Focus logic
}
}
// Live region directive
@Component({
selector: 'app-cart',
template: `
<div>
<h2>Shopping Cart</h2>
<div cdkAriaLive="polite" cdkAriaAtomic="true">
{{ cartMessage }}
</div>
<button (click)="addItem()">Add Item</button>
</div>
`
})
export class CartComponent {
itemCount = 0;
cartMessage = '';
addItem() {
this.itemCount++;
this.cartMessage = `${this.itemCount} items in cart`;
}
}
// Router focus management
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
@Component({
selector: 'app-root',
template: `<router-outlet></router-outlet>`
})
export class AppComponent implements OnInit {
constructor(
private router: Router,
private liveAnnouncer: LiveAnnouncer
) {}
ngOnInit() {
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
// Announce route change
const title = document.title;
this.liveAnnouncer.announce(`Navigated to ${title}`);
// Focus main heading
const heading = document.querySelector('h1') as HTMLElement;
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
});
}
}
Angular A11y Best Practices: Use Angular CDK A11yModule for built-in accessibility. Material
components are pre-tested for accessibility. Use cdk-focus-monitor for keyboard-specific focus indicators. Test
with angular-axe or @axe-core/playwright.
4. Svelte Accessibility Features
| Svelte Feature | Accessibility Benefit | Usage | Compiler Warnings |
|---|---|---|---|
| A11y compiler warnings | Built-in accessibility linting | Automatic during compilation | Missing alt, label, ARIA issues detected |
| bind:this for refs | Direct DOM access for focus management | bind:this={element} |
Element available after mount |
| on: event handlers | onClick handles keyboard automatically | on:click={handler} |
Works for keyboard on buttons/links |
| Reactive statements ($:) | Update ARIA attributes reactively | $: ariaExpanded = isOpen |
Auto-updates when dependencies change |
| Transitions | Respect prefers-reduced-motion | Use @media query or matchMedia | No automatic reduced-motion handling |
| Slots | Flexible accessible component composition | <slot name="label"> |
Better than render props for semantics |
| Actions | Reusable focus trap, click-outside directives | use:focusTrap |
Custom accessibility behaviors |
Example: Svelte accessibility patterns
<!-- Modal.svelte -->
<script>
import { onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
export let isOpen = false;
export let title = '';
let dialogElement;
let previousFocus;
$: if (isOpen && dialogElement) {
previousFocus = document.activeElement;
dialogElement.focus();
} else if (!isOpen && previousFocus) {
previousFocus.focus();
}
function handleKeydown(event) {
if (event.key === 'Escape') {
isOpen = false;
}
}
// Check for reduced motion preference
let prefersReducedMotion = false;
onMount(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotion = query.matches;
});
</script>
{#if isOpen}
<div
bind:this={dialogElement}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
on:keydown={handleKeydown}
transition:fly={{ y: 200, duration: prefersReducedMotion ? 0 : 300 }}
>
<h2 id="dialog-title">{title}</h2>
<slot />
<button on:click={() => isOpen = false}>Close</button>
</div>
{/if}
<!-- Accessible Input Component -->
<script>
export let id;
export let label;
export let value = '';
export let error = '';
export let required = false;
$: hasError = !!error;
$: errorId = `${id}-error`;
</script>
<div>
<label for={id}>{label}</label>
<input
{id}
bind:value
aria-required={required}
aria-invalid={hasError}
aria-describedby={hasError ? errorId : undefined}
/>
{#if hasError}
<span id={errorId} role="alert">
{error}
</span>
{/if}
</div>
<!-- Live region announcer store -->
<script context="module">
import { writable } from 'svelte/store';
export const announcements = writable('');
export function announce(message) {
announcements.set('');
setTimeout(() => {
announcements.set(message);
}, 100);
}
</script>
<!-- Announcer.svelte (add to layout) -->
<script>
import { announcements } from './announcer.js';
</script>
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{$announcements}
</div>
<!-- Focus trap action -->
<script>
// focusTrap.js
export function focusTrap(node) {
const focusableElements = node.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
function handleKeydown(event) {
if (event.key !== 'Tab') return;
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
node.addEventListener('keydown', handleKeydown);
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
}
};
}
</script>
<!-- Usage -->
<div use:focusTrap>
<!-- Focusable content -->
</div>
<!-- SvelteKit route announcements -->
<script>
import { page } from '$app/stores';
import { announce } from './announcer.js';
$: {
// Announce route changes
announce(`Navigated to ${$page.data.title || 'new page'}`);
// Focus main heading
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus();
}
}
</script>
Svelte A11y Advantages: Built-in compiler warnings for accessibility issues (a11y-*). No
runtime overhead for warnings. Actions provide elegant reusable accessibility patterns. Reactive statements
simplify ARIA updates. Use svelte-check and eslint-plugin-svelte for enhanced linting.
5. Web Components and Shadow DOM
| Challenge | Problem | Solution | Browser Support |
|---|---|---|---|
| ARIA across shadow boundary | aria-labelledby/describedby don't work across shadow DOM | Use slots or duplicate IDs in shadow, or use ElementInternals | Fundamental limitation |
| Form association | Custom inputs not recognized by forms | Use ElementInternals API with formAssociated: true | Chrome 77+, Safari 16.4+, Firefox 93+ |
| Focus delegation | Clicking custom element doesn't focus internal input | Use delegatesFocus: true in attachShadow | All modern browsers |
| Slots and semantics | Slotted content loses semantic context | Preserve semantic wrappers; use role attributes | Design consideration |
| CSS inheritance | Focus indicators may not inherit from global styles | Use CSS custom properties or :host-context | :host-context limited support |
| Screen reader testing | Inconsistent behavior across screen readers | Test extensively; prefer semantic HTML over ARIA | Testing requirement |
Example: Accessible web components
// Form-associated custom element
class AccessibleInput extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['aria-label', 'aria-required'];
constructor() {
super();
this._internals = this.attachInternals();
// Create shadow DOM with focus delegation
const shadow = this.attachShadow({
mode: 'open',
delegatesFocus: true
});
shadow.innerHTML = `
<style>
:host {
display: inline-block;
}
input {
padding: 8px;
border: 1px solid var(--input-border, #ccc);
border-radius: 4px;
}
input:focus {
outline: 2px solid var(--focus-color, #0066cc);
outline-offset: 2px;
}
:host([aria-invalid="true"]) input {
border-color: var(--error-color, #d32f2f);
}
</style>
<input type="text" id="input" />
`;
this._input = shadow.querySelector('input');
this._setupEventListeners();
this._syncARIA();
}
_setupEventListeners() {
this._input.addEventListener('input', () => {
this._internals.setFormValue(this._input.value);
this.dispatchEvent(new Event('input', { bubbles: true }));
});
}
_syncARIA() {
// Forward ARIA attributes from host to input
const observer = new MutationObserver(() => {
this._updateInputARIA();
});
observer.observe(this, {
attributes: true,
attributeFilter: ['aria-label', 'aria-required', 'aria-invalid']
});
this._updateInputARIA();
}
_updateInputARIA() {
['aria-label', 'aria-required', 'aria-invalid'].forEach(attr => {
const value = this.getAttribute(attr);
if (value) {
this._input.setAttribute(attr, value);
} else {
this._input.removeAttribute(attr);
}
});
}
// Expose value for forms
get value() {
return this._input.value;
}
set value(val) {
this._input.value = val;
this._internals.setFormValue(val);
}
// Form validation
checkValidity() {
return this._internals.checkValidity();
}
reportValidity() {
return this._internals.reportValidity();
}
}
customElements.define('accessible-input', AccessibleInput);
// Usage
/*
<form>
<label for="email">Email</label>
<accessible-input
name="email"
aria-label="Email address"
aria-required="true"
></accessible-input>
</form>
*/
// Button component with proper semantics
class AccessibleButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({
mode: 'open',
delegatesFocus: true
});
shadow.innerHTML = `
<style>
button {
padding: 12px 24px;
background: var(--button-bg, #0066cc);
color: var(--button-color, white);
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
button:hover {
background: var(--button-hover-bg, #0052a3);
}
button:focus-visible {
outline: 2px solid var(--focus-color, #0066cc);
outline-offset: 2px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<button part="button">
<slot></slot>
</button>
`;
this._button = shadow.querySelector('button');
// Forward click events
this._button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('button-click', {
bubbles: true,
composed: true
}));
});
}
static observedAttributes = ['disabled', 'aria-pressed'];
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'disabled') {
this._button.disabled = newValue !== null;
} else if (name === 'aria-pressed') {
this._button.setAttribute('aria-pressed', newValue);
}
}
}
customElements.define('accessible-button', AccessibleButton);
// Accordion with proper ARIA
class AccessibleAccordion extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isOpen = false;
}
connectedCallback() {
this.render();
this._setupEventListeners();
}
render() {
this.shadowRoot.innerHTML = `
<style>
button {
width: 100%;
padding: 16px;
text-align: left;
background: #f5f5f5;
border: 1px solid #ddd;
cursor: pointer;
}
.content {
padding: 16px;
border: 1px solid #ddd;
border-top: none;
display: none;
}
.content[aria-hidden="false"] {
display: block;
}
</style>
<button
aria-expanded="${this._isOpen}"
aria-controls="content"
>
<slot name="header"></slot>
</button>
<div
id="content"
role="region"
aria-hidden="${!this._isOpen}"
>
<slot name="content"></slot>
</div>
`;
}
_setupEventListeners() {
const button = this.shadowRoot.querySelector('button');
button.addEventListener('click', () => {
this.toggle();
});
}
toggle() {
this._isOpen = !this._isOpen;
this.render();
}
}
customElements.define('accessible-accordion', AccessibleAccordion);
// Usage
/*
<accessible-accordion>
<span slot="header">Click to expand</span>
<div slot="content">Hidden content here</div>
</accessible-accordion>
*/
Web Component A11y Challenges: ARIA references (aria-labelledby) don't cross shadow boundaries
- use slots or ElementInternals. Always set delegatesFocus: true for form controls. Test thoroughly with screen
readers (behavior varies). Use native elements inside shadow DOM when possible.
Framework-Specific Accessibility Quick Reference
- React: Use refs for focus management; create live regions once on mount; eslint-plugin-jsx-a11y for linting; Testing Library for accessible queries
- Vue: Template refs for DOM access; Teleport for modals; watch() for announcements; vue-axe for dev warnings; composables for reusable a11y logic
- Angular: CDK A11yModule (LiveAnnouncer, FocusTrap, FocusMonitor); ListKeyManager for keyboard navigation; Material components pre-tested
- Svelte: Built-in compiler warnings; bind:this for refs; actions for reusable behaviors; reactive statements for ARIA; svelte-check for validation
- Web Components: Use delegatesFocus: true; ElementInternals for forms; forward ARIA from host to shadow; slots for flexible semantics
- Common Patterns: Focus management on route changes; live region announcements; keyboard event handling; form validation feedback
- Testing: Framework-specific test libraries support accessible queries; test with screen readers; validate ARIA implementation
- Libraries: react-aria, @reach/ui (React); vue-announcer (Vue); Angular CDK; focus-trap libraries; polyfills for older browsers
- Best Practices: Start with semantic HTML; enhance with ARIA; test with keyboard only; verify screen reader announcements
- Tools: ESLint plugins, axe-core integrations, framework DevTools, browser accessibility inspectors