Accessibility

Accessibility considerations and best practices for contenteditable elements, including screen reader support, ARIA attributes, and keyboard navigation.

Overview

Making contenteditable accessible is challenging because screen readers may not properly announce changes, ARIA attributes may not be respected, and keyboard navigation can be inconsistent. This guide covers accessibility issues and solutions.

⚠️ Common Accessibility Issues

contenteditable has accessibility challenges:

  • Screen readers don't announce content changes automatically
  • ARIA attributes may not be properly announced (especially in Safari)
  • Keyboard navigation can be inconsistent
  • Focus management is complex
  • Formatting changes aren't announced

Screen Reader Support

⚠️ Changes Not Announced

Problem: When content changes in a contenteditable region (text is typed, deleted, or formatted), screen readers do not announce these changes to users. This makes it difficult for users relying on assistive technologies to understand what is happening in the editor.

// ❌ BAD: No announcements for content changes
<div contenteditable>
  <!-- Changes not announced to screen readers -->
</div>

// ✅ GOOD: Use aria-live regions for announcements
<div contenteditable
     role="textbox"
     aria-label="Rich text editor"
     aria-live="polite"
     aria-atomic="false">
  <!-- Content -->
</div>

// Or use a separate live region
<div id="announcements" 
     aria-live="polite" 
     aria-atomic="false"
     class="sr-only">
</div>

function announceChange(message) {
  const announcements = document.getElementById('announcements');
  announcements.textContent = message;
  // Clear after announcement
  setTimeout(() => {
    announcements.textContent = '';
  }, 1000);
}

Screen Reader Announcement Strategies

  • aria-live regions: Use aria-live="polite" or aria-live="assertive" for important changes
  • Separate live region: Use a dedicated element for announcements to avoid interrupting reading
  • Announce formatting: Announce when formatting is applied or removed (e.g., "Bold applied")
  • Announce selection: Announce selection changes (e.g., "2 words selected")
  • Debounce announcements: Don't announce every keystroke, batch announcements
// Example: Announce formatting changes
element.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'formatBold') {
    e.preventDefault();
    applyBold();
    announceChange('Bold formatting applied');
  } else if (e.inputType === 'formatRemove') {
    e.preventDefault();
    removeFormatting();
    announceChange('Formatting removed');
  }
});

// Example: Announce selection changes
document.addEventListener('selectionchange', () => {
  const selection = window.getSelection();
  if (selection.rangeCount > 0) {
    const range = selection.getRangeAt(0);
    if (!range.collapsed) {
      const text = range.toString();
      const wordCount = text.split(/\s+/).filter(w => w.length > 0).length;
      announceChange(`${wordCount} word${wordCount !== 1 ? 's' : ''} selected`);
    }
  }
});

ARIA Attributes

⚠️ ARIA Attributes Not Announced

Problem: When ARIA attributes (like role, aria-label, aria-describedby) are applied to contenteditable regions, screen readers may not properly announce them, especially in Safari. The accessibility information is lost.

// ❌ BAD: ARIA attributes may not be announced in Safari
<div contenteditable role="textbox" aria-label="Editor">
  <!-- ARIA info may be lost -->
</div>

// ✅ GOOD: Use multiple ARIA attributes and test across screen readers
<div contenteditable
     role="textbox"
     aria-label="Rich text editor"
     aria-describedby="editor-help"
     aria-multiline="true"
     aria-haspopup="false"
     tabindex="0">
  <!-- Content -->
</div>
<div id="editor-help" class="sr-only">
  Use keyboard shortcuts to format text. Press Ctrl+B for bold, Ctrl+I for italic.
</div>

Recommended ARIA Attributes

  • role="textbox": Indicates the element is a text input
  • aria-label or aria-labelledby: Provides accessible name
  • aria-describedby: Links to help text or instructions
  • aria-multiline="true": Indicates multi-line text input
  • aria-live: Announces dynamic content changes
  • aria-atomic: Controls whether entire region or only changes are announced
  • aria-invalid: Indicates validation errors
// Complete ARIA setup example
<div contenteditable
     role="textbox"
     aria-label="Document editor"
     aria-describedby="editor-instructions editor-status"
     aria-multiline="true"
     aria-live="polite"
     aria-atomic="false"
     aria-invalid="false"
     tabindex="0">
  <!-- Editor content -->
</div>

<div id="editor-instructions" class="sr-only">
  Rich text editor. Use keyboard shortcuts to format text.
</div>

<div id="editor-status" 
     aria-live="polite" 
     aria-atomic="false"
     class="sr-only">
  <!-- Status announcements -->
</div>

Keyboard Navigation

⚠️ tabindex Issues

Problem: When multiple contenteditable regions have tabindex attributes, the tab order may not follow the tabindex values correctly in some browsers. The focus order may be inconsistent or incorrect.

