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