Build an Accessible Markdown Editor in React

· 5 min read

I've built about a dozen markdown editors over the years, and honestly, the first few were disasters. Too much state, janky cursor jumps, performance issues with large documents. Here's what actually works in production.

The Core Architecture

The fundamental challenge with markdown editors is managing two parallel representations of the same content: the raw markdown text and the rendered HTML preview. Most developers reach for a split-pane approach, but I've found that creates more problems than it solves—users expect their cursor to stay put, and syncing scroll positions between panes is a nightmare.

Let's start with a minimal implementation that handles the basics right:

1import { useState, useCallback } from 'react';
2import { marked } from 'marked';
3import DOMPurify from 'dompurify';
4
5interface MarkdownEditorProps {
6  initialValue?: string;
7  onChange?: (value: string) => void;
8}
9
10export function MarkdownEditor({ 
11  initialValue = '', 
12  onChange 
13}: MarkdownEditorProps) {
14  const [markdown, setMarkdown] = useState(initialValue);
15  const [mode, setMode] = useState<'edit' | 'preview'>('edit');
16
17  const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
18    const value = e.target.value;
19    setMarkdown(value);
20    onChange?.(value);
21  }, [onChange]);
22
23  const renderPreview = useCallback(() => {
24    const rawHtml = marked(markdown);
25    return DOMPurify.sanitize(rawHtml);
26  }, [markdown]);
27
28  return (
29    <div className="markdown-editor">
30      <div className="toolbar">
31        <button onClick={() => setMode('edit')}>Edit</button>
32        <button onClick={() => setMode('preview')}>Preview</button>
33      </div>
34      
35      {mode === 'edit' ? (
36        <textarea
37          value={markdown}
38          onChange={handleChange}
39          className="editor-textarea"
40          spellCheck="false"
41        />
42      ) : (
43        <div 
44          className="preview-content"
45          dangerouslySetInnerHTML={{ __html: renderPreview() }}
46        />
47      )}
48    </div>
49  );
50}

Notice we're using DOMPurify here—never skip sanitization when rendering user-generated HTML. I've seen XSS vulnerabilities in production markdown editors that skipped this step. The performance hit is negligible compared to the security risk.

Handling Real-Time Preview Without Killing Performance

The tabbed approach above works, but users want to see their formatting as they type. The trick is debouncing the preview render without making it feel laggy. A 150ms debounce hits the sweet spot—fast enough to feel instant, slow enough to avoid unnecessary renders.

1import { useState, useCallback, useMemo } from 'react';
2import { marked } from 'marked';
3import DOMPurify from 'dompurify';
4import { useDebouncedValue } from './hooks/useDebouncedValue';
5
6export function LiveMarkdownEditor() {
7  const [markdown, setMarkdown] = useState('');
8  const debouncedMarkdown = useDebouncedValue(markdown, 150);
9
10  const preview = useMemo(() => {
11    const rawHtml = marked(debouncedMarkdown);
12    return DOMPurify.sanitize(rawHtml);
13  }, [debouncedMarkdown]);
14
15  return (
16    <div className="split-editor">
17      <textarea
18        value={markdown}
19        onChange={(e) => setMarkdown(e.target.value)}
20        className="editor-pane"
21      />
22      <div 
23        className="preview-pane"
24        dangerouslySetInnerHTML={{ __html: preview }}
25      />
26    </div>
27  );
28}

For the debounce hook, here's a simple implementation:

1import { useEffect, useState } from 'react';
2
3export function useDebouncedValue<T>(value: T, delay: number): T {
4  const [debouncedValue, setDebouncedValue] = useState(value);
5
6  useEffect(() => {
7    const handler = setTimeout(() => {
8      setDebouncedValue(value);
9    }, delay);
10
11    return () => clearTimeout(handler);
12  }, [value, delay]);
13
14  return debouncedValue;
15}

Adding Toolbar Actions That Feel Native

Users expect keyboard shortcuts and toolbar buttons for common formatting. The challenge is inserting text at the cursor position without breaking the undo stack. Here's how to do it properly:

1import { useRef, useCallback } from 'react';
2
3interface EditorAction {
4  prefix: string;
5  suffix?: string;
6  placeholder?: string;
7}
8
9export function useEditorActions(
10  value: string,
11  onChange: (value: string) => void
12) {
13  const textareaRef = useRef<HTMLTextAreaElement>(null);
14
15  const insertFormatting = useCallback((action: EditorAction) => {
16    const textarea = textareaRef.current;
17    if (!textarea) return;
18
19    const start = textarea.selectionStart;
20    const end = textarea.selectionEnd;
21    const selectedText = value.substring(start, end);
22    const replacement = selectedText || action.placeholder || '';
23    
24    const newText = 
25      value.substring(0, start) +
26      action.prefix +
27      replacement +
28      (action.suffix || '') +
29      value.substring(end);
30
31    onChange(newText);
32
33    // Restore cursor position after React re-renders
34    requestAnimationFrame(() => {
35      const newCursorPos = start + action.prefix.length + replacement.length;
36      textarea.focus();
37      textarea.setSelectionRange(newCursorPos, newCursorPos);
38    });
39  }, [value, onChange]);
40
41  const makeBold = useCallback(() => {
42    insertFormatting({ prefix: '**', suffix: '**', placeholder: 'bold text' });
43  }, [insertFormatting]);
44
45  const makeItalic = useCallback(() => {
46    insertFormatting({ prefix: '_', suffix: '_', placeholder: 'italic text' });
47  }, [insertFormatting]);
48
49  const makeHeading = useCallback(() => {
50    insertFormatting({ prefix: '## ', placeholder: 'heading' });
51  }, [insertFormatting]);
52
53  return { textareaRef, makeBold, makeItalic, makeHeading };
54}

The requestAnimationFrame trick is crucial here. Without it, the cursor jumps to the end after React's re-render. I learned this the hard way after spending hours debugging cursor behavior.

Accessibility Considerations

Markdown editors have terrible accessibility by default. Screen readers struggle with live regions that update constantly, and keyboard navigation often breaks. Here's what actually helps:

1. Use aria-live="polite" on the preview pane, not "assertive". Assertive interrupts screen readers mid-sentence, which is incredibly annoying for users.

2. Add skip links between the editor and preview. Power users want to jump between them quickly.

3. Provide keyboard shortcuts with visible hints. Don't just document them—show them in tooltips.

1export function AccessibleMarkdownEditor() {
2  const [markdown, setMarkdown] = useState('');
3  const debouncedMarkdown = useDebouncedValue(markdown, 150);
4  const preview = useMemo(() => /* ... */, [debouncedMarkdown]);
5
6  return (
7    <div className="editor-container" role="region" aria-label="Markdown editor">
8      <div className="skip-links">
9        <a href="#editor">Jump to editor</a>
10        <a href="#preview">Jump to preview</a>
11      </div>
12
13      <div className="editor-section">
14        <label htmlFor="markdown-input" className="visually-hidden">
15          Markdown content
16        </label>
17        <textarea
18          id="markdown-input"
19          value={markdown}
20          onChange={(e) => setMarkdown(e.target.value)}
21          aria-describedby="editor-help"
22        />
23        <div id="editor-help" className="sr-only">
24          Type markdown syntax to format your text. 
25          Press Ctrl+B for bold, Ctrl+I for italic.
26        </div>
27      </div>
28
29      <div 
30        id="preview"
31        className="preview-section"
32        role="region"
33        aria-live="polite"
34        aria-label="Preview of formatted content"
35        dangerouslySetInnerHTML={{ __html: preview }}
36      />
37    </div>
38  );
39}

The visually-hidden and sr-only classes hide content visually but keep it available to screen readers. This is standard practice but often forgotten in markdown editors.

Handling Large Documents

Once you hit documents over 10,000 characters, naive implementations start to lag. The marked library is fast, but re-parsing the entire document on every keystroke is wasteful.

I've had good results with a hybrid approach: parse the full document on load and after pauses, but show a "stale" preview during active typing. Users don't notice the 150ms delay, and it keeps the editor responsive even with 50,000+ character documents.

Another option is virtualization for the preview pane. Libraries like react-window work well here, though you'll need to calculate content heights carefully. For most use cases though, the debouncing approach is sufficient.

Syntax Highlighting in the Editor

Users coming from GitHub or VS Code expect syntax highlighting in the textarea itself. This is surprisingly hard to do well. The standard approach is overlaying a syntax-highlighted div behind a transparent textarea, but it's fragile—any CSS or font mismatch breaks the alignment.

I usually recommend CodeMirror or Monaco Editor for this. Yes, they're heavy (Monaco is 3MB+), but they handle all the edge cases. If you need a lightweight solution, react-simple-code-editor with Prism.js works for basic highlighting, but expect to spend time fixing cursor sync issues.

What About Autosave?

Every production markdown editor needs autosave. The pattern I use:

- Debounce saves to 2 seconds of inactivity - Save to localStorage immediately for crash recovery - Persist to backend after the debounce - Show a "Saved" indicator, not a spinner (spinners create anxiety)

Store a timestamp with each save so you can show "Last saved 3 minutes ago". Users find this reassuring.

---

Building a solid markdown editor takes time to get right. Start with the basic edit/preview toggle, add live preview once that works smoothly, then layer in toolbar actions and accessibility. Skip the fancy features until the fundamentals feel good—users will forgive missing features but won't tolerate a janky typing experience.

The code examples here are production-ready starting points, but you'll need to adapt them to your specific use case. Pay special attention to sanitization, accessibility, and performance with large documents. Those are the areas where I've seen the most issues in real-world implementations.

Share this page

Article by Emily Nakamura

Frontend architect and design systems specialist. Advocates for accessible, user-centric React applications. Former lead engineer at a major design tool company.