Look, I know what you’re thinking. “Another markdown editor? Really?” Yes. Really. Because the one I was using crashed twice yesterday while I was trying to document a simple API endpoint, and I took that personally. I didn’t want to debug someone else’s Electron app. I just wanted to write.

So, naturally, instead of doing the work I was actually paid to do, I spent my Tuesday evening building a clone of a certain popular in-browser markdown editor. I used SvelteKit and TypeScript. Why? Because I have zero patience for boilerplate, and frankly, React’s dependency arrays have caused me enough emotional damage for one lifetime.

Here’s the thing about Svelte in 2026: it’s boring. And I mean that as the highest possible compliment. You write code, and it just… works. No wrestling with the virtual DOM, no memoization gymnastics. It just updates the DOM when your data changes. Revolutionary concept, right?

The “State” of Things (Literally)

If you’re still clinging to the Svelte 4 way of doing things, you might be expecting a lot of let and weird store subscriptions. But I went full Svelte 5 Runes here. It’s cleaner. It feels more like just writing JavaScript.

I needed a global state to handle the raw markdown text because I wanted to split the editor and the preview into separate components. In the old days, I would have set up a complex context or a store. Here? I just made a raw state object.

// lib/editorState.svelte.ts
export class EditorState {
    content = $state('');
    
    constructor(initialContent = '') {
        this.content = initialContent;
    }

    update(newContent: string) {
        this.content = newContent;
    }

    // A derived value for word count because why not?
    get wordCount() {
        return this.content.split(/\s+/).filter(w => w.length > 0).length;
    }
}

export const editorStore = new EditorState('# Hello World');

That $state rune handles all the reactivity. I don’t need to wrap it in a provider or do any weird higher-order component nonsense. I just import editorStore and use it. If I change content, anything reading wordCount updates instantly. It feels almost like cheating.

The Editor View: Where the Typing Happens

Svelte logo - svelte logo Download png
Svelte logo – svelte logo Download png

The input side is basically a glorified textarea. But I wanted it to feel nice. Monospace font, decent padding, none of that default browser styling garbage.

Here’s the component logic. Notice how I’m just binding the value directly to the store instance we created above. No onChange handlers required unless I want to do something fancy like auto-saving (which I did, because I don’t trust browsers).

// components/EditorPane.svelte
<script lang="ts">
    import { editorStore } from '$lib/editorState.svelte';
    import { onMount } from 'svelte';

    let textarea: HTMLTextAreaElement;

    // Auto-resize logic or other DOM manipulation goes here
    function handleInput() {
        // Maybe save to localStorage here?
        localStorage.setItem('draft', editorStore.content);
    }
</script>

<div class="editor-container">
    <textarea
        bind:this={textarea}
        bind:value={editorStore.content}
        oninput={handleInput}
        placeholder="Start typing..."
        class="w-full h-full p-4 bg-gray-900 text-gray-100 font-mono resize-none focus:outline-none"
    ></textarea>
</div>

I threw in a localStorage save on input. Is it the most efficient way to handle persistence? Absolutely not. Does it save my butt when I accidentally close the tab? You bet. Sometimes the “dumb” solution is the right one.

The Preview: Parsing without Blocking

Parsing markdown can be heavy if you’re pasting in a massive novel. I didn’t want the typing to lag just because the parser was choking on a 5000-word regex operation.

I used a derived state for the compiled HTML. Svelte’s $derived rune is smart enough to only re-run when the dependencies change. I paired this with a simple parser (I used marked because I’m not writing a parser from scratch at 11 PM on a Tuesday).

// components/PreviewPane.svelte
<script lang="ts">
    import { editorStore } from '$lib/editorState.svelte';
    import { marked } from 'marked';
    import DOMPurify from 'dompurify';

    // This re-runs automatically whenever editorStore.content changes
    let compiledHtml = $derived.by(() => {
        const rawHtml = marked.parse(editorStore.content);
        // ALWAYS sanitize. Trust no one, not even yourself.
        return DOMPurify.sanitize(rawHtml as string);
    });
</script>

<div class="preview-container prose prose-invert p-4 overflow-y-auto">
    {@html compiledHtml}
</div>

Side note: If you ever skip the sanitization step because “it’s just a local app,” you’re wrong. I once pasted a markdown snippet from a forum that contained a malicious script tag and nearly XSS’d myself in my own dev environment. Don’t be me. Use DOMPurify.

The Scroll Sync Nightmare

Svelte logo - Svelte logo by gengns Download png
Svelte logo – Svelte logo by gengns Download png

This is where things got messy. I wanted that slick feature where scrolling the editor also scrolls the preview pane. It sounds simple. It is not.

The problem is the heights don’t match. The markdown source for an image is one line; the rendered image is 400 pixels tall. If you just map the scroll percentage 1:1, the preview drifts out of sync almost immediately.

I tried three different libraries before giving up and writing a “good enough” implementation myself. It’s not perfect, but it works for 90% of cases.

// Logic inside the parent layout component
<script lang="ts">
    let editorScroll = $state(0);
    let isScrollingEditor = false;
    let isScrollingPreview = false;

    function syncScroll(source: HTMLElement, target: HTMLElement, isSourceActive: boolean) {
        if (!isSourceActive) return;

        const percentage = source.scrollTop / (source.scrollHeight - source.clientHeight);
        const targetScrollTop = percentage * (target.scrollHeight - target.clientHeight);
        
        target.scrollTop = targetScrollTop;
    }
</script>

<!-- Simplified markup structure -->
<main class="grid grid-cols-2 h-screen">
    <div 
        onscroll={(e) => {
            isScrollingEditor = true;
            isScrollingPreview = false;
            syncScroll(e.currentTarget, previewDiv, isScrollingEditor);
        }}
    >
        <EditorPane />
    </div>

    <div 
        bind:this={previewDiv}
        onscroll={(e) => {
            isScrollingPreview = true;
            isScrollingEditor = false;
            syncScroll(e.currentTarget, editorDiv, isScrollingPreview);
        }}
    >
        <PreviewPane />
    </div>
</main>

The trick was the boolean flags (isScrollingEditor). Without those, you get an infinite loop where the editor scrolls the preview, which triggers a scroll event that scrolls the editor, which triggers the preview… until your browser tab crashes and you question your life choices.

Why SvelteKit Specifically?

TypeScript programming - TypeScript Programming Example - GeeksforGeeks
TypeScript programming – TypeScript Programming Example – GeeksforGeeks

I could have built this with vanilla JS. But SvelteKit gave me the routing (in case I want to add multiple file support later) and the server-side rendering capability for free. If I ever decide to turn this into a SaaS (I won’t, but let’s pretend), I’ve already got the API routes ready to go for backend storage.

Plus, the hot module replacement (HMR) in SvelteKit is insanely fast. When I was tweaking the CSS for the dark mode theme, the changes appeared instantly. No full page reloads, no losing my scroll position. It keeps the flow state alive.

I spent maybe three hours on this. The result is a clean, fast markdown editor that looks exactly how I want it to look. No subscription fees, no login screens, no “AI writing assistant” trying to complete my sentences poorly. Just text.

If you’ve been putting off learning Svelte or TypeScript because it looks intimidating, just try building something small like this. You might find yourself actually enjoying frontend development again. Weird, I know.