Skip to main content
Lexical provides built-in accessibility features and best practices to ensure your editor is usable by everyone, including users with disabilities.

Core Accessibility Features

Lexical includes:
  • ARIA Support: Proper ARIA roles and attributes
  • Keyboard Navigation: Full keyboard control
  • Screen Reader Support: Compatible with major screen readers
  • Focus Management: Proper focus handling
  • Semantic HTML: Uses semantic elements when possible

ContentEditable Setup

The ContentEditable component handles basic accessibility:
import { ContentEditable } from '@lexical/react/LexicalContentEditable';

function Editor() {
  return (
    <ContentEditable
      className="editor-input"
      aria-label="Editor content"
      aria-describedby="editor-description"
      aria-placeholder="Enter text..."
      role="textbox"
      spellCheck={true}
    />
  );
}

Required Attributes

  • aria-label: Describes the editor’s purpose
  • role="textbox": Identifies the editor’s role
  • aria-multiline="true": Indicates multi-line input (automatically set)

ARIA Labels

1
Basic Label
2
<ContentEditable
  aria-label="Article editor"
  className="editor-input"
/>
3
Label with Description
4
<div>
  <label id="editor-label" htmlFor="editor">
    Article Content
  </label>
  <p id="editor-description">
    Write your article content here. Use Markdown syntax for formatting.
  </p>
  <ContentEditable
    id="editor"
    aria-labelledby="editor-label"
    aria-describedby="editor-description"
    className="editor-input"
  />
</div>
5
Dynamic State
6
function Editor() {
  const [characterCount, setCharacterCount] = useState(0);
  const maxCharacters = 500;

  return (
    <div>
      <ContentEditable
        aria-label="Comment"
        aria-describedby="char-count"
        aria-invalid={characterCount > maxCharacters}
        className="editor-input"
      />
      <div id="char-count" role="status" aria-live="polite">
        {characterCount} / {maxCharacters} characters
      </div>
    </div>
  );
}

Keyboard Navigation

Lexical supports all standard keyboard shortcuts:

Text Editing

  • Arrow Keys: Navigate through text
  • Home/End: Move to start/end of line
  • Ctrl/Cmd + Home/End: Move to start/end of document
  • Shift + Arrows: Select text
  • Ctrl/Cmd + A: Select all
  • Backspace/Delete: Delete characters
  • Ctrl/Cmd + Backspace/Delete: Delete words

Formatting (Rich Text)

  • Ctrl/Cmd + B: Bold
  • Ctrl/Cmd + I: Italic
  • Ctrl/Cmd + U: Underline
  • Ctrl/Cmd + K: Insert link

History

  • Ctrl/Cmd + Z: Undo
  • Ctrl/Cmd + Shift + Z: Redo (or Ctrl + Y on Windows)

Lists

  • Tab: Indent list item
  • Shift + Tab: Outdent list item
  • Enter: New list item
  • Backspace (at start): Exit list

Custom Keyboard Shortcuts

Add accessible keyboard shortcuts:
import {
  KEY_DOWN_COMMAND,
  COMMAND_PRIORITY_HIGH,
} from 'lexical';

editor.registerCommand(
  KEY_DOWN_COMMAND,
  (event: KeyboardEvent) => {
    const { key, ctrlKey, metaKey, shiftKey } = event;
    const mod = ctrlKey || metaKey;

    // Ctrl/Cmd + Shift + F: Find
    if (mod && shiftKey && key === 'f') {
      event.preventDefault();
      // Open find dialog
      openFindDialog();
      return true;
    }

    return false;
  },
  COMMAND_PRIORITY_HIGH,
);

Screen Reader Support

Live Regions

Use live regions for dynamic content:
function StatusPlugin() {
  const [editor] = useLexicalComposerContext();
  const [status, setStatus] = useState('');

  useEffect(() => {
    return editor.registerUpdateListener(() => {
      // Announce changes to screen readers
      const wordCount = getWordCount(editor);
      setStatus(`Document has ${wordCount} words`);
    });
  }, [editor]);

  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {status}
    </div>
  );
}

Screen Reader Only Content

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

Announce Actions

function InsertImagePlugin() {
  const [editor] = useLexicalComposerContext();
  const [announcement, setAnnouncement] = useState('');

  const insertImage = useCallback(() => {
    editor.update(() => {
      // Insert image
      const imageNode = $createImageNode({ src, alt });
      $insertNodes([imageNode]);
      
      // Announce to screen readers
      setAnnouncement('Image inserted');
    });
  }, [editor]);

  return (
    <>
      <button onClick={insertImage} aria-label="Insert image">
        Insert Image
      </button>
      <div role="status" aria-live="polite" className="sr-only">
        {announcement}
      </div>
    </>
  );
}

Focus Management

Auto Focus

import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';

function Editor() {
  return (
    <LexicalComposer initialConfig={config}>
      <RichTextPlugin ... />
      <AutoFocusPlugin defaultSelection="rootStart" />
    </LexicalComposer>
  );
}

Programmatic Focus

// Focus editor
editor.focus();

// Focus with selection at start
editor.focus(() => {
  const root = $getRoot();
  root.selectStart();
}, { defaultSelection: 'rootStart' });

