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 emailmdRequires 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.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | starter template | Initial markdown when no draft or share link is present |
value / onChange | string / (md: string) => void | — | Controlled mode (disables autosave) |
autoSave | boolean | string | true | Draft persistence in localStorage; a string sets a custom key (default "emailmd:draft") |
share | boolean | false | Read #md= URL fragments on mount and show a share-link button |
colorScheme | 'light' | 'dark' | 'light' | UI scheme |
renderOptions | RenderOptions | — | Passed through to render() for the preview |
lint | boolean | false | Run lint() on each render and surface findings in the warnings banner |
debounceMs | number | 150 | Typing → re-render debounce |
toolbarItems | EmailmdBuilderToolbarItem[] | — | Custom toolbar buttons; see below |
className | string | — | Extra 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);| Method | What 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:
| Option | Type | Default | Description |
|---|---|---|---|
debounceMs | number | 150 | Wait after the last keystroke before re-rendering. 0 renders immediately. |
lint | boolean | false | Run lint() alongside each render; findings land in lintFindings. |
The returned object:
| Field | Type | Description |
|---|---|---|
html | string | Complete email document (empty until the first render lands) |
text | string | Plain-text MIME part |
meta | object | Frontmatter metadata |
warnings | RenderWarning[] | Non-fatal issues; empty when clean |
lintFindings | LintFinding[] | Lint results when the lint option is on; empty otherwise |
error | Error | null | Set if render() threw; previous output is retained |
isRendering | boolean | True 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" />| Prop | Type | Default | Description |
|---|---|---|---|
html | string | — | Rendered 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.