Gesso
Editors

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.

Viewport

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.

Studio

Paper samples

Three weights, one ink.

Updated today

Read the note

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

PieceOwns
BlockEditorCanvas. Selection, keyboard reorder, toolbar, inspector, viewport, Preview mode.
BlockSpecOne type: label, icon, default props, View / Settings.
BlockRegistrycreateBlockRegistry().register(specs).
TipTap leafRichTextField inside text / heading. Not the document model.
email-blocksThe 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:

ViewportFrame
Desktop600px
Mobile375px

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:

FieldWhat it does
outsideFrameA 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[] | undefined

Issues 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.

CodeLevel
image.src.missing · image.src.unsafeerror
image.alt.missing · image.alt.filenamewarning
row.empty · row.single · row.match.unsizedwarning
button.href.missing · button.href.unsafe · button.href.empty-hosterror
button.label.missingwarning
heading.content.empty · text.content.emptywarning
header.src.unsafeerror
header.alt.missing · header.alt.filename · header.emptywarning
specs.emptywarning
card.src.unsafeerror
card.href.unsafe · card.href.empty-hosterror
card.alt.missing · card.alt.filename · card.empty · card.href.missing · card.label.missingwarning
signature.src.unsafeerror
signature.alt.missing · signature.alt.filename · signature.emptywarning
footer.unsubscribe.unsafe · footer.preferences.unsafeerror
footer.unsubscribe.missingwarning
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.

Viewport

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}}

CapacityCapacitynumber lb
Hour meterHoursnumber
QuoteQuote totalcurrency
Marco Diaz

Rep name

Territory manager

Rep phoneRep email

WhereHow it works
Text and headingsA variable is a chip showing its label. It moves as one unit: one Backspace deletes it and the arrow keys step over it.
InsertingInsert 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 pastingA finished {{ key }}, typed or pasted, turns into a chip. So does one already in the document when it loads.
CreatingWith 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 fieldsA 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 canvasA 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 documentA 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 than currency and number. 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.

Pick a block to set whether campaigns keep it fixed or can edit it.
Viewport

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.

Studio

Paper samples

Three weights, one ink.

Updated today

Read the note
<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 blocksIn a copy
Image, card, button, stat, pull quoteOpen
Text, heading, header, spec table, signature, divider, spacer, section, image rowLocked
FooterAlways 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

Template

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}}

CapacityCapacitynumber lb
Hour meterHoursnumber
QuoteQuote totalcurrency
Marco Diaz

Rep name

Territory manager

Rep phoneRep email

const [row, setRow] = React.useState<HTMLDivElement | null>(null);

<div ref={setRow} />
<BlockEditor
  toolbarContainer={row}
  toolbar={<BlockEditorToolbar variant="bare" hideBlockActions />}
  …
/>
PropWhat it does
toolbarReplaces the default toolbar. Render BlockEditorToolbar here to set its options.
toolbarContainerRenders 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.
hideToolbarRenders no toolbar, and wins over the other two.
inspectorContainerRenders 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.
backLabelOn 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.
childrenOn 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.
highlightedVariableRings 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.
hideBlockActionsOn 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

TypePlacementSettingsInline
sectionrootBackground swatches, padding presets, gap presetsNone
headerchildLogo, alt, name, detailNone
textchildNoneTipTap leaf: bold, italic, link
headingchildLevel 1–3The same rich-text leaf
imagechildImage, alt, widthNone
rowchildFit, crop shape, gap presets. Holds images side by sideNone
cardchildImage, eyebrow, title, description, meta, buttonNone
specschildRows of label and valueNone
statchildNumber, label, alignNone
quotechildQuote, who said itNone
signaturechildPhoto, name, title, phone, emailNone
buttonchildLabel, URL, alignNone
dividerchildStyle (line, ornament, dots), color swatchesNone
spacerchildHeight presetsNone
footerrootLine, unsubscribe URL, preferences linkNone

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:

FitEach column's shareThe images
Match, the defaultThe photo's own shapeKeep their shape and end flush. A photo the editor hasn't measured yet counts as 4:3, so equal shapes stay equal.
CropThe image's widthCrop to one height, an average column at the crop shape: 1:1, 4:3, 3:2, or 16:9.
FreeThe image's widthKeep 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.

SettingPresetsStores
Section padding0, S 16px 24px, M 24px 32px, L 32px 40px, XL 48px 56px, Customthe CSS padding string
Section gap0, S 8, M 16, L 24, XL 32px after each block
Image row gap0, S 8, M 16, L 24, XL 32px between images
Spacer height8, 16, 24, 32, 48, 64, Custompx

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.

Accent
Viewport

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 them

EmailDocument 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' }}
  />,
);
BlockPrimitive
documentHtml, Head, Preview, Body, Container
sectionSection
textText (+ Link for marks)
headingHeading (h1–h3)
imageImg
rowRow with a Column per image
buttonButton
dividerHr
spacerSection 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 its treeitem children. aria-activedescendant points 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 not role="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 input aria-invalid. The panel sits on the raised surface so the inputs keep their fill.
  • The inspector is a labelled aside and an ordinary tab stop after the canvas. Selecting a block never moves focus into it: aria-activedescendant stays on the tree, and pulling DOM focus away would fight its single tab stop.
  • Keyboard in Edit mode: ↑ ↓ or j k select, Enter edits a text or heading block, Esc exits edit or clears the selection, Alt+↑ Alt+↓ reorder, ⌘D duplicates, and Backspace or Delete removes. ⌘Z and ⇧⌘Z undo and redo when history is 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 the border-strong hairline 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-hidden with every control tabIndex={-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 strand aria-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-label and 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-visible ring on the canvas, ring-ring on the item, outline under forced-colors.
  • Selection chrome and the viewport frame ease on token durations; motion-reduce drops them.
  • TipTap mounts only while a text / heading block is in edit. Esc returns 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 by label, 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-activedescendant names the active option. Create is the last option. While it saves the list is aria-busy, and a failure shows in an alert under it.
  • Known gap: → and ← expand and collapse are not implemented, and aria-expanded is always true on 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-blocks for email, with its constrained blocks, the compile, and the envelope. Register your own BlockSpec pack when the blocks are something else, such as page sections, and leave email-blocks out of the registry.

Install

shadcn add @gesso/block-editor
shadcn add @gesso/email-blocks

The 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.

On this page