Building a Custom Markdown Editor in React

Team 11 min read

#react

#markdown

#editor

#webdev

Building a Custom Markdown Editor in React

A custom Markdown editor is a great way to learn React, handle text selection, and integrate third-party libraries responsibly. In this guide, you’ll build a fully functional editor with:

  • Split-pane editor and live preview
  • Toolbar formatting (bold, italics, headings, lists, code)
  • Keyboard shortcuts
  • Autosave to localStorage
  • Drag-and-drop and paste-to-insert images
  • Export and import Markdown files
  • Secure rendering with sanitization and syntax highlighting

The focus is on readable code and safe defaults you can extend.

Prerequisites

  • Node.js 18+
  • Basic React knowledge
  • A package manager (npm, pnpm, or yarn)

1) Scaffold a React app

You can use your favorite setup. Here we’ll use Vite.

# Vite + React
npm create vite@latest react-markdown-editor -- --template react
cd react-markdown-editor
npm install

Install the Markdown rendering dependencies:

npm i react-markdown remark-gfm rehype-sanitize rehype-highlight highlight.js

2) Project structure

Create a simple structure:

src/
  components/
    Editor.jsx
    Preview.jsx
  hooks/
    useDebouncedValue.js
  App.jsx
  main.jsx
  styles.css
index.html

3) Base wiring

src/main.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './styles.css';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

src/App.jsx

import React, { useEffect, useState } from 'react';
import Editor from './components/Editor.jsx';
import Preview from './components/Preview.jsx';
import useDebouncedValue from './hooks/useDebouncedValue.js';

export default function App() {
  const [text, setText] = useState(`# Hello Markdown

- Type on the left
- See preview on the right
- Try bold, italics, lists, code, and more
`);
  const debounced = useDebouncedValue(text, 300);

  // Load from localStorage on mount
  useEffect(() => {
    const saved = localStorage.getItem('md:doc');
    if (saved != null) setText(saved);
  }, []);

  // Autosave with debounce
  useEffect(() => {
    localStorage.setItem('md:doc', debounced);
  }, [debounced]);

  const handleExport = () => {
    const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'document.md';
    a.click();
    URL.revokeObjectURL(url);
  };

  const handleImport = async (file) => {
    const content = await file.text();
    setText(content);
  };

  return (
    <div className="app">
      <header className="topbar">
        <h1>Markdown Editor</h1>
        <div className="spacer" />
        <label className="import-btn">
          Import
          <input
            type="file"
            accept=".md,.markdown,text/markdown,text/plain"
            onChange={(e) => e.target.files[0] && handleImport(e.target.files[0])}
            hidden
          />
        </label>
        <button onClick={handleExport}>Export</button>
      </header>

      <main className="panes">
        <Editor value={text} onChange={setText} />
        <Preview value={text} />
      </main>
    </div>
  );
}

src/hooks/useDebouncedValue.js

import { useEffect, useState } from 'react';

export default function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);
  }, [value, delay]);
  return debounced;
}

4) The Preview pane

Render Markdown securely with GitHub-flavored Markdown and syntax highlighting.

src/components/Preview.jsx

import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github.css';

export default function Preview({ value }) {
  return (
    <section className="pane preview" aria-label="Preview">
      <div className="pane-title">Preview</div>
      <div className="preview-body">
        <ReactMarkdown
          remarkPlugins={[remarkGfm]}
          rehypePlugins={[rehypeSanitize, rehypeHighlight]}
          linkTarget="_blank"
        >
          {value}
        </ReactMarkdown>
      </div>
    </section>
  );
}

Security note: rehype-sanitize strips dangerous HTML to reduce XSS risk. Avoid enabling raw HTML unless you fully sanitize it yourself.

5) The Editor with toolbar, shortcuts, and media

The Editor manages text selection to apply Markdown formatting. It also handles Tab indent/outdent, and drag-and-drop or paste-to-insert images as data URLs for demo purposes.

src/components/Editor.jsx

import React, { useCallback, useRef } from 'react';

