If you’ve ever wanted to understand how markdown-to-HTML conversion works under the hood, learning to build a markdown previewer using HTML, CSS, and vanilla JS is one of the most satisfying beginner projects you can tackle this year. It’s a small, focused app that teaches you DOM manipulation, event handling, and the fundamentals of parsing — all without a single dependency. In a world of ever-growing JavaScript bundles, there’s real joy in opening a single index.html file and having a working tool.
By the end of this walkthrough, you’ll have a real-time previewer that converts Markdown text into formatted HTML as you type. You’ll also learn a few modern CSS tricks for the layout and a security-focused approach to parsing.
Why a Markdown Previewer Is the Perfect Vanilla JS Project
Markdown is everywhere — READMEs, blog posts, forum comments, and even emails. But most people never look behind the curtain. Building your own previewer gives you a mental model of how parsers work. It’s not as hard as you might think, and you don’t need to write a full compiler. A focused set of regular expressions and a well-structured chain of functions is enough to handle the most common Markdown syntax.
Unlike using a library like marked or markdown-it, writing the parser yourself gives you total control over the output. You can add your own custom syntax later, and you’ll deeply understand every transformation that happens between the textarea and the preview panel.
Setting Up the HTML Structure
Start with a clean HTML document. You need two core elements: a textarea for the raw Markdown input and a container that will hold the rendered HTML. A simple semantic structure also makes styling easier later.
<main class="markdown-app">
<textarea id="editor" aria-label="Markdown input" placeholder="Type Markdown here..."></textarea>
<article id="preview" class="preview" aria-label="Rendered preview"></article>
</main>
The article element is a good fit for the preview because it represents an independent piece of content. The textarea uses an explicit aria-label to make the interface accessible to screen readers.
Styling the Editor and Preview with Modern CSS
In 2026, CSS has evolved to a point where you can avoid complex JavaScript layout logic entirely. Instead of manually managing split-pane resizing, use CSS grid for the main layout and a handful of modern properties for polish.
.markdown-app {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
height: 100vh;
}
#editor {
width: 100%;
padding: 1rem;
font-family: ui-monospace, monospace;
border: 1px solid #ccc;
border-radius: 0.5rem;
resize: horizontal;
}
.preview {
padding: 1rem;
overflow-y: auto;
border-radius: 0.5rem;
background-color: color-mix(in srgb, canvas, gray 10%);
}
Two modern CSS features deserve special attention:
:has()— You can style the preview based on its rendered content. For example, you could highlight the preview when it contains a code block:
.preview:has(pre) {
box-shadow: 0 0 0 2px cornflowerblue;
}
color-mix()— Instead of hard-coding a tinted background,color-mix()blends the canvas color with a custom color. This automatically respects both light and dark color schemes.
Using resize: horizontal on the textarea gives users a draggable split without writing any JavaScript.
Writing the Markdown Parser in Vanilla JavaScript
Now comes the heart of the project. Your parser will take a string of Markdown and return an HTML string. The key rule: always escape raw HTML first. If you don’t, a user could inject a <script> tag into your page — a serious security vulnerability known as cross-site scripting (XSS).
function escapeHTML(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
Block-Level Parsing
Start by splitting the input into lines and identifying block elements. Headings, paragraphs, blockquotes, and code blocks are all block-level constructs. A simple approach is to handle headings first, then fall through to other patterns.
function parseBlockMarkdown(text) {
const lines = escapeHTML(text).split("\n");
let html = "";
for (const line of lines) {
if (line.startsWith("### ")) {
html += `<h3>${line.slice(4)}</h3>`;
} else if (line.startsWith("## ")) {
html += `<h2>${line.slice(3)}</h2>`;
} else if (line.startsWith("# ")) {
html += `<h1>${line.slice(2)}</h1>`;
} else if (line.startsWith("> ")) {
html += `<blockquote>${line.slice(3)}</blockquote>`;
} else if (line.startsWith("```")) {
// toggle code block mode
} else if (line.trim() === "") {
html += "<br>";
} else {
html += `<p>${parseInlineMarkdown(line)}</p>`;
}
}
return html;
}
For code blocks, you’ll want a small state machine that toggles between regular text and preformatted code. When a line starts with “`, open a <pre><code> block and keep adding raw (escaped) lines until the closing backtick sequence appears.
Inline Parsing
After block parsing, every piece of regular text runs through an inline parser. This is where bold, italic, links, and inline code come to life.
function parseInlineMarkdown(text) {
return text
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`(.+?)`/g, "<code>$1</code>")
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
}
The order matters. For example, bold should be processed before italic, otherwise the asterisks in **bold** will be consumed by the italic pattern. This tiny parser handles most everyday Markdown.
Connecting the Parser to Real-Time Input
With parsing logic in place, the final step is wiring it to the textarea’s input event. Debouncing prevents the parser from running on every single keystroke, which keeps typing smooth even with large documents.
const editor = document.getElementById("editor");
const preview = document.getElementById("preview");
let debounceTimer;
editor.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
preview.innerHTML = parseBlockMarkdown(editor.value);
}, 120);
});
// Render initial content
editor.value = "# Hello, Markdown!";
preview.innerHTML = parseBlockMarkdown(editor.value);
If you want extra smoothness, pair the debounce with requestAnimationFrame. The debounce ensures you don’t parse too often, and requestAnimationFrame defers the DOM write until the browser is ready to repaint. For most documents, a single debounce is enough.
Going Beyond the Basics: Useful Enhancements
Now that the core previewer works, you can have fun extending it. A few ideas that keep the “vanilla” spirit alive:
- Live word and character count — Update a small status bar using the
inputevent you already have. - Copy as HTML — Select the preview content and copy it using the
Clipboard API. - Syntax highlighting — Write a tiny highlighter that adds spans to code comments, strings, and keywords. It’s a great follow-up project.
- Mobile layout — Use a CSS media query to stack the editor and preview vertically.
- Drag-to-resize — Replace the CSS
resizeproperty with a custom pointer-event handler for a fixed-width divider.
Each enhancement pushes you a little further without pulling in a dependency.
Testing and Edge Cases
As with any parser, the edge cases are where the real learning happens. Test your previewer with the following tricky inputs:
- Empty lines between paragraphs
- Unclosed bold or italic markers
- Nested blockquotes
- Code blocks containing asterisks, underscores, or link syntax
- Raw HTML tags like
<img onerror="alert(1)">— your escape function should render it as harmless text - Very long words that might break the layout
When you encounter a bug, open the browser’s DevTools and inspect the generated HTML. You’ll immediately see where the parser’s output diverges from your intent.
Conclusion
Building a markdown previewer using HTML, CSS, and vanilla JavaScript is a compact project with enormous learning value. You’ve practiced DOM manipulation, event handling, regex-based parsing, and security-conscious output rendering — all while creating a genuinely useful tool that runs anywhere, with zero dependencies. The techniques you used here are transferable to any client-side text processing task, and the final product is fast, accessible, and private by design.
