_emailmd_

React Builder

Drop the full emailmd builder into your React app, or use the hooks and preview components on their own.

@emailmd/react packages the entire emailmd.dev builder as a drop-in component, plus the lower-level pieces (a render hook and a sandboxed preview) for building your own UI.

Install

npm install @emailmd/react emailmd

Requires React 18+. Rendering runs fully client-side (emailmd uses mjml-browser in the browser), so all components are client components.

<EmailmdBuilder />

The full emailmd.dev builder, as a drop-in component: CodeMirror markdown editor with a formatting toolbar, visual theme editor, emoji and snippet pickers, live preview with device switching and a dark-mode toggle (enabled when the email uses theme: auto), HTML source (pretty/minified with a Gmail-clip meter), plain-text view, render warnings, autosave, download, and optional share links. The builder page on this site is exactly this component.

'use client';

import { EmailmdBuilder } from '@emailmd/react';
import '@emailmd/react/styles.css';

export function BuilderPage() {
  return <EmailmdBuilder />;
}

Give the parent element a height; the builder fills it.

PropTypeDefaultDescription
defaultValuestringstarter templateInitial markdown when no draft or share link is present
value / onChangestring / (md: string) => voidControlled mode (disables autosave)
autoSaveboolean | stringtrueDraft persistence in localStorage; a string sets a custom key (default "emailmd:draft")
sharebooleanfalseRead #md= URL fragments on mount and show a share-link button
colorScheme'light' | 'dark''light'UI scheme
renderOptionsRenderOptionsPassed through to render() for the preview
lintbooleanfalseRun lint() on each render and surface findings in the warnings banner
debounceMsnumber150Typing → re-render debounce
toolbarItemsEmailmdBuilderToolbarItem[]Custom toolbar buttons; see below
classNamestringExtra class on the root element

Share links put the (deflate-compressed) document in the URL fragment, so shared markdown never reaches your server.

Custom Toolbar Buttons

Add your own buttons to the toolbar with toolbarItems. Each onClick receives the editor API, so a button can read and rewrite the document with no extra wiring. The classic use is an AI button:

<EmailmdBuilder
  toolbarItems={[
    {
      id: 'ai',
      label: 'AI',
      tooltip: 'Draft with AI',
      onClick: async (editor) => {
        const prompt = window.prompt('What should this email say?');
        if (!prompt) return;
        const markdown = await draftEmail(prompt, editor.getMarkdown());
        editor.setMarkdown(markdown);
      },
    },
  ]}
/>

An item with an icon renders icon-only with its label as the accessible name and tooltip; without one, the label text renders on the button.

Driving the Editor

The same editor API is exposed through the component's ref, for driving the builder from outside the toolbar (a chat side panel, an import flow, a template picker):

const builderRef = useRef<EmailmdBuilderHandle>(null);

<EmailmdBuilder ref={builderRef} />;

// Later, when your AI response arrives:
builderRef.current?.setMarkdown(generated);
MethodWhat it does
getMarkdown()Returns the current document
setMarkdown(md)Replaces the whole document
insertAtCursor(text)Inserts at the cursor, leaving existing text in place
replaceSelection(text)Replaces the selected range (inserts at the cursor when empty)
getSelection()Returns { text, from, to } for the current selection
focus()Focuses the editor

Every mutation goes through a CodeMirror transaction, so autosave and onChange fire exactly as if the user had typed, and each call lands as a single undo step: your users can Cmd+Z away an AI draft they don't like. getSelection plus replaceSelection enable selection-scoped AI actions like "rewrite this paragraph".

Styling

The UI has no Tailwind or component-library dependency. styles.css scopes everything under .emd- class names and derives all colors from --emd-* CSS custom properties, so you can retheme it:

.emd-builder {
  --emd-link: #7c3aed;
  --emd-radius: 10px;
}

useEmailmd(markdown, options?)

Renders markdown to email-safe HTML as it changes. Debounced, race-safe (stale renders never overwrite newer ones), and SSR-safe (returns empty output on the server).

'use client';

import { useState } from 'react';
import { useEmailmd, EmailPreview } from '@emailmd/react';

export function Editor() {
  const [markdown, setMarkdown] = useState('# Hello');
  const { html, text, meta, warnings, error, isRendering } = useEmailmd(markdown);

  return (
    <div style={{ display: 'flex', height: '100%' }}>
      <textarea value={markdown} onChange={(e) => setMarkdown(e.target.value)} />
      <EmailPreview html={html} />
    </div>
  );
}

Options extend RenderOptions, so theme, fonts, minify, strings, and the rest all pass through, plus:

OptionTypeDefaultDescription
debounceMsnumber150Wait after the last keystroke before re-rendering. 0 renders immediately.
lintbooleanfalseRun lint() alongside each render; findings land in lintFindings.

The returned object:

FieldTypeDescription
htmlstringComplete email document (empty until the first render lands)
textstringPlain-text MIME part
metaobjectFrontmatter metadata
warningsRenderWarning[]Non-fatal issues; empty when clean
lintFindingsLintFinding[]Lint results when the lint option is on; empty otherwise
errorError | nullSet if render() threw; previous output is retained
isRenderingbooleanTrue while an updated render is in flight

Options are compared by value, so inline object literals are fine. If you swap a custom wrapper function at runtime, memoize it; function identity changes aren't detected until the next input change.

<EmailPreview />

A sandboxed iframe showing a rendered email.

<EmailPreview html={html} device="mobile" />
PropTypeDefaultDescription
htmlstringRendered email document (RenderResult.html)
device'desktop' | 'mobile' | number'desktop''desktop' fills the container, 'mobile' is 375px, or pass a pixel width
emulateColorScheme'light' | 'dark'Pin the preview to one of the email's color-scheme variants regardless of the viewer's OS; unset follows the OS

All other iframe props (className, style, sandbox, title, …) pass through.

The exported helper hasDarkModeStyles(html) tells you whether a rendered email opts into dark mode, useful for showing a dark-preview toggle only when it does something.

Versioning

@emailmd/react is versioned independently of emailmd and declares it as a peer dependency (>=0.7.0), so you control the rendering engine version.

On this page