// ❌ BAD: tabindex may not work correctly
<div contenteditable tabindex="3">Third</div>
<div contenteditable tabindex="1">First</div>
<div contenteditable tabindex="2">Second</div>
// Focus order may be incorrect!

// ✅ GOOD: Use sequential tabindex or manage focus programmatically
<div contenteditable tabindex="0">First</div>
<div contenteditable tabindex="0">Second</div>
<div contenteditable tabindex="0">Third</div>
// Natural DOM order

// Or manage focus programmatically
const editors = document.querySelectorAll('[contenteditable]');
editors.forEach((editor, index) => {
  editor.addEventListener('keydown', (e) => {
    if (e.key === 'Tab' && !e.shiftKey) {
      e.preventDefault();
      const next = editors[index + 1];
      if (next) next.focus();
    } else if (e.key === 'Tab' && e.shiftKey) {
      e.preventDefault();
      const prev = editors[index - 1];
      if (prev) prev.focus();
    }
  });
});

Keyboard Navigation Best Practices

  • Use tabindex="0": For natural tab order, use tabindex="0" instead of custom values
  • Manage Focus: Programmatically manage focus for custom keyboard navigation
  • Escape Key: Provide a way to exit the editor (e.g., blur on Escape)
  • Arrow Keys: Ensure arrow keys work correctly for navigation within content
  • Home/End: Test that Home and End keys work as expected

Focus Management

⚠️ autofocus Doesn't Work

Problem: The autofocus attribute, which automatically focuses form inputs on page load, does not work on contenteditable elements. There is no built-in way to automatically focus a contenteditable region when a page loads.

// ❌ BAD: autofocus doesn't work on contenteditable
<div contenteditable autofocus>
  <!-- Won't receive focus automatically -->
</div>

// ✅ GOOD: Use JavaScript to focus
<div contenteditable id="editor">
  <!-- Content -->
</div>

<script>
  // Focus on page load
  window.addEventListener('load', () => {
    const editor = document.getElementById('editor');
    editor.focus();
  });
  
  // Or use requestAnimationFrame for better timing
  requestAnimationFrame(() => {
    editor.focus();
  });
</script>

Focus Management Best Practices

  • Programmatic Focus: Use element.focus() instead of relying on autofocus
  • Focus Indicators: Ensure focus is visible with clear focus styles
  • Focus Trapping: Consider focus trapping for modal editors
  • Focus Restoration: Restore focus after programmatic DOM changes
  • Skip Links: Provide skip links to jump to editor

Live Announcements

Implementing Live Announcements

Use aria-live regions to announce changes:

// Create a dedicated live region
<div id="live-region" 
     aria-live="polite" 
     aria-atomic="false"
     class="sr-only">
</div>

// Announce changes
function announce(message, priority = 'polite') {
  const liveRegion = document.getElementById('live-region');
  liveRegion.setAttribute('aria-live', priority);
  liveRegion.textContent = message;
  
  // Clear after announcement
  setTimeout(() => {
    liveRegion.textContent = '';
  }, 1000);
}

// Announce formatting
element.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'formatBold') {
    announce('Bold formatting applied');
  } else if (e.inputType === 'formatItalic') {
    announce('Italic formatting applied');
  }
});

// Announce selection
document.addEventListener('selectionchange', debounce(() => {
  const selection = window.getSelection();
  if (selection.rangeCount > 0) {
    const range = selection.getRangeAt(0);
    if (!range.collapsed) {
      const text = range.toString();
      const words = text.split(/\s+/).filter(w => w.length > 0);
      announce(`${words.length} word${words.length !== 1 ? 's' : ''} selected`);
    }
  }
}, 300));

Screen Reader Only CSS

Use CSS to hide live regions visually but keep them accessible to screen readers:

.sr-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;
}

Platform-Specific Issues

Browser-Specific Issues

⚠️ Safari: ARIA Attributes Not Announced

Safari: ARIA attributes on contenteditable elements may not be properly announced by screen readers (VoiceOver). Use live regions as a workaround.

⚠️ Chrome/Edge: Better ARIA Support

Chrome/Edge: Generally better ARIA support, but still use live regions for dynamic content changes.

⚠️ Firefox: Keyboard Navigation

Firefox: Keyboard navigation may behave differently. Test thoroughly with NVDA.

Screen Reader-Specific Issues

⚠️ VoiceOver: Limited Announcements

VoiceOver (macOS/iOS): May not announce content changes automatically. Use aria-live regions for all important changes.

⚠️ NVDA: Better Support

NVDA (Windows): Generally better support, but still test with live regions.

⚠️ JAWS: Complex Content

JAWS: May struggle with complex nested content. Keep structure simple when possible.

Related resources