Loading
Build a split-pane markdown editor with real-time preview, syntax highlighting, formatting toolbar, keyboard shortcuts, and file export.
Markdown editors are everywhere — GitHub, Notion, Obsidian, and countless developer tools use them. Building one teaches you about text manipulation, real-time rendering, keyboard event handling, and the split-pane layout pattern that is standard in developer tooling.
In this tutorial, you will build a live markdown editor in React with TypeScript. The editor features a split-pane layout with the editor on the left and a live preview on the right, a formatting toolbar for common operations, keyboard shortcuts for power users, syntax highlighting in code blocks, and the ability to export your document as HTML or raw markdown.
You will use a minimal set of dependencies: React for the UI, a markdown parser to convert text to HTML, and a syntax highlighter for code blocks. The result is a focused, fast editor that you can extend in any direction.
Create a new React project and install the markdown processing libraries.
marked converts markdown to HTML. highlight.js provides syntax highlighting for code blocks. dompurify sanitizes the generated HTML to prevent XSS attacks — this is critical because you are rendering user-provided content as HTML.
Create a utility that converts markdown text to safe, highlighted HTML.
The custom renderer intercepts code blocks and runs them through highlight.js before they reach the DOM. DOMPurify strips any dangerous HTML while preserving the highlighting classes.
Build the core layout component with a draggable divider between editor and preview.
The divider is draggable between 20% and 80% of the container width. Using useRef for the dragging state avoids re-renders on every mouse move — only splitPosition triggers a re-render, and that only changes when the user is actively dragging.
Create the text editor with line numbers and auto-indentation.
Tab key inserts two spaces instead of moving focus — this is expected behavior in a code editor. The word count updates on every keystroke since it is cheap to compute from the existing value string.
Create the panel that renders the parsed markdown in real-time.
useMemo ensures the markdown is only re-parsed when the source text actually changes. dangerouslySetInnerHTML is safe here because we pass the HTML through DOMPurify first.
Create a toolbar that inserts markdown formatting at the cursor position.
Add keyboard shortcuts that mirror the toolbar actions.
This hook listens for Ctrl/Cmd key combinations and maps them to callback functions. Using e.metaKey alongside e.ctrlKey ensures shortcuts work on both macOS (Cmd) and Windows/Linux (Ctrl).
Let users download their document as markdown or HTML.
Auto-save the document so users do not lose work when they close the tab.
Debouncing the save with a 1-second delay prevents excessive writes to localStorage on every keystroke while still saving frequently enough that no meaningful work is lost.
Wire all the components together into the main page.
Run npm run dev and open http://localhost:3000. You have a fully functional markdown editor with live preview, formatting tools, keyboard shortcuts, auto-save, and export. Type in the left pane and watch the right pane update instantly.
To extend this further, add a file browser sidebar for managing multiple documents, implement collaborative editing with WebSockets, add image upload with drag-and-drop, or integrate a WYSIWYG toggle that lets users switch between raw markdown and rich text editing.
npx create-next-app@latest markdown-editor --typescript --app --tailwind
cd markdown-editor
npm install marked highlight.js dompurify
npm install -D @types/dompurify// src/lib/markdown.ts
import { marked } from "marked";
import hljs from "highlight.js";
import DOMPurify from "dompurify";
marked.setOptions({
gfm: true,
breaks: true,
});
const renderer = new marked.Renderer();
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
};
marked.use({ renderer });
export function parseMarkdown(source: string): string {
const rawHtml = marked.parse(source) as string;
return DOMPurify.sanitize(rawHtml, {
ADD_TAGS: ["pre", "code"],
ADD_ATTR: ["class"],
});
}"use client";
// src/components/SplitPane.tsx
import { useState, useCallback, useRef } from "react";
interface SplitPaneProps {
left: React.ReactNode;
right: React.ReactNode;
}
export function SplitPane({ left, right }: SplitPaneProps) {
const [splitPosition, setSplitPosition] = useState(50);
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const handleMouseDown = useCallback(() => {
isDragging.current = true;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const position = ((e.clientX - rect.left) / rect.width) * 100;
setSplitPosition(Math.min(80, Math.max(20, position)));
}, []);
const handleMouseUp = useCallback(() => {
isDragging.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}, []);
return (
<div
ref={containerRef}
className="flex h-screen"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div style={{ width: `${splitPosition}%` }} className="overflow-hidden">
{left}
</div>
<div
className="w-1 cursor-col-resize bg-gray-300 hover:bg-blue-500 transition-colors"
onMouseDown={handleMouseDown}
/>
<div style={{ width: `${100 - splitPosition}%` }} className="overflow-hidden">
{right}
</div>
</div>
);
}"use client";
// src/components/Editor.tsx
import { useCallback, useRef } from "react";
interface EditorProps {
value: string;
onChange: (value: string) => void;
}
export function Editor({ value, onChange }: EditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const textarea = textareaRef.current;
if (!textarea) return;
if (e.key === "Tab") {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newValue =
value.substring(0, start) + " " + value.substring(end);
onChange(newValue);
requestAnimationFrame(() => {
textarea.selectionStart = start + 2;
textarea.selectionEnd = start + 2;
});
}
},
[value, onChange]
);
return (
<div className="flex h-full flex-col">
<div className="flex items-center border-b bg-gray-50 px-4 py-2">
<span className="text-sm font-medium text-gray-600">Markdown</span>
<span className="ml-auto text-xs text-gray-400">
{value.split(/\s+/).filter(Boolean).length} words
</span>
</div>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 resize-none bg-white p-4 font-mono text-sm leading-relaxed outline-none"
spellCheck={false}
placeholder="Start writing markdown..."
/>
</div>
);
}"use client";
// src/components/Preview.tsx
import { useMemo } from "react";
import { parseMarkdown } from "@/lib/markdown";
interface PreviewProps {
source: string;
}
export function Preview({ source }: PreviewProps) {
const html = useMemo(() => parseMarkdown(source), [source]);
return (
<div className="flex h-full flex-col">
<div className="flex items-center border-b bg-gray-50 px-4 py-2">
<span className="text-sm font-medium text-gray-600">Preview</span>
</div>
<div
className="prose prose-sm max-w-none flex-1 overflow-y-auto p-4"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}"use client";
// src/components/Toolbar.tsx
interface ToolbarAction {
label: string;
icon: string;
prefix: string;
suffix: string;
shortcut: string;
}
const ACTIONS: ToolbarAction[] = [
{ label: "Bold", icon: "B", prefix: "**", suffix: "**", shortcut: "Ctrl+B" },
{ label: "Italic", icon: "I", prefix: "_", suffix: "_", shortcut: "Ctrl+I" },
{ label: "Code", icon: "<>", prefix: "`", suffix: "`", shortcut: "Ctrl+E" },
{ label: "Link", icon: "🔗", prefix: "[", suffix: "](url)", shortcut: "Ctrl+K" },
{ label: "Heading", icon: "H", prefix: "## ", suffix: "", shortcut: "Ctrl+H" },
{ label: "List", icon: "•", prefix: "- ", suffix: "", shortcut: "Ctrl+L" },
{ label: "Quote", icon: ">", prefix: "> ", suffix: "", shortcut: "Ctrl+Q" },
];
interface ToolbarProps {
onAction: (prefix: string, suffix: string) => void;
}
export function Toolbar({ onAction }: ToolbarProps) {
return (
<div className="flex items-center gap-1 border-b bg-white px-2 py-1">
{ACTIONS.map((action) => (
<button
key={action.label}
onClick={() => onAction(action.prefix, action.suffix)}
title={`${action.label} (${action.shortcut})`}
className="rounded px-2 py-1 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
{action.icon}
</button>
))}
</div>
);
}"use client";
// src/hooks/useKeyboardShortcuts.ts
import { useEffect, useCallback } from "react";
interface ShortcutMap {
[key: string]: () => void;
}
export function useKeyboardShortcuts(shortcuts: ShortcutMap): void {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const modifier = e.ctrlKey || e.metaKey;
if (!modifier) return;
const key = `Ctrl+${e.key.toUpperCase()}`;
if (shortcuts[key]) {
e.preventDefault();
shortcuts[key]();
}
},
[shortcuts]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
}// src/lib/export.ts
import { parseMarkdown } from "./markdown";
export function exportAsMarkdown(content: string, filename = "document.md"): void {
downloadFile(content, filename, "text/markdown");
}
export function exportAsHtml(content: string, filename = "document.html"): void {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 6px; overflow-x: auto; }
code { font-family: monospace; }
blockquote { border-left: 3px solid #ddd; margin-left: 0; padding-left: 1rem; color: #666; }
</style>
</head>
<body>${parseMarkdown(content)}</body>
</html>`;
downloadFile(html, filename, "text/html");
}
function downloadFile(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}"use client";
// src/hooks/useAutoSave.ts
import { useEffect, useRef } from "react";
const STORAGE_KEY = "markdown-editor-content";
const SAVE_DELAY_MS = 1000;
export function useAutoSave(content: string): void {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, content);
} catch (error) {
console.error("Failed to save to localStorage:", error);
}
}, SAVE_DELAY_MS);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [content]);
}
export function loadSavedContent(): string {
if (typeof window === "undefined") return "";
try {
return localStorage.getItem(STORAGE_KEY) ?? "";
} catch {
return "";
}
}"use client";
// src/app/page.tsx
import { useState, useCallback, useRef } from "react";
import { SplitPane } from "@/components/SplitPane";
import { Editor } from "@/components/Editor";
import { Preview } from "@/components/Preview";
import { Toolbar } from "@/components/Toolbar";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { useAutoSave, loadSavedContent } from "@/hooks/useAutoSave";
import { exportAsMarkdown, exportAsHtml } from "@/lib/export";
const DEFAULT_CONTENT = `# Hello World
Welcome to the **Markdown Editor**.
## Features
- Live preview
- Syntax highlighting
- Keyboard shortcuts
- Export to HTML
\`\`\`javascript
function greet(name) {
return \`Hello, \${name}!\`;
}
\`\`\`
> Start writing to see the preview update in real time.
`;
export default function Home() {
const [content, setContent] = useState(() => loadSavedContent() || DEFAULT_CONTENT);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useAutoSave(content);
const insertFormatting = useCallback(
(prefix: string, suffix: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = content.substring(start, end);
const replacement = `${prefix}${selected || "text"}${suffix}`;
const newContent = content.substring(0, start) + replacement + content.substring(end);
setContent(newContent);
},
[content]
);
useKeyboardShortcuts({
"Ctrl+B": () => insertFormatting("**", "**"),
"Ctrl+I": () => insertFormatting("_", "_"),
"Ctrl+E": () => insertFormatting("`", "`"),
"Ctrl+S": () => exportAsMarkdown(content),
});
return (
<div className="flex h-screen flex-col">
<Toolbar onAction={insertFormatting} />
<div className="flex items-center gap-2 border-b px-4 py-1">
<button
onClick={() => exportAsMarkdown(content)}
className="text-xs text-blue-600 hover:underline"
>
Export .md
</button>
<button
onClick={() => exportAsHtml(content)}
className="text-xs text-blue-600 hover:underline"
>
Export .html
</button>
</div>
<SplitPane
left={<Editor value={content} onChange={setContent} />}
right={<Preview source={content} />}
/>
</div>
);
}