Quick start

The email template editor that lives in your app, not someone else's SaaS.

The email template editor that lives in your app, not someone else's SaaS.

Marketing emails are usually the one part of a product designed somewhere else, behind someone else's login, in someone else's brand tooling. This package puts the whole editing surface inside your own app: a grey canvas, a 600px card, and fifteen blocks the author drags into place. The blocks, the theme and every string are configuration you pass in, and the document it produces is plain data your server renders into the sent email.

DesktopMobile
Your header

Not at allAbsolutely
The full footer (contact details, social links, unsubscribe) is added when the email is sent.

Block settings

Select a block to edit its settings.

This is the real component. Drag a block by its handle, edit the copy in place, or select a block and open its settings.

Install

bun add @voila.dev/ui @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
globals.css
@import "@voila.dev/ui/styles/sources.css";

Peers: react@19, react-dom@19, tailwindcss@4.

The 3-minute win

The editor is controlled: you hold the document, it dispatches changes. That makes persistence one JSON.stringify away, and undo, autosave or drafts a matter of what you do with the state.

import {
	createEmailBlocks,
	EmailBlockEditor,
	type EmailEditorDocument,
	emptyEmailEditorDocument,
} from "@voila.dev/ui/email-block-editor";
import { useState } from "react";
 
// Built once, outside the component: this is the editor's configuration, not
// its state.
const BLOCKS = createEmailBlocks({ currency: "EUR" });
 
export function CampaignEditor() {
	const [document, setDocument] = useState<EmailEditorDocument>(
		emptyEmailEditorDocument,
	);
 
	return (
		<EmailBlockEditor
			blocks={BLOCKS}
			document={document}
			onDocumentChange={setDocument}
			onUploadImage={async (file) => {
				// Upload to your own storage, resolve with the public URL.
				const { url } = await uploadImage(file);
				return url;
			}}
		/>
	);
}

Omit onUploadImage to disable image uploads. Pass headerSlot and footerSlot to replace the neutral header and footer placeholders with your own chrome, documentSettings for fields that belong to the document rather than to a block (a subject line, a preheader), and generateBlockId for deterministic ids in tests.

Arranging it differently

EmailBlockEditor is the parts put together the usual way. When you want a different arrangement, compose them yourself — every part renders a sensible default with no children, so this is the same editor:

<EmailEditor.Root blocks={BLOCKS} document={document} onDocumentChange={setDocument}>
	<EmailEditor.Layout>
		<EmailEditor.Toolbar />
		<EmailEditor.Canvas />
		<EmailEditor.Sidebar />
	</EmailEditor.Layout>
	<EmailEditor.SettingsSheet />
</EmailEditor.Root>

Selection and preview are controlled if you pass them (selectedBlockId / onSelectedBlockIdChange, preview / onPreviewChange) and internal if you do not.

Mental model

The document is plain data; the editor is one view of it. A document is { version, blocks }, every block a small readonly object with a type field, and the whole thing serialises to JSON without ceremony. The editor never emits HTML: placeholders like {{firstName}} stay as tokens, prices are integers in minor units, and rich text is a flat list of spans. Your server walks the same structure to produce the sent email, which is why the canvas can promise to be an honest preview rather than an approximation.

Every building part is exported individually — the block definitions, the option rows, the reducer, the hooks — so the composed EmailBlockEditor is a convenience rather than a boundary.

API

PropTypeDefault
blocksEmailEditorRegistry | readonly AnyEmailBlockDefinition[]requiredThe block types this editor offers. createEmailBlocks returns the ones this package ships; add your own, drop what you do not want, reorder them. Either the definition list or a registry built from it.
documentEmailEditorDocument<Block>requiredThe document being edited: { version, blocks }, plain serialisable data. Controlled — the editor holds no copy, so persistence is one JSON.stringify away and undo is whatever you do with the state.
onDocumentChange(document: EmailEditorDocument<Block>) => voidrequiredCalled with the whole next document on every edit, keystrokes included.
documentSettingsReactNodeFields for the document as a whole — a subject line, a preheader. Sits above the canvas when the layout is compact, at the top of the settings column when it is wide.
footerSlotReactNodeReplaces the neutral footer placeholder below the card.
generateBlockId(() => string)Block-id factory, injectable for deterministic tests.
headerSlotReactNodeReplaces the neutral header placeholder above the blocks with your own chrome.
labelsEmailEditorLabelsInputEvery string the editor shows.
onPreviewChange((preview: EmailEditorPreview) => void)
onSelectedBlockIdChange((blockId: string | null) => void)
onUploadImage((file: File) => Promise<string>)Delegated image upload: receives the picked file, resolves with its public URL. Omit to disable image uploads.
previewEmailEditorPreviewControls which rendering the canvas mirrors. Omit both and the editor starts on the one that suits the viewport.
selectedBlockIdstring | nullControls the selection. Omit both to let the editor keep it internally.
themeEmailEditorThemeInputColours, font, preview locale and metrics of the canvas.

Plus the DOM props of the element it renders. Source: email-block-editor/components/email-block-editor.tsx.

Everything on the pages below is a block — a plain readonly object with a type field, not a component with props — so those pages document a data shape instead of a table. The definitions behind them are public too, so a host can render the same blocks outside the editor.

Page map

The blocks:

  • HeadingBlock: the email's title, or a section heading inside it.
  • ParagraphBlock: rich text as a flat list of spans.
  • ButtonBlock: the call to action, filled or outlined.
  • ImageBlock: an image, optionally linked, with a play-badge overlay for video thumbnails.
  • ListBlock: bullets, numbers or badges, with an optional bold lead-in per item.
  • DividerBlock: the horizontal rule between sections.
  • GridBlock: a multi-column row any leaf block can sit in.
  • StatBlock: one figure with a label; three of them make a stats row in a grid.
  • TableBlock: plain-text rows and columns, with an optional header row.
  • ArticleBlock: a blog post or resource card.
  • ProductBlock: a product card with price and compare-at price.
  • OfferBlock: a plan card with features and an optional highlight.
  • RatingBlock: a one-to-five question where each step is its own tracked link.
  • HighlightBlock: one bold line on a brand-tinted panel.
  • FinePrintBlock: the small print at the foot of the email.

The features: