Custom blocks

Add your own block type and let the editor pick it up everywhere.

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.

Quick start · Server-side rendering · Theming the canvas