Skip to main content
Back to Blog
Tutorials
3 min read
November 20, 2024

How to Build a Markdown Editor in React

Build a split-pane Markdown editor with live preview, syntax highlighting, and toolbar buttons in React.

Ryel Banfield

Founder & Lead Developer

A Markdown editor is essential for content management systems and documentation tools. Here is how to build one.

Step 1: Install Dependencies

pnpm add react-markdown remark-gfm rehype-highlight rehype-sanitize

Step 2: Basic Split-Pane Editor

"use client";

import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import rehypeSanitize from "rehype-sanitize";

const defaultContent = `# Hello World

This is a **Markdown** editor with _live preview_.

## Features

- Split-pane editing
- Live preview
- Syntax highlighting
- GitHub Flavored Markdown

\`\`\`javascript
const greeting = "Hello, World!";
console.log(greeting);
\`\`\`

> This is a blockquote.

| Header 1 | Header 2 |
|----------|----------|
| Cell 1   | Cell 2   |
`;

export function MarkdownEditor() {
  const [content, setContent] = useState(defaultContent);
  const [view, setView] = useState<"split" | "edit" | "preview">("split");

  return (
    <div className="flex h-[600px] flex-col rounded-xl border dark:border-gray-700">
      {/* Toolbar */}
      <div className="flex items-center justify-between border-b px-3 py-2 dark:border-gray-700">
        <EditorToolbar
          onInsert={(text) => setContent((prev) => prev + text)}
        />
        <div className="flex gap-1">
          {(["edit", "split", "preview"] as const).map((v) => (
            <button
              key={v}
              onClick={() => setView(v)}
              className={`rounded px-2 py-1 text-xs capitalize ${
                view === v
                  ? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
                  : "text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
              }`}
            >
              {v}
            </button>
          ))}
        </div>
      </div>

      {/* Editor / Preview */}
      <div className="flex flex-1 overflow-hidden">
        {view !== "preview" && (
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            className="flex-1 resize-none border-r p-4 font-mono text-sm focus:outline-none dark:border-gray-700 dark:bg-gray-900"
            placeholder="Write your Markdown here..."
            spellCheck={false}
          />
        )}
        {view !== "edit" && (
          <div className="flex-1 overflow-y-auto p-4">
            <div className="prose dark:prose-invert max-w-none">
              <ReactMarkdown
                remarkPlugins={[remarkGfm]}
                rehypePlugins={[rehypeHighlight, rehypeSanitize]}
              >
                {content}
              </ReactMarkdown>
            </div>
          </div>
        )}
      </div>

      {/* Status bar */}
      <div className="flex items-center justify-between border-t px-3 py-1 text-xs text-gray-500 dark:border-gray-700">
        <span>Markdown</span>
        <span>
          {content.split("\n").length} lines | {content.length} characters
        </span>
      </div>
    </div>
  );
}

Step 3: Formatting Toolbar

"use client";

import {
  Bold,
  Italic,
  Heading1,
  Heading2,
  List,
  ListOrdered,
  Code,
  Link,
  Image,
  Quote,
  Minus,
} from "lucide-react";

interface ToolbarProps {
  onInsert: (text: string) => void;
}

const tools = [
  { icon: Bold, label: "Bold", insert: "**bold text**" },
  { icon: Italic, label: "Italic", insert: "_italic text_" },
  { icon: Heading1, label: "Heading 1", insert: "\n# " },
  { icon: Heading2, label: "Heading 2", insert: "\n## " },
  { icon: List, label: "Bullet List", insert: "\n- Item 1\n- Item 2\n- Item 3\n" },
  { icon: ListOrdered, label: "Numbered List", insert: "\n1. Item 1\n2. Item 2\n3. Item 3\n" },
  { icon: Code, label: "Code Block", insert: "\n```\ncode here\n```\n" },
  { icon: Quote, label: "Quote", insert: "\n> " },
  { icon: Link, label: "Link", insert: "[link text](https://example.com)" },
  { icon: Image, label: "Image", insert: "![alt text](image-url.jpg)" },
  { icon: Minus, label: "Divider", insert: "\n---\n" },
];

export function EditorToolbar({ onInsert }: ToolbarProps) {
  return (
    <div className="flex gap-0.5">
      {tools.map(({ icon: Icon, label, insert }) => (
        <button
          key={label}
          onClick={() => onInsert(insert)}
          title={label}
          className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-300"
        >
          <Icon className="h-4 w-4" />
        </button>
      ))}
    </div>
  );
}

Step 4: Selection-Aware Formatting

"use client";

import { useRef, useCallback } from "react";

export function useEditorFormatting() {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const wrapSelection = useCallback((before: string, after: string) => {
    const textarea = textareaRef.current;
    if (!textarea) return;

    const start = textarea.selectionStart;
    const end = textarea.selectionEnd;
    const selected = textarea.value.substring(start, end);
    const replacement = `${before}${selected || "text"}${after}`;

    textarea.setRangeText(replacement, start, end, "select");
    textarea.focus();

    // Trigger React onChange
    const event = new Event("input", { bubbles: true });
    textarea.dispatchEvent(event);
  }, []);

  const insertAtCursor = useCallback((text: string) => {
    const textarea = textareaRef.current;
    if (!textarea) return;

    const start = textarea.selectionStart;
    textarea.setRangeText(text, start, start, "end");
    textarea.focus();

    const event = new Event("input", { bubbles: true });
    textarea.dispatchEvent(event);
  }, []);

  return {
    textareaRef,
    bold: () => wrapSelection("**", "**"),
    italic: () => wrapSelection("_", "_"),
    code: () => wrapSelection("`", "`"),
    link: () => wrapSelection("[", "](url)"),
    heading: (level: number) =>
      insertAtCursor("\n" + "#".repeat(level) + " "),
    insertAtCursor,
  };
}

Step 5: Export Options

export function EditorExport({
  content,
  filename = "document",
}: {
  content: string;
  filename?: string;
}) {
  function downloadMarkdown() {
    const blob = new Blob([content], { type: "text/markdown" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `${filename}.md`;
    a.click();
    URL.revokeObjectURL(url);
  }

  function downloadHTML() {
    // Simple markdown to HTML (use a proper converter in production)
    const html = `<!DOCTYPE html>
<html>
<head><title>${filename}</title></head>
<body>${content}</body>
</html>`;
    const blob = new Blob([html], { type: "text/html" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `${filename}.html`;
    a.click();
    URL.revokeObjectURL(url);
  }

  function copyToClipboard() {
    navigator.clipboard.writeText(content);
  }

  return (
    <div className="flex gap-2">
      <button
        onClick={downloadMarkdown}
        className="rounded border px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
      >
        Download .md
      </button>
      <button
        onClick={downloadHTML}
        className="rounded border px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
      >
        Download .html
      </button>
      <button
        onClick={copyToClipboard}
        className="rounded border px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
      >
        Copy
      </button>
    </div>
  );
}

Need a Content Management System?

We build CMS solutions with rich editing, markdown support, and content workflows. Contact us to discuss your project.

markdowneditorWYSIWYGReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles