Skip to content
Build a Markdown Editor with Live Preview (Step by Step)

Build a Markdown Editor with Live Preview (Step by Step)

DodaTech Updated Jun 19, 2026 10 min read

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-editor

Create 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">&lt;/&gt;</button>
        <button onclick="insertFormat('[','](url)')" title="Link">🔗</button>
        <button onclick="insertFormat('![','](url)')" 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

FeatureStatus
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  # Windows

Or serve it with Python for localhost access:

python -m http.server 8000
# Open http://localhost:8000

Expected 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

Why use marked instead of other Markdown libraries?
Marked is the fastest Markdown parser in JavaScript, supports GFM (tables, strikethrough, task lists), and integrates easily with highlight.js. It’s also lightweight (~15KB minified). Alternatives include Showdown (slower but more configurable) and remarkable (no longer maintained).
How do I add spell check to the editor?
Add spellcheck="true" to the <textarea> — modern browsers include built-in spell check. For more advanced features, use a library like TinyMCE or CodeMirror, which support plugins for spell check, auto-completion, and code folding.
Can I add image upload support?
Yes. Add a file input or drop zone for images. On upload, convert the image to a Base64 data URL and insert ![](data:image/png;base64,...) into the editor. For production, upload to a cloud storage service and insert the URL instead (base64 blobs make the Markdown file huge).

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