DEV Community

ggwork
ggwork

Posted on

How to Build a Real-Time Markdown Previewer with Synchronized Scrolling

While working on a side project recently, I found myself constantly switching between my editor and a browser tab to check how my Markdown rendered. I was writing documentation for a small library, and every time I wanted to verify a table or a code block, I'd copy-paste the content into some online tool, wait for it to load, and then scroll around looking for the right section.

After doing this about fifty times, I had a thought: "This is ridiculous. I should just build my own."

Spoiler: that thought led me down a rabbit hole of scrolling synchronization, XSS sanitization, and a surprising amount of CSS. Let me walk you through what I learned.

The Initial Approach

My first instinct was to find an existing solution. There are plenty of Markdown previewers out there — VS Code extensions, online editors, even some CLI tools that watch files and open a browser window. But most of them felt either too heavy (I don't need a full IDE for this) or too dependent on external services (I didn't want my content sent to someone else's server).

So I decided to build a simple browser-based tool. Pure HTML, CSS, and JavaScript. No build step, no framework, just a single file I could open anywhere.

For the Markdown parsing, I went with marked.js. It's fast, supports GitHub Flavored Markdown (GFM) out of the box, and has a tiny footprint. I also grabbed DOMPurify for sanitization — because the last thing I want is to render arbitrary HTML that someone pasted into my editor and have it execute scripts.

Here's the core rendering logic that ended up being the heart of the whole tool:

function renderMarkdown() {
  const raw = editor.value;
  const rawHtml = marked.parse(raw);
  preview.innerHTML = DOMPurify.sanitize(rawHtml);
}
Enter fullscreen mode Exit fullscreen mode

That's it. Three lines. The entire "rendering engine" of my tool.

The Scroll Synchronization Problem

The easy part was rendering. The hard part was syncing the scroll positions between the editor and the preview pane.

Here's why this is tricky: the editor is a <textarea> with plain text, and the preview is a <div> with rendered HTML. They have completely different content heights and scrolling behaviors. The textarea has one long scrollable content, while the preview has paragraphs, headings, code blocks, and all sorts of elements with different heights.

My first attempt was naive:

editor.addEventListener('scroll', () => {
  const percentage = editor.scrollTop / (editor.scrollHeight - editor.clientHeight);
  preview.scrollTop = percentage * (preview.scrollHeight - preview.clientHeight);
});
Enter fullscreen mode Exit fullscreen mode

This kind of works, but it's janky. The mapping is never perfect because Markdown source and rendered HTML have different proportions. A heading in Markdown might be 20 pixels tall, but the rendered <h2> with its margins might be 60 pixels. So the percentage calculation gets progressively more wrong as you scroll down.

I tried a few different approaches, including mapping line numbers to DOM elements, but that got complicated fast. The line-height in the textarea doesn't reliably match the rendered line heights in the preview.

What finally worked was a hybrid approach: use percentage-based syncing but with a smoothing factor, and only sync in one direction at a time to avoid feedback loops.

let isSyncing = false;

function syncScroll(source, target) {
  if (isSyncing) return;
  isSyncing = true;

  const sourceRatio = source.scrollTop / (source.scrollHeight - source.clientHeight);
  target.scrollTop = sourceRatio * (target.scrollHeight - target.clientHeight);

  requestAnimationFrame(() => { isSyncing = false; });
}
Enter fullscreen mode Exit fullscreen mode

The requestAnimationFrame is the key — it prevents the two scroll events from triggering each other infinitely.

AI-Assisted Development: The Honest Take

I'll be upfront: I used Claude to help me write this tool. And it was a mixed experience.

The first prompt I gave was something like: "Build a single-file HTML markdown previewer with live editing, GFM support, and synchronized scrolling."

The AI got the basic structure right immediately. The HTML layout, the CSS variables for theming, the marked.js integration — all solid. It even added the dark mode support via prefers-color-scheme without me asking, which was a nice touch.

But the scroll synchronization? It completely botched it on the first try. It gave me the naive percentage approach I described above, which I had to debug and fix myself. The feedback loop issue (where scrolling one pane causes the other to scroll, which causes the first to scroll again) was something the AI didn't anticipate.

I also had to step in on the i18n implementation. The AI initially hardcoded all the button labels in English. I had to explicitly prompt it to create an i18n system with a t('key') function and data-i18n attributes for static text. It handled the refactor well once I explained what I wanted, but it wasn't proactive about it.

My honest take: AI is great for scaffolding and boilerplate. It's terrible at understanding the subtle interactions between different parts of a UI. The scroll sync bug was something that only manifests at runtime, and the AI couldn't reason about it from static code alone.

The XSS Gotcha

Here's something I almost missed: marked.js doesn't sanitize its output. By default, it will happily render raw HTML from your Markdown. That means if someone writes <script>alert('hacked')</script> in the editor, it will execute in the preview pane.

For a local tool this might not seem like a big deal, but it's bad practice. Especially if you ever share the tool or use it to preview untrusted content (like README files from random GitHub repos).

The fix was straightforward — run the output through DOMPurify:

const cleanHtml = DOMPurify.sanitize(marked.parse(raw));
Enter fullscreen mode Exit fullscreen mode

But here's the thing: this adds a dependency. And it's a security-critical one. If DOMPurify has a vulnerability, my tool inherits it. I could have written my own sanitizer, but that's a terrible idea — writing a robust HTML sanitizer is genuinely hard, and I'd be reinventing a wheel that's already been battle-tested.

Sometimes the right engineering decision is to trust a well-maintained library.

The CDN Dilemma

Another decision I wrestled with: where to load marked.js and DOMPurify from.

My first choice was jsdelivr — it's fast and reliable. But then I remembered that it's sometimes blocked in certain regions (notably mainland China). Since I wanted this tool to be usable by as many people as possible, I switched to staticfile.org, a CDN that's specifically designed to be accessible from China.

<script src="https://cdn.staticfile.org/marked/4.3.0/marked.min.js"></script>
<script src="https://cdn.staticfile.org/dompurify/3.0.6/purify.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

This is the kind of consideration that's easy to overlook but makes a real difference for actual users.

What I Learned

Building this tool taught me a few things:

1. Scroll synchronization is deceptively hard. It seems simple on the surface, but the mismatch between source text and rendered HTML makes perfect sync nearly impossible. You have to accept a "good enough" approximation.

2. AI assistance is a multiplier, not a replacement. The AI saved me maybe 30 minutes of boilerplate coding, but it also introduced bugs that took me longer to fix than if I'd just written the code myself. The best workflow was: AI for structure, me for logic.

3. Single-file tools have a charm. There's something nice about a tool that's just one HTML file. No build process, no dependencies to manage (except CDN links), no server. You can email it to someone, and it just works.

During this process, I built a small browser-based tool to make this workflow easier. It's now live at Craftvo if you want to try it. The source is simple enough that you could replicate it yourself, but if you'd rather not reinvent this particular wheel, it's there.

The best part? I haven't switched to a browser tab to check my Markdown rendering since.


Tags: markdown, javascript, webdev, ai, tools

Top comments (0)