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.
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@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
| Prop | Type | Default | |
|---|---|---|---|
blocks | EmailEditorRegistry | readonly AnyEmailBlockDefinition[] | required | The 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. |
document | EmailEditorDocument<Block> | required | The 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>) => void | required | Called with the whole next document on every edit, keystrokes included. |
documentSettings | ReactNode | — | Fields 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. |
footerSlot | ReactNode | — | Replaces the neutral footer placeholder below the card. |
generateBlockId | (() => string) | — | Block-id factory, injectable for deterministic tests. |
headerSlot | ReactNode | — | Replaces the neutral header placeholder above the blocks with your own chrome. |
labels | EmailEditorLabelsInput | — | Every 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. |
preview | EmailEditorPreview | — | Controls which rendering the canvas mirrors. Omit both and the editor starts on the one that suits the viewport. |
selectedBlockId | string | null | — | Controls the selection. Omit both to let the editor keep it internally. |
theme | EmailEditorThemeInput | — | Colours, 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:
- Server-side rendering: walk the document to produce the sent email.
- Custom blocks: add your own block type in one file.
- Theming the canvas: the editor chrome follows your tokens; the email card follows your email.