export default function Editor({ value, onChange }) {
  const taRef = useRef(null);

  // Core apply helper
  const apply = useCallback(
    (editFn) => {
      const el = taRef.current;
      if (!el) return;
      const start = el.selectionStart ?? 0;
      const end = el.selectionEnd ?? 0;
      const { text, selectionStart, selectionEnd } = editFn(value, start, end);
      onChange(text);
      requestAnimationFrame(() => {
        el.focus();
        el.selectionStart = selectionStart;
        el.selectionEnd = selectionEnd;
      });
    },
    [value, onChange]
  );

  // Utilities
  const wrapOrInsert = (text, start, end, before, after, placeholder = '') => {
    const selected = text.slice(start, end) || placeholder;
    const next = text.slice(0, start) + before + selected + after + text.slice(end);
    const s = start + before.length;
    const e = s + selected.length;
    return { text: next, selectionStart: s, selectionEnd: e };
  };

  const blockWrap = (text, start, end, fence = '```') => {
    // Ensure fenced blocks with newlines
    const selected = text.slice(start, end) || 'code';
    const before = text.slice(0, start);
    const after = text.slice(end);
    const prefix = selected.startsWith('
') ? '' : '
';
    const suffix = selected.endsWith('
') ? '' : '
';
    const wrapped = `${prefix}${fence}
${selected}${suffix}${fence}
`;
    const next = before + wrapped + after;
    const s = before.length + fence.length + 2; // rough cursor start inside code
    const e = s + selected.length;
    return { text: next, selectionStart: s, selectionEnd: e };
  };

  const toggleLinePrefix = (text, start, end, prefix) => {
    // Apply or remove prefix for each selected line
    const lineStart = text.lastIndexOf('
', start - 1) + 1;
    const lineEnd = end === 0 ? 0 : text.indexOf('
', end);
    const endIndex = lineEnd === -1 ? text.length : lineEnd;

    const block = text.slice(lineStart, endIndex);
    const lines = block.split('
');
    const allHave = lines.every((l) => l.startsWith(prefix));
    const updated = lines
      .map((l) => (allHave ? l.replace(new RegExp('^' + escapeRegExp(prefix)), '') : prefix + l))
      .join('
');

    const delta = updated.length - block.length;
    const next = text.slice(0, lineStart) + updated + text.slice(endIndex);
    const newStart = start + (allHave ? -prefix.length : prefix.length);
    const newEnd = end + delta + (allHave ? -prefix.length : prefix.length);
    return {
      text: next,
      selectionStart: Math.max(lineStart, newStart),
      selectionEnd: Math.max(lineStart, newEnd),
    };
  };

  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

  // Formatting actions
  const bold = () => apply((t, s, e) => wrapOrInsert(t, s, e, '**', '**', 'bold'));
  const italic = () => apply((t, s, e) => wrapOrInsert(t, s, e, '*', '*', 'italics'));
  const codeInline = () => apply((t, s, e) => wrapOrInsert(t, s, e, '`', '`', 'code'));
  const codeBlock = () => apply((t, s, e) => blockWrap(t, s, e));
  const heading = (level = 2) =>
    apply((t, s, e) => toggleLinePrefix(t, s, e, `${'#'.repeat(level)} `));
  const list = () => apply((t, s, e) => toggleLinePrefix(t, s, e, '- '));
  const quote = () => apply((t, s, e) => toggleLinePrefix(t, s, e, '> '));
  const hr = () =>
    apply((t, s) => {
      const before = t.slice(0, s);
      const after = t.slice(s);
      const insert = (before.endsWith('
') ? '' : '
') + '---
';
      const next = before + insert + after;
      const pos = (before + insert).length;
      return { text: next, selectionStart: pos, selectionEnd: pos };
    });
  const link = () =>
    apply((t, s, e) => {
      const selected = t.slice(s, e) || 'link text';
      const insert = `[${selected}](https://example.com)`;
      const next = t.slice(0, s) + insert + t.slice(e);
      // place cursor inside URL
      const urlStart = s + selected.length + 3;
      const urlEnd = urlStart + 'https://example.com'.length;
      return { text: next, selectionStart: urlStart, selectionEnd: urlEnd };
    });

  // Key handling: Tab indent/outdent and common shortcuts
  const onKeyDown = (e) => {
    const el = taRef.current;
    if (!el) return;

    // Shortcuts (Cmd on macOS, Ctrl on others)
    const mod = e.metaKey || e.ctrlKey;

    if (mod && e.key.toLowerCase() === 'b') {
      e.preventDefault();
      bold();
      return;
    }
    if (mod && e.key.toLowerCase() === 'i') {
      e.preventDefault();
      italic();
      return;
    }
    if (mod && e.key.toLowerCase() === 'k') {
      e.preventDefault();
      link();
      return;
    }
    if (mod && e.key === '`') {
      e.preventDefault();
      codeBlock();
      return;
    }

    if (e.key === 'Tab') {
      e.preventDefault();
      const start = el.selectionStart ?? 0;
      const end = el.selectionEnd ?? 0;
      const t = value;

      const lineStart = t.lastIndexOf('
', start - 1) + 1;
      const lineEnd = t.indexOf('
', end);
      const endIndex = lineEnd === -1 ? t.length : lineEnd;
      const block = t.slice(lineStart, endIndex);
      const lines = block.split('
');

      if (e.shiftKey) {
        // Outdent
        const changed = lines.map((l) => (l.startsWith('  ') ? l.slice(2) : l.replace(/^\t/, ''))).join('
');
        const delta = changed.length - block.length;
        const next = t.slice(0, lineStart) + changed + t.slice(endIndex);
        onChange(next);
        requestAnimationFrame(() => {
          el.selectionStart = Math.max(lineStart, start + (start === end ? 0 : delta));
          el.selectionEnd = Math.max(lineStart, end + delta);
        });
      } else {
        // Indent
        const changed = lines.map((l) => '  ' + l).join('
');
        const delta = changed.length - block.length;
        const next = t.slice(0, lineStart) + changed + t.slice(endIndex);
        onChange(next);
        requestAnimationFrame(() => {
          el.selectionStart = start + 2 + (start === end ? 0 : 2);
          el.selectionEnd = end + delta;
        });
      }
    }
  };

  // Drop and paste images as data URLs (demo)
  const insertImage = (dataUrl) => {
    apply((t, s, e) => {
      const md = `![image](${dataUrl})`;
      const next = t.slice(0, s) + md + t.slice(e);
      const pos = s + md.length;
      return { text: next, selectionStart: pos, selectionEnd: pos };
    });
  };

  const handleFiles = (files) => {
    [...files].forEach((file) => {
      if (!file.type.startsWith('image/')) return;
      const reader = new FileReader();
      reader.onload = () => insertImage(reader.result);
      reader.readAsDataURL(file);
    });
  };

  const onDrop = (e) => {
    e.preventDefault();
    if (e.dataTransfer?.files?.length) handleFiles(e.dataTransfer.files);
  };

  const onPaste = (e) => {
    const items = e.clipboardData?.items || [];
    for (const item of items) {
      if (item.kind === 'file') {
        const file = item.getAsFile();
        if (file && file.type.startsWith('image/')) {
          e.preventDefault();
          handleFiles([file]);
        }
      }
    }
  };

  return (
    <section className="pane editor" aria-label="Editor" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
      <div className="pane-title">Editor</div>
      <div className="toolbar">
        <button type="button" onClick={bold} title="Bold (Ctrl/Cmd+B)">B</button>
        <button type="button" onClick={italic} title="Italic (Ctrl/Cmd+I)">I</button>
        <button type="button" onClick={() => heading(1)} title="H1">H1</button>
        <button type="button" onClick={() => heading(2)} title="H2">H2</button>
        <button type="button" onClick={list} title="List">• List</button>
        <button type="button" onClick={quote} title="Quote">“”</button>
        <button type="button" onClick={codeInline} title="Inline code">`code`</button>
        <button type="button" onClick={codeBlock} title="Code block">Code</button>
        <button type="button" onClick={hr} title="Horizontal rule">—</button>
        <button type="button" onClick={link} title="Link (Ctrl/Cmd+K)">Link</button>
      </div>

      <textarea
        ref={taRef}
        className="textarea"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={onKeyDown}
        onPaste={onPaste}
        placeholder="Write Markdown here..."
        spellCheck="true"
        aria-label="Markdown editor"
      />
      <div className="hint">Tip: Drop or paste images to embed them as data URLs.</div>
    </section>
  );
}

6) Styles for a clean split-pane UI

src/styles.css

:root {
  --bg: #0f172a;        /* slate-900 */
  --panel: #0b1220;     /* slightly darker */
  --text: #e2e8f0;      /* slate-200 */
  --muted: #94a3b8;     /* slate-400 */
  --accent: #38bdf8;    /* sky-400 */
  --border: #1f2937;    /* gray-800 */
}

* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
  margin: 0;
  background: linear-gradient(180deg, var(--bg), #030617 65%);
  color: var(--text);
  font: 14px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}

.app { height: 100%; display: flex; flex-direction: column; }

.topbar {
  display: flex; align-items: center; gap: 8px;
  padding: 10px 16px; border-bottom: 1px solid var(--border);
  background: rgba(8, 15, 30, 0.8); backdrop-filter: blur(10px);
  position: sticky; top: 0; z-index: 10;
}
.topbar h1 { font-size: 14px; font-weight: 600; margin: 0; color: var(--muted); }
.spacer { flex: 1; }
.topbar button, .import-btn {
  background: #0ea5e9; color: white; border: 0; padding: 8px 10px;
  border-radius: 6px; cursor: pointer; font-weight: 600;
}
.import-btn { display: inline-flex; align-items: center; gap: 6px; }

.panes {
  flex: 1; display: grid;
  grid-template-columns: 1fr 1fr;
  min-height: 0;
}

.pane {
  display: flex; flex-direction: column;
  border-right: 1px solid var(--border);
  min-height: 0; /* allow children to overflow auto */
}
.pane:last-child { border-right: 0; }
.pane-title {
  padding: 10px 12px; border-bottom: 1px solid var(--border);
  color: var(--muted); font-weight: 600; background: rgba(10, 14, 25, 0.55);
  position: sticky; top: 48px; /* under topbar */
  z-index: 5;
}

.editor { background: linear-gradient(180deg, #060a14, #070c18); }
.preview { background: linear-gradient(180deg, #0a1120, #0b1220); }

.toolbar {
  display: flex; gap: 6px; flex-wrap: wrap; padding: 8px 12px; border-bottom: 1px solid var(--border);
}
.toolbar button {
  background: #10192b; color: var(--text); border: 1px solid var(--border);
  padding: 6px 8px; border-radius: 6px; cursor: pointer;
}
.toolbar button:hover { border-color: var(--accent); color: white; }

.textarea {
  width: 100%; flex: 1; background: transparent; color: var(--text);
  border: 0; outline: none; resize: none; padding: 12px; line-height: 1.5;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
  caret-color: var(--accent);
}
.hint {
  padding: 6px 12px; color: var(--muted); border-top: 1px dashed var(--border);
}

.preview-body {
  padding: 12px; overflow: auto; height: 100%;
}
.preview-body h1, .preview-body h2, .preview-body h3 { margin-top: 1.5em; }
.preview-body pre { background: #0c1220; padding: 12px; border-radius: 8px; overflow: auto; }
.preview-body code { background: #0c1220; padding: 2px 4px; border-radius: 4px; }
.preview-body a { color: #7dd3fc; }

7) Try it

npm run dev

Open the app and try:

  • Typing Markdown in the left pane
  • Toolbar buttons for bold, italics, headings, lists, quotes, code, rule, link
  • Keyboard: Ctrl/Cmd+B, Ctrl/Cmd+I, Ctrl/Cmd+K, Ctrl/Cmd+` for code block
  • Tab/Shift+Tab to indent or outdent lines
  • Paste or drag an image to embed as a data URL
  • Export and import .md files
  • Refresh to see autosave working

8) Security and robustness

  • Sanitization: Preview uses rehype-sanitize to remove risky HTML.
  • Images: This demo inserts images as data URLs. For production, upload to object storage or your backend and insert permanent URLs. Validate file types and sizes.
  • Large documents: For very large files, consider virtualization in the preview or splitting the document.
  • Accessibility: The editor and buttons include ARIA labels and titles. Expand with focus outlines and descriptive text for all actions.

9) Extensions to consider

  • Resizable panes
  • Custom themes and a theme switcher
  • Slash commands for quick insertions
  • Collaborative editing with CRDTs or OT
  • File system sync via the File System Access API
  • Export to PDF via server-side rendering

Final thoughts

You now have a fast, secure, extensible Markdown editor built with React. The structure keeps concerns separate: editing and selection logic in the Editor, safe rendering in the Preview, and app-level features like autosave and file import/export in App. From here, you can tailor the UI, add plugins, and integrate real media hosting to fit your workflow.