Markdown Editor in React: Preview, Syntax Highlight, Shortcuts
Build a React markdown editor with live preview, syntax highlighting, and keyboard shortcuts. Real code, no fluff — just the patterns that actually work.
Why Most React Markdown Editors Fall Apart at the Seams
Honestly, most markdown editor tutorials give you a textarea and a dangerouslySetInnerHTML div and call it a day. That's not an editor — that's two boxes next to each other.
A real editor needs live preview sync, keyboard shortcuts that don't conflict with the browser, proper syntax highlighting in fenced code blocks, and enough state management to not re-render the entire universe on every keystroke. Those four things together are where people get stuck.
This guide walks through building a proper markdown editor component in React. We're using react-markdown for parsing, rehype-highlight for code block syntax highlighting, and a handful of controlled useRef tricks to wire up shortcuts without fighting the event system. Tailwind v4.0.2 handles layout and theming. No magic — just patterns you'll actually understand.
Setting Up the Core Editor State
The editor lives in a single useState string. That's it. Don't reach for a rich text library unless you genuinely need one — for markdown, raw text is the source of truth and you want to keep it that way.
Split the view into two panels: a textarea on the left, a preview div on the right. A useRef on the textarea gives you cursor position access for shortcut injection later. The key insight is that both panels read from the same state value, so they stay in sync automatically.
Here's the base component shell. Notice we're using a controlled textarea with an onChange handler — never defaultValue — so React owns the value at all times:
import { useState, useRef, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github-dark.css';
type EditorProps = {
initialValue?: string;
onChange?: (value: string) => void;
};
export function MarkdownEditor({ initialValue = '', onChange }: EditorProps) {
const [value, setValue] = useState(initialValue);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
onChange?.(e.target.value);
},
[onChange]
);
return (
<div className="flex h-full gap-4 p-4 bg-neutral-950">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
className="w-1/2 resize-none rounded-lg bg-neutral-900 p-4 font-mono
text-sm text-neutral-100 outline-none focus:ring-2
focus:ring-violet-500 leading-relaxed"
spellCheck={false}
/>
<div className="w-1/2 overflow-y-auto rounded-lg bg-neutral-900 p-4
prose prose-invert prose-sm max-w-none">
<ReactMarkdown rehypePlugins={[rehypeHighlight]}>
{value}
</ReactMarkdown>
</div>
</div>
);
}Keyboard Shortcuts That Don't Break the Browser
Adding Ctrl+B for bold is where most implementations go wrong. They attach a global keydown listener that fires even when the user is typing in an unrelated input somewhere else on the page. Don't do that.
Instead, put the onKeyDown handler directly on the textarea. It only fires when the textarea is focused, which is exactly when you want it. Then use e.preventDefault() only on the shortcuts you're handling — not as a catch-all.
The wrapping logic needs cursor awareness. selectionStart and selectionEnd tell you exactly what the user has highlighted. If nothing is selected, wrap a placeholder word. If something is selected, wrap that selection. Then you set the new value, move the cursor with setSelectionRange, and React re-renders the preview automatically.
const wrapSelection = useCallback(
(before: string, after: string) => {
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = value.slice(start, end) || 'text';
const newValue =
value.slice(0, start) + before + selected + after + value.slice(end);
setValue(newValue);
onChange?.(newValue);
// restore cursor after React re-renders
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(
start + before.length,
start + before.length + selected.length
);
});
},
[value, onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 'b') { e.preventDefault(); wrapSelection('**', '**'); }
if (mod && e.key === 'i') { e.preventDefault(); wrapSelection('*', '*'); }
if (mod && e.key === 'k') { e.preventDefault(); wrapSelection('[', '](url)'); }
// Tab inserts 2 spaces instead of moving focus
if (e.key === 'Tab') {
e.preventDefault();
const el = textareaRef.current!;
const pos = el.selectionStart;
const next = value.slice(0, pos) + ' ' + value.slice(pos);
setValue(next);
requestAnimationFrame(() => el.setSelectionRange(pos + 2, pos + 2));
}
},
[wrapSelection, value]
);Syntax Highlighting Inside Fenced Code Blocks
The rehype-highlight plugin handles syntax highlighting inside fenced code blocks by running highlight.js at parse time. You import a theme CSS file and it applies class-based coloring — no runtime JavaScript needed for the highlight pass itself.
The github-dark.css theme is solid for dark UIs. But you can swap it for any highlight.js theme. If you're building a theme toggle in React, you can swap the import dynamically by adding and removing a <link> tag on the document head, or maintain two stylesheet imports and toggle a class on the root.
One gotcha: rehype-highlight adds hljs and language classes to the <code> element, but your Tailwind prose styles might override the background or padding. Fix that by targeting pre code in your CSS with a higher-specificity rule, or use the prose customization API introduced in Tailwind Typography v0.5.
Debouncing the Preview for Performance
Should you debounce the preview render? Depends on the document length. For small notes, no — real-time feedback is the whole point. For documents above ~5,000 words with heavy code blocks, yes, because rehype-highlight is synchronous and blocks the main thread.
A 150ms debounce is the sweet spot. Below that, users can't perceive the delay. Above 200ms, the preview visibly lags behind typing. Implement it with useRef holding the timeout ID, not useState, so clearing the timer doesn't trigger a re-render. If you want more depth on avoiding unnecessary renders, the React performance guide covers memoization patterns that apply here too.
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [previewValue, setPreviewValue] = useState(initialValue);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const next = e.target.value;
setValue(next);
onChange?.(next);
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
previewTimerRef.current = setTimeout(() => setPreviewValue(next), 150);
},
[onChange]
);
// Use previewValue instead of value in <ReactMarkdown>Toolbar Buttons and Accessible Icon Labels
A toolbar makes the editor usable for people who don't remember keyboard shortcuts. Each button calls the same wrapSelection helper — they're just UI entry points for the same operations. Keep the toolbar thin: bold, italic, code, link, and maybe heading. Everything else adds noise.
Accessibility matters here. A button with only an icon needs an aria-label. Screen readers won't know what a B button does without it. Use title attributes too — they give sighted users hover tooltips without any JavaScript. This is one of those details that takes 30 seconds and makes a real difference.
The toolbar styling can use rgba(255,255,255,0.08) for button backgrounds on dark surfaces — subtle enough to not compete with the content, visible enough to be clickable. Active state on mousedown (not click) feels more responsive because it fires before the textarea loses focus, which means selectionStart is still valid when wrapSelection runs. That's a non-obvious timing dependency worth knowing.
Persisting Editor Content and Handling File Import
Auto-saving to localStorage is a one-liner: localStorage.setItem('md-draft', value) inside the debounced handler. Read it back in the useState initializer with a function form: useState(() => localStorage.getItem('md-draft') ?? ''). The function form prevents reading from storage on every render.
File import is surprisingly simple with the File API. A hidden <input type="file" accept=".md,.txt"> triggered by a button click reads the file as text and calls setValue. No library needed. What about drag-and-drop? The onDrop event on the editor container works the same way — e.dataTransfer.files[0] gives you the file object.
If you're adding form integration with something like React Hook Form, check out the React Hook Form patterns article — it covers the Controller wrapper pattern that makes controlled custom inputs like this editor play nicely with form validation.
Styling the Split-Pane Layout with Tailwind
The two-panel layout is a flex container with gap-4 (16px gap) and h-full so it fills its parent. Each panel is w-1/2. That's fine on desktop. On mobile you probably want a tab-based switcher — editor tab vs preview tab — rather than a squished side-by-side view.
The prose plugin from @tailwindcss/typography handles preview rendering styles. Without it, your parsed HTML is unstyled. prose-invert flips it to dark mode. max-w-none removes the default max-width cap so the preview fills the panel width. These three classes together are doing a lot of work.
Want to push the editor further? The glassmorphism style from what is glassmorphism works nicely for floating toolbar panels — backdrop-blur-md with bg-white/10 and a 1px border at rgba(255,255,255,0.15) gives a frosted glass toolbar that sits over the editor without being heavy. Pair it with the animated backgrounds from particles background React and you've got something that genuinely looks polished.
FAQ
react-markdown is the go-to for React projects because it renders markdown as React elements rather than raw HTML, making it safe without needing to sanitize. marked is faster but outputs an HTML string requiring dangerouslySetInnerHTML. remark is the underlying parser react-markdown uses — you'd touch it directly only if you're building custom AST transformations.
Install rehype-highlight and highlight.js, then pass rehypePlugins={[rehypeHighlight]} to the ReactMarkdown component. Import a highlight.js CSS theme file (e.g., 'highlight.js/styles/github-dark.css') at the top of your module. The plugin runs at parse time and adds class names that the CSS file styles.
Clicking a button moves focus from the textarea to the button, which clears selectionStart and selectionEnd before your onClick handler fires. Fix it by handling the shortcut on mousedown instead of click, and calling e.preventDefault() in the mousedown handler to stop the focus from shifting away from the textarea.
Add an onKeyDown handler on the textarea and call e.preventDefault() when e.key === 'Tab'. Then manually insert two spaces at the cursor position by slicing the value string and calling setValue with the new string. Use requestAnimationFrame to restore the cursor position after React re-renders.
Yes. Wrap it in React Hook Form's Controller component. The render prop gives you onChange and value — pass value as initialValue and call onChange in your editor's onChange callback. If the editor manages internal state, sync it back to the form on every change so validation triggers correctly.
For short documents, no — it's imperceptible. For long documents with many fenced code blocks, rehype-highlight's synchronous processing can cause frame drops. Add a 150ms debounce on the preview state update while keeping the textarea value update immediate. That way typing feels instant and the preview just catches up slightly.