Block editor
A block canvas where React owns a JSON document, TipTap edits the text inside it, and email compiles through React Email.
A block canvas for composing email. The document is JSON that React owns, along with selection, reorder, the toolbar, and the settings panel. TipTap mounts only inside text and heading fields, for bold, italic, links, and variables when the host passes them. It is not a markdown editor, and the document is never markdown.
Block types plug in through BlockSpec. The email-blocks pack ships
section, header, text, heading, image, row, card, specs, signature, button,
divider, spacer, and footer, with no free-form HTML, and a pack of your own registers on
the same shell.
The edit canvas is the preview. Desktop (about 600px) and mobile (about 375px) resize that canvas, and Preview swaps it for the compiled React Email HTML, so there is no permanent editor and HTML split.
The canvas is the preview. Use Preview to see compiled React Email HTML, and Viewport for desktop (600px) vs mobile (375px). ↑↓ move selection. Alt↑ reorders. ⌘Z undoes.
The block canvas is yours
Gesso ships an email-first block editor — React owns the tree, TipTap only paints a text leaf. Read the contract.
Toggle Preview to inspect the compiled HTML. Copy compiled HTML is on the preview toolbar, and the document JSON stays behind Show document JSON.
Usage
import { BlockEditor, useBlockHistory } from '@/components/ui/block-editor';
import {
createEmailBlockRegistry,
createSampleEmailDocument,
serializeEmailHtml,
} from '@/components/ui/email-blocks';
const registry = createEmailBlockRegistry();
function Composer() {
const history = useBlockHistory(createSampleEmailDocument);
return (
<BlockEditor
document={history.document}
onDocumentChange={history.setDocument}
history={history}
registry={registry}
compilePreview={(next) =>
serializeEmailHtml(next, registry, { title: 'Welcome' })
}
/>
);
}
// Anywhere that holds a document, such as a send handler:
const html = await serializeEmailHtml(doc, registry);The editor is controlled. It never mutates in place, and every edit produces the next
BlockDocument and calls onDocumentChange, the same shape a mailer would persist. Without
compilePreview or previewHtml there is nothing for Preview to show.
Architecture
| Piece | Owns |
|---|---|
| BlockEditor | Canvas. Selection, keyboard reorder, toolbar, inspector, viewport, Preview mode. |
| BlockSpec | One type: label, icon, default props, View / Settings. |
| BlockRegistry | createBlockRegistry().register(specs). |
| TipTap leaf | RichTextField inside text / heading. Not the document model. |
| email-blocks | The constrained email pack, EmailDocument, and serializeEmailHtml. |
Preview and viewport
BlockEditor defaults to Edit. Pass compilePreview or previewHtml so
Preview can show the compiled letter. The viewport control is labelled
Viewport (aria-labelledby) and switches:
| Viewport | Frame |
|---|---|
| Desktop | 600px |
| Mobile | 375px |
The same width applies to the edit canvas and the compiled iframe, so the
canvas stays the primary preview. Switching Edit ↔ Preview moves focus to the
canvas or the preview region. Viewport width eases on --duration-base;
prefers-reduced-motion drops it.
Preview mode reads the real byte length of the compiled HTML off the string it
already has, so nothing renders twice. Pass previewSizeLimitBytes to show it
against a budget. email-blocks exports EMAIL_CLIP_BYTES (102 KB, where
Gmail starts hiding the rest behind "View entire message"). The shell itself
knows nothing about email, so the constant arrives as a prop. Over the budget, the size gains a
yellow warning icon and the text stays in text-fg.
The compile runs only in Preview, because nothing consumes it while you edit and it is a
full React Email render per change.
The preview frame takes the letter's height, so the page scrolls and the letter never sits
in a short box of its own. It is sandboxed with allow-same-origin and no allow-scripts, so
the editor can measure the letter and nothing in it runs.
canvasStyle paints the gutter behind the letter, in Edit and in Preview, and frameStyle
paints the letter column. Both are inline styles, so a host can pass its brand's page and
letter colors and the canvas matches the inbox.
The letter rounds at rounded-lg, the brand radius plus 4px. On the canvas that's the kit's
--radius-lg, and in the compiled letter it's EmailTheme.radius plus 4, so pass the same
brand radius to both. The default is 8, which rounds at 12. Outlook for Windows ignores the
corner and draws it square.
<BlockEditor
document={doc}
onDocumentChange={setDoc}
registry={registry}
defaultViewport="mobile"
compilePreview={(next) => serializeEmailHtml(next, registry)}
/>BlockSpec
The canvas never switches on a type name. It looks the spec up. Adding a block
kind is adding one BlockSpec and registering it.
import { createBlockRegistry } from '@/components/ui/block-editor';
import { emailBlockSpecs } from '@/components/ui/email-blocks';
// import { heroSpec, cardGridSpec } from './page-blocks'
const registry = createBlockRegistry()
.register(emailBlockSpecs);
// .register([heroSpec, cardGridSpec])BlockDocument.kind is "email" or "page". "page" is reserved for a pack of page
blocks, so one shell serves both. Unknown types render a stub and survive a JSON round trip,
so a newer pack can resolve them later.
A spec can also shape the canvas around it:
| Field | What it does |
|---|---|
outsideFrame | A root block with this flag draws below the letter card, on the page, the way an email footer prints. |
childLayout | "row" lays the block's children side by side instead of stacked. |
childWeight | (child, parent) => number sets each child's share of a row, as flex-grow. Without it the children split equally. |
View and Settings both get parent, the block this one sits in, so an image knows when
it's in a row.
BlockSpec.serialize is an escape hatch for a pack that wants to emit its own
HTML fragment. The email pack never calls it, because serializeEmailHtml compiles
through React Email primitives instead.
Inspector
View renders the block. Everything with a value is edited in the panel beside the
canvas, through Settings:
Settings?: React.FC<BlockSettingsContext<P>>
interface BlockSettingsContext<P> {
node: BlockNode<P>;
/** The block this one sits in, or undefined at the root. */
parent?: BlockNode;
/** What `validate` raised for this block. */
issues: readonly BlockIssue[];
updateProps: (props: Partial<P>) => void;
}A form on the canvas would be a poor fit for two reasons. The edit canvas is the preview, and a form inside a block stops it being one. The canvas is also the email's width, so at the mobile viewport a settings form would have to fit in 375px.
Settings receives the block's own issues, so a check surfaces twice from one
validate: as a count in the toolbar chip, and next to the field that fixes it.
Errors render through FieldError and mark the input aria-invalid; warnings
render as a description, because a decorative image is allowed an empty alt.
An email panel with more than one kind of input splits them into named groups, each a
FieldSet with a small FieldLegend and a FieldSeparator between them. A card reads as
Image, Copy, and Button, a signature as Image, Name and title, and Contact, and a section as
Fill and Spacing. A screen reader announces the group name as focus enters it, and each spec
table row is a group named for its row. A one-input panel, like a divider's color, stays a
single field.
With nothing selected and variables passed, the panel lists the fields the document uses,
grouped the way the list groups them, with how many blocks use each. Pointing at one rings its
chips on the canvas, and clicking it scrolls to the next one, so the list doubles as a map of
what a send has to fill. A key the list does not have sits last, under Not in the list. A
group of more than six, like a card's worth of units.1 fields, folds to its name and count, and
pointing at the fold rings everything under the path its keys share.
documentVariables returns the same list for a host that wants it elsewhere.
The panel sits beside the canvas above lg and stacks under it below that. It
is edit-mode only, and Preview gives the compiled iframe the full width. Replace it
with inspector, or drop it with hideInspector, the same way toolbar and
hideToolbar work. BlockInspector is exported if you would rather place it
yourself.
editable means only that a block owns an inline editing mode, which is a caret on the
canvas and not a form. In email-blocks that is text and heading alone, so editingId
narrows to "a rich text field has the caret", and every other block stays navigable while its
properties are being set.
Checks
BlockSpec.validate is pure, synchronous, and runs on every document change.
collectBlockIssues walks the tree, runs each spec's validator and stamps the
block id on what comes back. The toolbar chip reads worst-level-first and
clicking an issue selects the block and scrolls to it.
The chip is a Chip trigger over the issue list. While
there is something to fix it takes the outline ring in the worst issue's tone, red for an
error and yellow for a warning, with the icon in that color and the count in the ink. With
nothing to fix it goes quiet, a ghost chip with a green check.
validate?: (
node: BlockNode<P>,
ctx: BlockValidateContext,
) => readonly BlockIssueInput[] | undefinedIssues from outside the tree reach the same chip through extraIssues, which
is how the email composer folds subject and preview-text
checks in beside the block ones. Memoize that array, because it feeds a context value.
Validation is advisory. The compile is a security boundary and still coerces
whatever it is handed, because EmailDocument is a public export that a send
path may render on a document nobody ran checks over. The two share one
predicate: isSafeUrl both drops an unsafe image at compile time and raises
image.src.unsafe in the editor.
| Code | Level |
|---|---|
image.src.missing · image.src.unsafe | error |
image.alt.missing · image.alt.filename | warning |
row.empty · row.single · row.match.unsized | warning |
button.href.missing · button.href.unsafe · button.href.empty-host | error |
button.label.missing | warning |
heading.content.empty · text.content.empty | warning |
header.src.unsafe | error |
header.alt.missing · header.alt.filename · header.empty | warning |
specs.empty | warning |
card.src.unsafe | error |
card.href.unsafe · card.href.empty-host | error |
card.alt.missing · card.alt.filename · card.empty · card.href.missing · card.label.missing | warning |
signature.src.unsafe | error |
signature.alt.missing · signature.alt.filename · signature.empty | warning |
footer.unsubscribe.unsafe · footer.preferences.unsafe | error |
footer.unsubscribe.missing | warning |
variable.unknown:{key} · variable.unsupported:{token} | error |
button.href.empty-host catches a bare https://, which is what a fresh
button starts with and what isSafeUrl accepts. The two variable checks only run
when the host passes variables; the next section covers them.
Variables
Pass variables and authors can put merge fields in the letter. Each one is a key the
send path merges, a label people read, and optionally a group, a shorter label for under its
group's heading, a sample value, and a short note for the picker. Leave variables out and the editor behaves as before, with {{…}} staying plain
text.
interface BlockVariable {
key: string; // "rep.name", saved as {{rep.name}}
label: string; // "Rep name", what the chip shows
shortLabel?: string; // "Name", what a list shows under the "Rep" heading
group?: string; // "Rep", a heading in the picker
example?: string; // "$42,000", a sample shown in the picker
detail?: string; // "1,240 contacts", a note beside the row
value?: string; // "Toyota", shown on the canvas in place of the chip
format?: "currency" | "number";
}A row in the lists shows its label, and on the right its detail, or else its example.
It never shows the key, which is for the send path rather than the people writing.
detail is where a host says something about the field rather than its value, like how
many contacts have it.
shortLabel is what a list shows under the group's heading, so a Rep group reads Name, Phone,
and Email while the chips in the letter still say Rep name and Rep phone. The panel of what the
letter fills in uses it too, since its rows sit under the same headings. Pointing at a chip shows
its label.
Pass variableGroupIcons to put an icon beside each group's heading, by group name, like
{ Rep: IdentificationBadge }. A group without one gets none. Memoize it, since it feeds a
context value.
value is what the variable fills with in this letter, like "Toyota" for unit.make. The
canvas shows it as plain text with a dotted underline instead of a chip, and a picture slot
whose source is the variable shows that picture. The settings panel and the lists still show the
label, and the saved letter keeps the token. It's for a copy where the host already knows its
values, so the canvas reads like what will send.
format is written into the token when that variable is inserted from the picker.
currency prints whole dollars, so 23480 becomes $23,480. number adds thousands
separators, so 1240 becomes 1,240. A bare {{key}} stays raw, and a pipe the list
does not know, like {{name | there}}, is still an unsupported tag.
The compile keeps the token. The send path fills it with applyVariableFormats, which
replaces both {{key}} and {{key | currency}}. A merge that only looks for {{key}}
will leave the formatted token as written.
import { applyVariableFormats } from '@/components/ui/block-editor/variables-model';
applyVariableFormats(html, { 'quote.totalUsd': 23480, 'unit.hours': 1240 });variables-model is a plain module, so a send path on the server imports from it, as
On a server covers.
Santana Equipment
Rep phone
Hi First name
Rep name here. Quote Quote number for Company name comes to Quote totalcurrency and is good through Good through. Call me at {{rep.phne}} with questions. {{#tradeIn}}We can take your trade-in.{{/tradeIn}}
| Where | How it works |
|---|---|
| Text and headings | A variable is a chip showing its label. It moves as one unit: one Backspace deletes it and the arrow keys step over it. |
| Inserting | Insert variable (the braces) in the text toolbar opens a searchable list grouped by group. Typing {{ opens the same list at the caret, filtered as you type; Enter or Tab picks, Esc leaves the braces as typed. Every word typed has to start a word of a variable's name, group, or key, so unit 1 make finds Unit 1 · Make, and a number matches whole, so 1 doesn't find Unit 10. |
| Typing and pasting | A finished {{ key }}, typed or pasted, turns into a chip. So does one already in the document when it loads. |
| Creating | With onCreateVariable, both lists end with New variable and what was typed when no variable has that name. Picking it, or Enter on it, creates the variable and puts it in as a chip. |
| Block fields | A block's own fields in the inspector, like a card title, a button label and URL, an image source, or alt text, show variables as chips too. Each is one line: typing {{ opens the list, and the braces button shows only while the field is in use. A URL may start with a variable, like {{quote.url}} or {{quote.url}}?ref=email, as long as nothing after it carries a scheme, since a variable can fill in empty. |
| On the canvas | A block's plain text, like a header, a card, a signature, a button label, or a spec row, draws its variables as chips. A chip on the canvas takes the color of the text around it, so it reads on a dark button and on a light page in either theme. |
| The saved document | A chip is a variable node in the rich-text JSON, { "type": "variable", "attrs": { "key": "rep.name" } }. A formatted one adds "format": "currency" or "format": "number", and the compile writes the token with that format. |
Pass onCreateVariable to let authors make a variable where they write, instead of in a
settings page first. It gets the name as typed, and resolves with the new variable once
your variables include it. Reject with an Error to show its message under the list,
like a name that's taken. The demo above keeps its list in state, so {{ and a new name
make one.
The host can ask for more before it makes one, like the variable's type, in a dialog of its
own. The chip still lands where {{ was typed after the list closes, and resolving with
null when the person cancels leaves the text as it was, with no error. Resolving with a
string writes that text in place of the {{ and the name, for a person who decides the words
should stay words, like a one-off. The braces button on a URL field just closes its list.
const [variables, setVariables] = React.useState(initialVariables);
<BlockEditor
variables={variables}
onCreateVariable={async (label) => {
const variable = await saveVariable(label); // your API: picks the key, checks the name
setVariables((current) => [...current, variable]);
return variable;
}}
/>;People writing a letter may not know the word variable. Pass variableNoun with the word
your product uses, like { one: "detail", other: "details" }, and the insert buttons, the search
field, the New option at the end of the lists, and the panel of what the letter fills in all
say it instead. It defaults to variable and variables.
The grammar matches a {{ key }} merge: letters, digits, and underscores, dot paths, and
spaces inside the braces. {{ key | currency }} and {{ key | number }} are the two
formats. With a list to check against, the editor raises two errors in the
issues panel:
- Unknown key, like a typo in
{{frist_name}}. A merge fills an unknown key with nothing, so it sends empty. - Unsupported tag, like a section
{{#tradeIn}}…{{/tradeIn}}or any pipe other thancurrencyandnumber. A merge leaves these alone, so they reach inboxes as written.
Pass every key your send path fills, including ones it adds itself, like an unsubscribe link. Anything missing from the list reads as unknown.
A block's fields are strings with {{key}} in them, so a custom block gets the same chips
from two exports. VariableInput is the one-line field for its settings, and VariableText
draws a string's variables as chips in its View. Both fall back to plain text when the
host passes no variables.
Send path. A URL that is a variable puts recipient data in an href. The compile keeps
{{quote.url}} as written, so the merge that fills it should check the value it puts there
is a web, mail, or phone link, and drop anything else, like a javascript: value from an
uploaded list.
Pictures
Every image in a letter is picked with Image field, so it
shows in the inspector as what it is, not as a link: the image in its block's shape with its name,
Replace, and remove, or a place to drop, paste, or choose a file, the recent images, and a link.
The header's logo is a logo, the card's image a card, and the signature's photo round, and
the image block and an image row's images are wide. Inside the editor it's BlockImageField,
which passes the host's variables as fields, so the link box reads Use a link or field and a
picked variable shows as a chip that fills in at send. Picking an image while its alt text is empty
fills the alt from the image's name, so santana-logo.png gives "Santana logo", unless the name says
nothing, like IMG_2034.jpg. Alt text that reads like a file name gets a warning. The demo above
has a library to pick from.
Pass imageLibrary for the upload and the recent images. It's Image field's ImageLibrary, and
BlockImageLibrary is the same type under the editor's name, so the host stores the files the
same way: upload saves one and resolves to it, and an image just uploaded joins the front of
assets for the session. Leave imageLibrary out and an image takes a link or a variable.
const imageLibrary = React.useMemo<BlockImageLibrary>(
() => ({
assets: recent.map((file) => ({ url: file.publicUrl, name: file.fileName })),
upload: async (file) => {
const saved = await saveAsset(file);
return { url: saved.publicUrl, name: saved.fileName };
},
uploadHint: 'JPEG, PNG, GIF, or WebP up to 4 MB.',
}),
[recent],
);
<BlockEditor imageLibrary={imageLibrary} />;On the canvas, a picture that is a variable, like {{unit.imageUrl}}, draws a placeholder
named for the variable instead of loading the token as an address, and an empty image block
asks for a picture. Both paint in the letter's theme. BlockImageField and PicturePlaceholder
give a custom block the same field and the same placeholder.
Templates and copies
A template is a letter people send again with a few things changed. Pass fill to fill in a
copy of one: only the blocks the template leaves open can be picked and changed, and no block
is added, moved, or removed. Pass the template as baseDocument too, and a changed block says
so in its settings, with Reset to put it back. The issues chip counts only the blocks the copy
can change, plus extraIssues, since a locked block's problem is the template's to fix.
The block canvas is yours
Gesso ships an email-first block editor — React owns the tree, TipTap only paints a text leaf. Read the contract.
<BlockEditor
document={copy}
onDocumentChange={setCopy}
registry={registry}
fill
baseDocument={template}
/>Each spec sets a default with fill, and a template can change it block by block.
fillControls puts a Fixed or Editable choice beside the block's name in its settings while
authoring, with a line under it saying what the choice means. Pass labels to name it for your
product, where label names the choice for assistive tech. A block's own choice is stored on its node as fill: "open" or fill: "locked", and
only when it differs from its spec's default.
<BlockEditor
fillControls={{
label: "In campaigns",
fixed: "Campaigns send it exactly as written here.",
editable: "Each campaign can change it. What's here is where it starts.",
}}
/>| Email blocks | In a copy |
|---|---|
| Image, card, button, stat, pull quote | Open |
| Text, heading, header, spec table, signature, divider, spacer, section, image row | Locked |
| Footer | Always locked, with no choice (fill: "fixed") |
Every block a copy can change shows a dashed outline, so the parts to fill in read at a glance and the rest reads as the letter. Hovering one makes the outline solid.
A locked block still fills its variables at send, so a chip in fixed copy reads each contact's
value. In an open block, the person filling in can type over a variable or insert one. Outside
the editor, isBlockOpen(node, spec) answers whether a copy can change a block, setBlockFill
sets it, and isBlockChanged and resetBlockProps compare a copy with its template.
Hosting the toolbar
The toolbar can live outside the editor, like in a page header's second row, and still work the editor.
Quote follow-up
Santana Equipment
Rep phone
Hi First name
Rep name here. Quote Quote number for Company name comes to Quote totalcurrency and is good through Good through. Call me at {{rep.phne}} with questions. {{#tradeIn}}We can take your trade-in.{{/tradeIn}}
const [row, setRow] = React.useState<HTMLDivElement | null>(null);
<div ref={setRow} />
<BlockEditor
toolbarContainer={row}
toolbar={<BlockEditorToolbar variant="bare" hideBlockActions />}
…
/>| Prop | What it does |
|---|---|
toolbar | Replaces the default toolbar. Render BlockEditorToolbar here to set its options. |
toolbarContainer | Renders the toolbar, default or yours, into that element instead of above the canvas. It still drives this editor. While the element is null, nothing renders. |
hideToolbar | Renders no toolbar, and wins over the other two. |
inspectorContainer | Renders the settings panel, default or inspector, into that element instead of beside the canvas. A host can keep its own form there and show the panel only while a block is selected. |
backLabel | On BlockInspector: a back arrow before the block's name that clears the selection, with this as its tooltip. For a host that shows the panel in place of its own form. |
children | On BlockInspector: the host's own fields for the selected block, between its name and its settings. They render as given, so give them the panel's p-3. |
highlightedVariable | Rings that variable's chips and picture slots on the canvas, like the one whose field has focus in the host's form. A path, like units.1, rings every variable under it, such as a whole unit card. |
variant="bare" | On BlockEditorToolbar: no border, fill, or padding, and one line that scrolls sideways. When it leads with Undo or Copy, the glyph lands on the container's edge. |
hideBlockActions | On BlockEditorToolbar: hides Add, Move, Edit, and Delete, leaving undo, issues, viewport, and mode. Blocks are still added and moved on the canvas. |
Undo and redo
The editor stays controlled, so history lives outside it. useBlockHistory
owns the document and hands back setDocument for edits and reset for the
things that should not be undoable, like loading a draft, pasting JSON, or resetting a
sample.
const history = useBlockHistory(createSampleEmailDocument);
<BlockEditor
document={history.document}
onDocumentChange={history.setDocument}
history={history}
registry={registry}
/>Changes closer together than coalesceMs (500 by default) collapse into one
step, so a burst of typing undoes as one and not one keystroke at a time.
Inside a focused rich-text field ⌘Z is TipTap's and only reaches that field.
Outside one it is the document's. The listener sits on the editor root rather
than the canvas, because after a toolbar click focus is not in the canvas, and
rather than window, because that would take ⌘Z away from a host's own
inputs.
Email blocks
| Type | Placement | Settings | Inline |
|---|---|---|---|
section | root | Background swatches, padding presets, gap presets | None |
header | child | Logo, alt, name, detail | None |
text | child | None | TipTap leaf: bold, italic, link |
heading | child | Level 1–3 | The same rich-text leaf |
image | child | Image, alt, width | None |
row | child | Fit, crop shape, gap presets. Holds images side by side | None |
card | child | Image, eyebrow, title, description, meta, button | None |
specs | child | Rows of label and value | None |
stat | child | Number, label, align | None |
quote | child | Quote, who said it | None |
signature | child | Photo, name, title, phone, email | None |
button | child | Label, URL, align | None |
divider | child | Style (line, ornament, dots), color swatches | None |
spacer | child | Height presets | None |
footer | root | Line, unsubscribe URL, preferences link | None |
A new section has no background of its own, so it takes the letter's color: the theme's
containerBackground in the compile, and whatever frameStyle paints on the canvas. Set
Background to paint one section differently.
Header is the letterhead, and it sits in a section with the rest of the letter. A logo or the name is on the left, a short line such as a phone number on the right, and a 2px rule in the strong border color runs under that row. There is no color bar on the letter.
Card is a picture beside an eyebrow, a title, a description, a meta line, and a button. The
picture is 144px wide and 128px tall, on the sunken surface, and the copy sits in 16px of
padding inside the same rounded-lg corner as the letter. That is the horizontal frame of
Interactive card. Leave a field empty and
that line is omitted. The button is secondary: a neutral fill and the ink, sized to
sit beside the meta line. It needs a label and a safe URL, or the compile drops it. The letter
does not lift, zoom, or reveal.
On a phone the picture stacks over the copy, full width at 16:10, like Interactive card. The
letter carries one small <style> for this, only when it has a card, and it applies under a
480px screen (EMAIL_PHONE_WIDTH). A client that drops the style keeps the picture beside the
copy. The canvas stacks when the letter frame is under 480px, so the Mobile viewport shows it.
Spec table is a label and a value on each row. Every other row, starting with the first, sits on the sunken surface. The value can hold a merge field, including a formatted one.
Signature is a round photo beside the name, title, phone, and email. The sign-off above it, such as "Cheers,", stays an ordinary text block.
Footer prints below the letter, on the page background, after the other blocks, and Edit
and Preview both show it below the letter card. It carries Unsubscribe. Turn on Email preferences
to add that link beside it. Leave the preferences URL empty and the link is omitted. A section
with outside: true compiles the same way, with no fill of its own, so a host can expand its
own footer into one before it compiles.
An image row shares its section's width between its images, and Fit decides the shares:
| Fit | Each column's share | The images |
|---|---|---|
| Match, the default | The photo's own shape | Keep their shape and end flush. A photo the editor hasn't measured yet counts as 4:3, so equal shapes stay equal. |
| Crop | The image's width | Crop to one height, an average column at the crop shape: 1:1, 4:3, 3:2, or 16:9. |
| Free | The image's width | Keep their shape. |
An image records naturalWidth and naturalHeight when it loads in the editor, and changing
its source clears them. In a Match row the inspector hides the width field and says the row is
sizing from the photo. A row of two 1200×800 and 800×1200 photos in a 536px section with a 16px
gap compiles at 360px and 160px, flush at 240px tall.
The images stay side by side on phones and shrink with the letter. Every column gives up the
same padding, so equal shares stay equal. The canvas lays the row out with the same weights
through childWeight, so it matches the sent HTML.
javascript: and data: URLs are dropped at compile time whatever the panel says, as
Checks describes.
Spacing presets
Section padding, section gap, and spacer height pick from a preset row instead of a bare
field. The presets live in theme.ts beside the style functions both renderers call, so
there is one scale.
| Setting | Presets | Stores |
|---|---|---|
| Section padding | 0, S 16px 24px, M 24px 32px, L 32px 40px, XL 48px 56px, Custom | the CSS padding string |
| Section gap | 0, S 8, M 16, L 24, XL 32 | px after each block |
| Image row gap | 0, S 8, M 16, L 24, XL 32 | px between images |
| Spacer height | 8, 16, 24, 32, 48, 64, Custom | px |
Every gap and padding sits on a 4px grid. emailSectionPadding, emailSectionGap, and
emailSpacerHeight snap each length to the nearest multiple of 4, and both renderers call
them, so an off-grid value can never draw. Custom padding is two number fields, Top and
bottom and Sides, in px. Custom fields take whatever you type and snap on blur, so 13 and
30 store as 12px 32px. A padding saved with three or four values shows its top and its right,
and an edit writes two. A stored padding token that is not px or unitless, like 1em, is
dropped.
The document stores the value, not the preset name, so an older document keeps working
and a value that matches no preset opens on Custom with its field showing. Gap is the
space after each block in the section, and it defaults to 16, so a document without one
compiles the same as one that sets 16. Headings keep their
tighter 12px so they stay with the text they introduce, and paragraphs inside a text
block keep the 16px body rhythm. The canvas spaces the section's blocks 4px under the
gap, which puts the default back on its usual gap-3.
Inserting a content block with nothing selected wraps it in a new section. The
+ between two blocks offers only what that position accepts, which is section at
the root and content blocks inside one.
Envelope
Subject and preview text are part of the email, so they live on
BlockDocument.meta and round-trip through its JSON rather than being passed at
send time. meta is untyped at the shell, each pack brings its own reader, and
readEmailEnvelope / writeEmailEnvelope are the email pack's.
EmailDocument layers defaults, then the document's brand, then its envelope,
then any explicit options, so serializeEmailHtml(doc, registry) picks up the subject as <title>
and the preview text as React Email's Preview with no extra arguments.
Brand moments
A few blocks carry a brand past its logo and photos, in the letter's accent. A stat is one
number that carries the point, with a short line under it. It sets at 40px, or at 28px with
size: "medium" so a longer value, like a price range, stays on one line on a phone. A pull quote is someone else's
words under a large mark, then who said them, for a testimonial. The divider takes a style:
a line, an ornament that breaks the rule with a diamond in the accent, or a row of dots
in the muted ink. Each one compiles to table cells and borders, so Gmail and Outlook lay it out
the same way. The diamond is a square with softened corners turned on its point, and Outlook on
Windows, which turns and rounds nothing, gets a diamond glyph in its place.
The accent is the brand's accentColor, and the button color when there is none. A brand with a
dark button can set a brighter accent, like an amber, without changing its buttons.
A section's background and a divider's color are swatches from the letter's brand, not a hex field. A section offers the letter's color, the page, the sunken stripe, and a tint of the accent, and a divider offers its default rule, muted, the accent, and ink. A divider's color paints its rule, its diamond, or its dots, and without one the dots stay muted. The last swatch, an eyedropper, opens the color picker for anything else, and takes on a custom color once one is set. The value saved is the hex, so a letter keeps its colors when the brand changes later, and a hex that matches a swatch selects it.
Your fleet review is ready
We looked at every truck on your floor. One number stood out.
24%
of replacement cost went to repairs this year
They had a truck on our dock in under two hours. Nobody else came close.
Dana Ruiz, Harbor Freight Lines
• • •
Brand
A letter can carry the brand it was written in on meta.brand, so a later change to the
tenant's settings doesn't restyle a saved letter. EmailBrand is the background, letter,
text, link, button, and button-label colors as #rrggbb, and the body and heading font stacks,
trimmed and at most 120 characters. It can also carry radius, the brand radius in px from 0
to 32, and accentColor for the brand moments, and a snapshot without them still reads.
import {
emailThemeFromBrand,
readEmailBrand,
writeEmailBrand,
} from '@/components/ui/email-blocks';
const saved = writeEmailBrand(doc, tenantBrand);
readEmailBrand(saved); // the brand, or undefined when meta.brand is missing or malformed
emailThemeFromBrand(readEmailBrand(saved)); // the defaults with the brand spread over themEmailDocument resolves its theme as defaults, then the brand, then the envelope's subject
and preview text, then explicit options. A document without a brand compiles the defaults.
A theme's logoUrl is the logo a header shows when it has none of its own. It isn't part of the
brand snapshot, so a letter never saves it. Pass it when you compile and to EmailThemeProvider,
and a change to the logo reaches every letter whose header leaves its own empty. The header's
settings say so while it does.
serializeEmailHtml(doc, registry, { logoUrl: tenant.logoUrl });The canvas paints its blocks from the defaults unless you give it a theme. Wrap the editor in
EmailThemeProvider with the theme the letter compiles with, and text, headings, buttons, the
header, the spec table, the card, the signature, and the footer draw in it, so a tenant's fonts,
inks, and button colors show while you write. The provider paints blocks only, so pass the
page and letter colors to canvasStyle and frameStyle as well.
import {
EmailThemeProvider,
emailThemeFromBrand,
readEmailBrand,
} from '@/components/ui/email-blocks';
const theme = emailThemeFromBrand(readEmailBrand(doc));
<EmailThemeProvider theme={theme}>
<BlockEditor
document={doc}
onDocumentChange={setDoc}
registry={registry}
canvasStyle={{ backgroundColor: theme.backgroundColor }}
frameStyle={{ backgroundColor: theme.containerBackground }}
compilePreview={(next) => serializeEmailHtml(next, registry)}
/>
</EmailThemeProvider>A block of your own reads the same theme with useEmailTheme(), which returns the defaults
outside a provider.
EmailInboxPreview renders that envelope as a row between two neighbours at
either viewport, which is the only way preview-text truncation is legible while
you write it.
JSON document
interface BlockDocument {
version: 1;
kind: 'email' | 'page';
/** Kind-specific. The shell never reads it; each pack brings its own reader. */
meta?: Record<string, unknown>;
children: BlockNode[];
}
interface BlockNode {
id: string;
type: string;
props: Record<string, unknown>;
children?: BlockNode[];
}Rich text is a TipTap-shaped subset (doc → paragraph → text + bold /
italic / link). The compile walks that into React Email Text / Heading
/ Link, not HTML strings from the field.
React Email compile
Canonical HTML is React Email 6 (react-email). serializeEmailHtml is a thin
render(<EmailDocument document={doc} />) wrapper. Table-safe layout and
inline styles come from those primitives, not a second hand-rolled serializer.
A send path can import EmailDocument and call render itself:
import { render } from 'react-email';
import { EmailDocument } from '@/components/ui/email-blocks/email-document';
const html = await render(
<EmailDocument
document={doc}
options={{ title: 'Welcome', previewText: 'You are in' }}
/>,
);| Block | Primitive |
|---|---|
| document | Html, Head, Preview, Body, Container |
section | Section |
text | Text (+ Link for marks) |
heading | Heading (h1–h3) |
image | Img |
row | Row with a Column per image |
button | Button |
divider | Hr |
spacer | Section with height |
A Crop row crops with CSS by default, a fixed height and object-fit: cover. Outlook for
Windows ignores that, so a host that can serve real crops passes imageSrc to
serializeEmailHtml or EmailDocument. It gets the source and the size, and returns a cropped
URL that scales like any image, or undefined to fall back to CSS.
const html = await serializeEmailHtml(doc, registry, {
imageSrc: (src, { width, height }) => cropUrl(src, width * 2, height * 2),
});serializeEmailHtml is async. Unknown block types are omitted from the letter, and they
still show as a stub on the canvas.
On a server
The compile runs on a server, in a route handler, a server action, or a test, as long as it is
imported from its own files rather than the package entries, which also carry the client
editor. email-blocks/serialize and email-blocks/email-document reach no client module, and
serializeEmailHtml does not need the registry, so a send path never builds one.
import { applyVariableFormats } from '@/components/ui/block-editor/variables-model';
import { serializeEmailHtml } from '@/components/ui/email-blocks/serialize';
const html = applyVariableFormats(await serializeEmailHtml(doc), recipient);The pure helpers live in two plain modules beside the editor. rich-text-model has
emptyRichText, isRichTextDoc, toRichTextDoc, inlineText, richTextPlain, and
withVariableNodes. variables-model has the variable grammar and formats: VARIABLE_TOKEN,
variableToken, splitVariableTokens, startsWithVariable, variableIssues,
formatMergeValue, applyVariableFormats, and VARIABLE_FORMATS. @/components/ui/block-editor
still exports all of them for client code. email-document.tsx imports React itself, so a
host that compiles it with the classic JSX transform, like tsx --test, needs nothing else in
scope.
Accessibility
role="tree"sits on the email frame, not the scroll container around it, so the elements the tree owns really are itstreeitemchildren.aria-activedescendantpoints at the selected block, and the frame is the single tab stop.- The empty state renders outside the tree. A heading and an Add button are not treeitems, and an empty document has no tree at all.
- Toolbar controls expose their label. The bar is
role="group"and deliberately notrole="toolbar", because that pattern is one tab stop with arrow navigation and every control here is independently tabbable and the nested toggle groups run their own roving focus. - The viewport group is labelled Viewport, and the Desktop and Mobile items include the
pixel width in
aria-label. The editor mode group is labelled "Editor mode". - Switching Edit ↔ Preview focuses the canvas or the compiled preview region.
- Every input in an email block's panel is named by its visible label through
htmlFor, so a click on the label focuses it. An error marks the inputaria-invalid. The panel sits on the raised surface so the inputs keep their fill. - The inspector is a labelled
asideand an ordinary tab stop after the canvas. Selecting a block never moves focus into it:aria-activedescendantstays on the tree, and pulling DOM focus away would fight its single tab stop. - Keyboard in Edit mode:
↑↓orjkselect,Enteredits a text or heading block,Escexits edit or clears the selection,Alt+↑Alt+↓reorder,⌘Dduplicates, andBackspaceorDeleteremoves.⌘Zand⇧⌘Zundo and redo whenhistoryis wired. - Selecting a block reveals a rail in the gutter beside the frame and a
+above and below it. Both appear on selection only, never on hover. The+carries the selection ring's own color so it reads as part of the outline, and the rail takes theborder-stronghairline that Popover and the insert menu use. - The rail is one element for the selected block, parked outside the frame rather than over the letter.
- The rail and the
+are pointer-only:aria-hiddenwith every controltabIndex={-1}, and the rail lives outside the tree so the tree still owns nothing but treeitems. A tabbable control in either would pull DOM focus off the canvas and strandaria-activedescendant. Every rail action has a keyboard path in the list above, and the toolbar Add menu is the keyboard equivalent of the+. - The issue chip carries the count and breakdown in
aria-labeland no live region, because the count changes on every keystroke and opening the popover is the moment worth announcing. Dismissing it returns focus to the canvas. - Selected block: 2px
:focus-visiblering on the canvas,ring-ringon the item,outlineunder forced-colors. - Selection chrome and the viewport frame ease on token durations;
motion-reducedrops them. - TipTap mounts only while a text / heading block is in edit.
Escreturns focus to the canvas. - Filling in a copy, a block the copy can't change is
aria-disabled, the arrow keys skip it, and the pointer passes over it. The template's Fixed or Editable choice is a radio group named bylabel, beside the block's name in its settings. - Every image is picked with Image field, so its keyboard and focus work as on Image field.
- The variable lists are listboxes the typing drives: focus stays in the text or search
field, and
aria-activedescendantnames the active option. Create is the last option. While it saves the list isaria-busy, and a failure shows in an alert under it. - Known gap:
→and←expand and collapse are not implemented, andaria-expandedis alwaystrueon a block with children.
When to use something else
- Markdown editor when the document is a markdown file. Its saved output is the file itself, where the Block editor saves a JSON tree and compiles to HTML.
- Textarea for one field of plain text, like a comment or a subject line, where there are no blocks to arrange and no formatting to keep.
- Email blocks against a pack of your own: install
email-blocksfor email, with its constrained blocks, the compile, and the envelope. Register your ownBlockSpecpack when the blocks are something else, such as page sections, and leaveemail-blocksout of the registry.
Install
shadcn add @gesso/block-editor
shadcn add @gesso/email-blocksThe Block editor is the canvas and email-blocks is the email pack, so install the second
only when you compose email. It brings react-email for the compile.