The block set is not something the package decides. An editor instance runs on the definitions you hand it, and the add-block menu, the canvas, the settings sidebar, the reducer and the drag-and-drop layer all read from that one list. Adding a block is writing one file and putting it in the list — there is no central switch, no union to extend inside the package, and nothing to fork.
Suppose your emails need a pull quote: a line of text with an author, drawn in the brand colour.
1. Declare the type
Your own type, in your own code. The editor asks only for id and type:
export interface QuoteBlock {
readonly id: string;
readonly type: "quote";
readonly text: string;
readonly author: string;
}2. Write the definition
One file, exporting an EmailBlockDefinition. It is the single source of
truth for that block: what an empty one looks like, how it renders, how it is
configured, whether its content is rich text, and whether it holds other
blocks.
import { QuotesIcon } from "@phosphor-icons/react";
import {
BlockTextInput,
type EmailBlockComponentProps,
type EmailBlockDefinition,
TextOption,
useEmailEditorTheme,
} from "@voila.dev/ui/email-block-editor";
function QuoteBlockView({
block,
onChange,
}: EmailBlockComponentProps<QuoteBlock>) {
const theme = useEmailEditorTheme();
return (
<figure
className="m-0 border-l-2 pl-4"
style={{ borderColor: theme.color.brand, fontFamily: theme.font }}
>
<BlockTextInput
ariaLabel="Citation"
value={block.text}
onChange={(text) => onChange({ ...block, text })}
placeholder="Votre citation"
className="italic leading-[1.5]"
style={{ color: theme.color.ink, fontSize: "15px" }}
/>
<BlockTextInput
ariaLabel="Auteur"
value={block.author}
onChange={(author) => onChange({ ...block, author })}
placeholder="Auteur"
className="mt-1"
style={{ color: theme.color.muted, fontSize: "13px" }}
/>
</figure>
);
}
function QuoteBlockSettings({
block,
onChange,
}: EmailBlockComponentProps<QuoteBlock>) {
return (
<>
<TextOption
label="Citation"
value={block.text}
onChange={(text) => onChange({ ...block, text })}
/>
<TextOption
label="Auteur"
value={block.author}
onChange={(author) => onChange({ ...block, author })}
/>
</>
);
}
export const quoteBlockDefinition: EmailBlockDefinition<QuoteBlock> = {
type: "quote",
label: "Citation",
icon: QuotesIcon,
createEmpty: (id) => ({ id, type: "quote", text: "", author: "" }),
View: QuoteBlockView,
Settings: QuoteBlockSettings,
};View is the WYSIWYG rendering, edited in place on the canvas; Settings is
the per-block panel (null when a block has nothing to configure, like the
divider). useEmailEditorTheme() gives you the same palette every built-in
block paints with, so a block of yours follows the host's theme for free.
Your block carries its own copy. The label is what the add-block menu
shows, and the strings inside your View are yours to write — the editor's
labels cover the editor's own chrome, not your block's. That is what makes a
block written in French sit correctly in a French editor.
3. Register it
import { createEmailBlocks, createEmailBlockRegistry } from "@voila.dev/ui/email-block-editor";
export const BLOCKS = [
...createEmailBlocks({ currency: "EUR" }),
quoteBlockDefinition,
];
export const REGISTRY = createEmailBlockRegistry(BLOCKS);Pass either to the editor:
<EmailBlockEditor blocks={BLOCKS} document={document} onDocumentChange={setDocument} />From here everything is derived. The menu lists the new block, the canvas
renders it, the reducer moves and duplicates it, it is offered inside grid
cells because it declares no container, and drag-and-drop needs no
configuration at all. createEmailBlockRegistry throws on two definitions
claiming the same type, so a collision is a startup error rather than a block
rendered by a definition that did not create it.
createEmailBlocks is just a list — drop the blocks you do not want, reorder
them, or build the array entirely yourself.
4. Keep your document type honest
Derive the union from the definitions rather than writing it twice:
import type { EmailEditorBlockOf, EmailEditorDocument } from "@voila.dev/ui/email-block-editor";
export type AppBlock = EmailEditorBlockOf<typeof BLOCKS>;
export type AppDocument = EmailEditorDocument<AppBlock>;If your document is persisted through a schema of your own, assert both directions once and the compiler tells you when the editor and the schema stop describing the same documents:
type Guards = [
AppBlock extends PersistedBlock ? true : never,
PersistedBlock extends AppBlock ? true : never,
];
const _guards: Guards = [true, true];5. A block that holds other blocks
Declaring container is what makes a block a container — the grid is one, and
nothing about it is special:
container: {
children: (block) => block.rows,
withChildren: (block, rows) => ({ ...block, rows }),
// Defaults to "anything that is not itself a container".
accepts: (type) => type !== "grid",
layout: "list",
},The reducer and the drag-and-drop layer consult this, so one level of nesting, drop targets, deep duplication and delete-with-children all work with no further wiring.
6. Teach your renderer
Your server-side switch over block.type now has an unhandled case, and if
you ended it with a satisfies never check, that is a compile error too. Add
the case "quote" that emits your email markup, and the round trip is
complete. See
Server-side rendering.