// Focus with selection at end
editor.focus(() => {
  const root = $getRoot();
  root.selectEnd();
}, { defaultSelection: 'rootEnd' });

Focus Trap

For modal editors:
import FocusTrap from 'focus-trap-react';

function ModalEditor({ onClose }: Props) {
  return (
    <FocusTrap>
      <div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
        <h2 id="dialog-title">Edit Content</h2>
        <LexicalComposer initialConfig={config}>
          <RichTextPlugin ... />
        </LexicalComposer>
        <button onClick={onClose}>Close</button>
      </div>
    </FocusTrap>
  );
}

Toolbar Accessibility

Button Labels

function ToolbarPlugin() {
  const [editor] = useLexicalComposerContext();
  const [isBold, setIsBold] = useState(false);

  return (
    <div role="toolbar" aria-label="Formatting options">
      <button
        onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')}
        aria-label="Bold"
        aria-pressed={isBold}
        title="Bold (Ctrl+B)"
      >
        <strong>B</strong>
      </button>
    </div>
  );
}

Toolbar Navigation

function Toolbar() {
  const toolbarRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const toolbar = toolbarRef.current;
    if (!toolbar) return;

    // Arrow key navigation between buttons
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
        const buttons = Array.from(
          toolbar.querySelectorAll('button:not([disabled])')
        );
        const currentIndex = buttons.indexOf(
          document.activeElement as HTMLButtonElement
        );
        const nextIndex =
          event.key === 'ArrowRight'
            ? (currentIndex + 1) % buttons.length
            : (currentIndex - 1 + buttons.length) % buttons.length;
        (buttons[nextIndex] as HTMLButtonElement).focus();
        event.preventDefault();
      }
    };

    toolbar.addEventListener('keydown', handleKeyDown);
    return () => toolbar.removeEventListener('keydown', handleKeyDown);
  }, []);

  return (
    <div
      ref={toolbarRef}
      role="toolbar"
      aria-label="Text formatting"
      aria-orientation="horizontal"
    >
      {/* buttons */}
    </div>
  );
}

Form Integration

Label Association

function FormField({ label, name }: Props) {
  const id = useId();

  return (
    <div className="form-field">
      <label htmlFor={id}>{label}</label>
      <LexicalComposer initialConfig={config}>
        <PlainTextPlugin
          contentEditable={
            <ContentEditable
              id={id}
              className="form-input"
              aria-label={label}
            />
          }
          placeholder={<div>Enter {label.toLowerCase()}...</div>}
          ErrorBoundary={LexicalErrorBoundary}
        />
      </LexicalComposer>
    </div>
  );
}

Validation Messages

function ValidatedEditor() {
  const [error, setError] = useState<string>('');
  const errorId = useId();

  return (
    <div>
      <ContentEditable
        aria-label="Comment"
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
        className="editor-input"
      />
      {error && (
        <div id={errorId} role="alert" className="error-message">
          {error}
        </div>
      )}
    </div>
  );
}

Color Contrast

Ensure sufficient contrast:
/* Minimum 4.5:1 contrast ratio for normal text */
.editor-input {
  color: #1a1a1a; /* Dark text */
  background: #ffffff; /* Light background */
}

.editor-placeholder {
  color: #6b7280; /* Medium gray - still meets contrast */
}

/* Selection highlighting */
.editor-input::selection {
  background: #3b82f6;
  color: #ffffff;
}

Error Handling

Accessible error messages:
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';

function CustomErrorBoundary({ children }: Props) {
  return (
    <LexicalErrorBoundary
      onError={(error) => {
        console.error(error);
      }}
    >
      {(error, resetError) => (
        <div role="alert" aria-live="assertive">
          <h3>An error occurred</h3>
          <p>{error.message}</p>
          <button onClick={resetError}>Try again</button>
        </div>
      )}
    </LexicalErrorBoundary>
  );
}

Testing Accessibility

Automated Testing

import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';

expect.extend(toHaveNoViolations);

test('editor is accessible', async () => {
  const { container } = render(<Editor />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Manual Testing

  1. Keyboard Only: Navigate without a mouse
  2. Screen Reader: Test with NVDA, JAWS, or VoiceOver
  3. Zoom: Test at 200% zoom level
  4. High Contrast: Test with high contrast mode enabled
  5. Color Blindness: Verify color is not the only indicator

Best Practices

  • Always provide aria-label or aria-labelledby for ContentEditable
  • Use semantic HTML when possible (headings, lists, etc.)
  • Ensure keyboard navigation works for all functionality
  • Test with screen readers regularly during development
  • Provide text alternatives for images and decorative content
  • Use role="status" for non-critical announcements
  • Use role="alert" for important/error messages
  • Maintain focus visibility with clear focus indicators
  • Support high contrast mode and respects user preferences
  • Document keyboard shortcuts in help documentation

WCAG Compliance

Follow WCAG 2.1 Level AA guidelines:
  • Perceivable: Provide text alternatives, sufficient contrast
  • Operable: Keyboard accessible, enough time to read
  • Understandable: Predictable, clear error messages
  • Robust: Compatible with assistive technologies

Resources

See Also

Build docs developers (and LLMs) love