Build a Markdown Editor with Live Preview (Step by Step)
Build a browser-based Markdown editor with a live preview pane, syntax-highlighted code blocks, and file save/export — using vanilla HTML, CSS, and JavaScript with the marked library.
What You’ll Build
You’ll build a split-pane Markdown editor: type Markdown on the left, see the rendered HTML on the right — instantly. It includes syntax highlighting for code blocks, a toolbar for quick formatting, and buttons to save as .md, export as .html, or clear the document. At DodaTech, this same pattern is used in DodaZIP’s release note editor and in the documentation preview tool.
Why Build a Markdown Editor?
Markdown is everywhere — README files, documentation, forum posts, note-taking apps. Building your own editor teaches you DOM manipulation, event handling, third-party library integration, file I/O via the browser’s File API, and the concept of “live preview” that’s central to modern development tools. Plus, you end up with a tool you can actually use daily.
Prerequisites
- Basic HTML, CSS, and JavaScript
- Familiarity with Markdown syntax
- No backend needed — everything runs in the browser
Step 1: Setup
mkdir md-editor
cd md-editorCreate a single HTML file that contains everything:
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Editor</title>
<!-- marked: Markdown → HTML conversion -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- highlight.js: syntax highlighting for code blocks -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github.css">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/highlight.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; height: 100vh; display: flex; flex-direction: column; }
/* Toolbar */
.toolbar {
display: flex; gap: 4px; padding: 8px 12px;
background: #f8f9fa; border-bottom: 1px solid #dee2e6;
align-items: center; flex-wrap: wrap;
}
.toolbar button {
padding: 6px 12px; border: 1px solid #dee2e6; border-radius: 4px;
background: white; cursor: pointer; font-size: 13px;
transition: background 0.15s;
}
.toolbar button:hover { background: #e9ecef; }
.toolbar .divider { width: 1px; height: 24px; background: #dee2e6; margin: 0 4px; }
.toolbar .spacer { flex: 1; }
.toolbar .filename { font-size: 13px; color: #666; }
.toolbar .word-count { font-size: 12px; color: #999; }
/* Main editor area */
.editor-container {
display: flex; flex: 1; overflow: hidden;
}
.pane {
flex: 1; display: flex; flex-direction: column; overflow: hidden;
}
.pane-header {
padding: 6px 12px; font-size: 12px; font-weight: 600;
color: #666; background: #f1f3f5; border-bottom: 1px solid #dee2e6;
text-transform: uppercase; letter-spacing: 0.5px;
}
.pane:first-child { border-right: 1px solid #dee2e6; }
/* Editor textarea */
#editor {
flex: 1; padding: 16px; border: none; outline: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 14px; line-height: 1.7; resize: none;
tab-size: 4;
}
/* Preview pane */
#preview {
flex: 1; padding: 16px; overflow-y: auto;
font-size: 15px; line-height: 1.7;
}
/* Preview styles */
#preview h1 { font-size: 2em; margin: 0.5em 0 0.3em; border-bottom: 2px solid #eee; padding-bottom: 0.2em; }
#preview h2 { font-size: 1.6em; margin: 0.5em 0 0.3em; border-bottom: 1px solid #eee; padding-bottom: 0.15em; }
#preview h3 { font-size: 1.3em; margin: 0.4em 0 0.2em; }
#preview p { margin: 0.5em 0; }
#preview ul, #preview ol { margin: 0.5em 0; padding-left: 2em; }
#preview li { margin: 0.25em 0; }
#preview blockquote {
margin: 0.5em 0; padding: 8px 16px; border-left: 4px solid #007bff;
background: #f8f9fa; color: #555;
}
#preview code {
background: #f1f3f5; padding: 2px 6px; border-radius: 3px;
font-family: monospace; font-size: 0.9em;
}
#preview pre {
margin: 0.5em 0; border-radius: 6px; overflow-x: auto;
}
#preview pre code {
background: none; padding: 0; font-size: 0.85em;
}
#preview table { border-collapse: collapse; margin: 0.5em 0; width: 100%; }
#preview th, #preview td { border: 1px solid #dee2e6; padding: 8px 12px; text-align: left; }
#preview th { background: #f8f9fa; font-weight: 600; }
#preview img { max-width: 100%; border-radius: 4px; }
#preview a { color: #007bff; }
#preview hr { margin: 1em 0; border: none; border-top: 1px solid #dee2e6; }
/* Scrollbar styling */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #aaa; }
</style>
</head>
<body>Step 2: The Toolbar
<body>
<div class="toolbar">
<button onclick="insertFormat('**','**')" title="Bold">B</button>
<button onclick="insertFormat('*','*')" title="Italic"><em>I</em></button>
<button onclick="insertFormat('`','`')" title="Code"></></button>
<button onclick="insertFormat('[','](url)')" title="Link">🔗</button>
<button onclick="insertFormat('')" title="Image">🖼</button>
<div class="divider"></div>
<button onclick="insertLine('# ')">H1</button>
<button onclick="insertLine('## ')">H2</button>
<button onclick="insertLine('### ')">H3</button>
<div class="divider"></div>
<button onclick="insertLine('- ')">• List</button>
<button onclick="insertLine('1. ')">1. List</button>
<button onclick="insertLine('> ')">Quote</button>
<button onclick="insertLine('```\n\n```')">Code Block</button>
<div class="divider"></div>
<button onclick="insertLine('---')">HR</button>
<div class="spacer"></div>
<span class="filename" id="filename">untitled.md</span>
<span class="word-count" id="wordCount">0 words</span>
<div class="divider"></div>
<button onclick="saveMarkdown()">💾 Save .md</button>
<button onclick="exportHtml()">📄 Export HTML</button>
<button onclick="clearEditor()">🗑 Clear</button>
</div>
<div class="editor-container">
<div class="pane">
<div class="pane-header">Editor</div>
<textarea id="editor" placeholder="Type Markdown here...
# Heading 1
## Heading 2
**Bold** and *italic* text
- List item 1
- List item 2
```python
print('Hello, World!')
```"># Welcome to Markdown Editor
## Features
- **Live preview** as you type
- **Syntax highlighting** for code blocks
- **Toolbar** for quick formatting
- **Export** as HTML or Markdown
## Code Example
```python
def greet(name):
return f"Hello, {name}!"
print(greet("Markdown"))Table
| Feature | Status |
|---|---|
| Live Preview | ✅ |
| Syntax Highlighting | ✅ |
| File Export | ✅ |
Preview | |
## Step 3: The JavaScript
```html
<script>
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const wordCount = document.getElementById('wordCount');
// Configure marked with highlight.js
marked.setOptions({
breaks: true,
gfm: true,
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {}
}
return hljs.highlightAuto(code).value;
}
});
// Live preview
function updatePreview() {
const html = marked.parse(editor.value);
preview.innerHTML = html;
updateWordCount();
}
function updateWordCount() {
const text = editor.value.trim();
if (!text) {
wordCount.textContent = '0 words';
return;
}
const words = text.split(/\s+/).length;
wordCount.textContent = `${words} word${words !== 1 ? 's' : ''}`;
}
editor.addEventListener('input', updatePreview);
// Insert formatting around selected text (inline)
function insertFormat(before, after) {
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selected = editor.value.substring(start, end);
const replacement = before + selected + after;
editor.setRangeText(replacement, start, end, 'select');
editor.focus();
updatePreview();
}
// Insert at beginning of line
function insertLine(prefix) {
const start = editor.selectionStart;
const lineStart = editor.value.lastIndexOf('\n', start - 1) + 1;
editor.setRangeText(prefix, lineStart, lineStart, 'end');
editor.focus();
updatePreview();
}
// File operations
function saveMarkdown() {
const blob = new Blob([editor.value], { type: 'text/markdown' });
downloadBlob(blob, getFilename());
}
function exportHtml() {
const html = marked.parse(editor.value);
const fullHtml = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Exported Markdown</title>
<style>body{font-family:system-ui,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.7}
pre{background:#f5f5f5;padding:16px;border-radius:8px;overflow-x:auto}
code{background:#f0f0f0;padding:2px 6px;border-radius:3px}
table{border-collapse:collapse;width:100%}
th,td{border:1px solid #ddd;padding:8px 12px}
img{max-width:100%}</style></head>
<body>${html}</body></html>`;
const blob = new Blob([fullHtml], { type: 'text/html' });
const name = getFilename().replace('.md', '.html');
downloadBlob(blob, name);
}
function getFilename() {
return document.getElementById('filename').textContent || 'untitled.md';
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function clearEditor() {
if (editor.value.trim() && !confirm('Clear the editor?')) return;
editor.value = '';
updatePreview();
editor.focus();
}
// Allow tab key in textarea (instead of moving focus)
editor.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
const start = editor.selectionStart;
editor.setRangeText('\t', start, start, 'end');
updatePreview();
}
});
// Drag and drop markdown files
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', function(e) {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.md')) {
const reader = new FileReader();
reader.onload = function(ev) {
editor.value = ev.target.result;
document.getElementById('filename').textContent = file.name;
updatePreview();
};
reader.readAsText(file);
}
});
// Initial render
updatePreview();
</script>
</body>
</html>Step 4: Run It
Since this is a single HTML file with no backend, you can open it directly in a browser:
# Open directly
open index.html # macOS
xdg-open index.html # Linux
start index.html # WindowsOr serve it with Python for localhost access:
python -m http.server 8000
# Open http://localhost:8000Expected output: A split-screen editor. Left side has pre-filled Markdown example text. Right side shows rendered HTML with styled headings, code blocks with syntax highlighting (Python in shades of blue/purple), and a table with proper borders. Type in the left pane — the right pane updates instantly.
Architecture
flowchart LR
A[User types in textarea] --> B[input event]
B --> C[marked.parse(text)]
C --> D[HTML string]
D --> E[preview.innerHTML = html]
E --> F[highlight.js processes <pre><code>]
F --> G[Syntax-colored preview rendered]
H[User clicks toolbar button] --> I[insertFormat / insertLine]
I --> J[Modify textarea selection]
J --> B
K[User drops .md file] --> L[FileReader reads content]
L --> M[Set textarea value]
M --> B
N[User clicks Save/Export] --> O[Create Blob]
O --> P[CreateObjectURL + download link]
Common Errors
1. Preview doesn’t update when typing
The input event listener on the textarea triggers updatePreview(). If the preview is stale, check that marked is loaded (look for console errors: marked is not defined means the CDN failed to load). Also ensure editor.addEventListener('input', updatePreview) is called after the DOM is ready — our script is at the bottom of <body>, so this should work.
2. Syntax highlighting not working
The highlight function in marked.setOptions must return valid HTML. If hljs is not loaded, highlightAuto will throw. Check that highlight.js CDN loaded correctly. The lang parameter comes from the fenced code block’s language tag (e.g., ```python). If the tag doesn’t match hljs.getLanguage(), it falls back to highlightAuto.
3. Toolbar inserts at wrong position
insertLine calculates the line start by finding the last newline before the cursor. If the cursor is at position 0 (first line), lastIndexOf('\n', -1) returns -1, so lineStart becomes 0 (correct). But if the textarea content starts with a newline, this calculation is off. The insertFormat function correctly handles any selection, including zero-length (cursor without selection).
4. Download doesn’t work
Some browsers block automatic downloads from file:// URLs. Use python -m http.server to serve the file over HTTP, then the download will work. The URL.createObjectURL + click pattern is the most reliable cross-browser approach.
Practice Questions
1. How does the live preview work?
Every keystroke fires an input event on the textarea. The handler calls marked.parse(editor.value) to convert Markdown text to HTML, then sets preview.innerHTML to the result. Finally, highlight.js scans the preview for <pre><code> blocks and applies syntax coloring.
2. What’s the difference between insertFormat and insertLine?
insertFormat wraps the current selection (or inserts at cursor): **selected** or `selected`. insertLine prepends text to the beginning of the current line: # , - , > . insertFormat uses selectionStart/End, insertLine calculates the line start position.
3. How does the file drag-and-drop feature work?
The drop event listener reads the dropped file via FileReader. It checks for .md extension, reads the content as text, sets the editor value, updates the filename display, and triggers the preview update. The dragover listener prevents the default browser behavior (which would navigate away from the page).
4. Challenge: Add a word/character counter Add a character count next to the word count. Show both in the toolbar. Add a “reading time” estimate: divide word count by 200 (average reading speed in words per minute) and display “~2 min read”.
5. Challenge: Add a dark mode toggle
Add a button that toggles between light and dark themes. Switch the editor background, text color, preview styles, and toolbar. Save the preference in localStorage so it persists across sessions.
FAQ
Next Steps
- Add localStorage autosave to persist content across page reloads
- Explore React to build a component-based version of this editor
- Check the JavaScript fundamentals tutorial for deeper DOM manipulation patterns
- Build the File Upload Service to handle image uploads for the editor
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro