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 thirteen blocks the author drags into place. It is source-shipped like everything else here, so the editor is code you can read, rebrand and extend, 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";Or copy the source — it's the same files.
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 { EmailBlockEditor, type EmailEditorDocument, emptyEmailEditorDocument } from "@voila.dev/ui/email-block-editor";
import { useState } from "react";
export function CampaignEditor() {
const [document, setDocument] = useState<EmailEditorDocument>(
emptyEmailEditorDocument,
);
return (
<EmailBlockEditor
document={document}
onChange={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, and generateBlockId for deterministic ids in tests.
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
sections, the drag-and-drop list, the reducer), so the composed
EmailBlockEditor is a convenience, not a boundary. Read the code, then keep
it.
API
| Prop | Type | Default | |
|---|---|---|---|
document | EmailEditorDocument | required | The template being edited: { version, blocks }, plain serialisable data.
Controlled — the editor holds no copy of it, so persistence is one
JSON.stringify away and undo is whatever you do with the state. |
onChange | (document: EmailEditorDocument) => void | required | Called with the whole next document on every edit, keystrokes included. |
footerSlot | ReactNode | — | Replaces the neutral footer placeholder below the canvas. |
generateBlockId | (() => string) | () => crypto.randomUUID() | Block-id factory, injectable for deterministic tests. |
headerSlot | ReactNode | — | Replaces the neutral header placeholder above the canvas with your own chrome. |
onUploadImage | ((file: File) => Promise<string>) | — | Delegated image upload: receives the picked file, resolves with its public URL. Omit to disable image uploads. |
Plus the DOM props of the element it renders. Source: email-block-editor/components/email-block-editor.tsx.
This is the only component the package exports. Everything else 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 registry behind them, EMAIL_BLOCK_DEFINITIONS, is 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.
The features:
- Server-side rendering: walk the document to produce the sent email.
- Custom blocks: add your own block type in three files.
- Theming the canvas: the editor chrome follows your tokens; the email card follows your email.