The features other editors gate. In your repo. MIT-licensed.
Six power features and a clean set of essentials — all included, all open. Portable JSON in, MJML out, no usage tier in the way.
Custom blocks with API-backed data
Register your own block types — static templates or live data fetched from your API at preview time. Built in, not bolted on.
Ship CRM-aware blocks your team drops in without engineering tickets.
- Per-field config: text, image, color, select, repeatable arrays
- Static template or live API fetch at preview time
- Liquid templates with conditionals and built-in filters
- Type-safe block factories with full TypeScript types
const editor = await init({
container: '#editor',
customBlocks: [
{
type: 'event-details',
name: 'Event Details',
description: 'Date, time, location, and a map link',
fields: [
{ type: 'text', key: 'eventName', label: 'Event Name', required: true },
{ type: 'text', key: 'date', label: 'Date', default: 'April 15, 2026' },
{ type: 'text', key: 'location', label: 'Location' },
{ type: 'text', key: 'mapUrl', label: 'Map Link (optional)' },
{ type: 'color', key: 'accent', label: 'Accent', default: '#7c3aed' },
],
template: `
<div style="border: 2px solid {{ accent }}; padding: 20px; border-radius: 8px;">
<h3 style="color: {{ accent }};">{{ eventName }}</h3>
<p>📅 {{ date }} · 📍 {{ location }}</p>
{% if mapUrl %}
<a href="{{ mapUrl }}">View on Map →</a>
{% endif %}
</div>
`,
},
],
})Merge tags with pluggable syntax
Handlebars, Liquid, JS template literals, or your own — with human-readable labels rendered directly on the canvas. No vendor-locked syntax.
Build a CRM-aware tag picker in an afternoon, not a sprint.
- Built-in syntaxes plus a hook for your own
- Human-readable labels rendered directly on the canvas
- Inline autocomplete — type the syntax opener to surface matching tags
- Optional onRequest hook to swap the picker for your CRM UI
- Round-trip safe — JSON stores the canonical token
const editor = await init({
container: '#editor',
mergeTags: {
syntax: 'liquid',
tags: [
{ label: 'First name', value: '{{first_name}}' },
{ label: 'Email', value: '{{email}}' },
{ label: 'Plan name', value: '{{plan_name}}' },
{ label: 'Order ID', value: '{{order_id}}' },
{ label: 'Order total', value: '{{order_total}}' },
{ label: 'Unsubscribe URL', value: '{{unsubscribe_url}}' },
],
},
})Display conditions
Show or hide blocks based on recipient attributes, with live preview in the editor. Built in, not a paid add-on.
Personalize without bolting on a separate targeting service.
- Per-block show/hide rules from recipient attributes
- Live preview while editing
- allowCustom: true lets editors add conditions inline
- Custom wrappers — your ESP evaluates Liquid at send time
const editor = await init({
container: '#editor',
displayConditions: {
conditions: [
{
label: 'VIP Partners',
before: '{% if vip_partner %}',
after: '{% endif %}',
group: 'Audience',
description: 'Show only to VIP partner accounts',
},
{
label: 'Enterprise',
before: '{% if plan == "enterprise" %}',
after: '{% endif %}',
group: 'Audience',
},
{
label: 'Early Bird',
before: '{% if early_bird %}',
after: '{% endif %}',
group: 'Registration',
},
],
allowCustom: true,
},
})Full theming via design tokens
27 OKLch tokens, custom fonts, dark mode, complete theme overrides. Every surface tokenized — not just the ones in the marketing screenshot.
The editor looks like your product on day one.
- 27 OKLch design tokens covering every surface
- Light + dark theme overrides via the same theme.dark key
- Custom fonts via --tpl-font-sans and --tpl-font-mono
- Tailwind 4 with `tpl:` prefix — no preflight, no style leaks
const editor = await init({
container: '#editor',
uiTheme: 'auto',
theme: {
'--tpl-color-primary': '#0d9488',
'--tpl-color-accent': '#0ea5e9',
'--tpl-color-background': '#ffffff',
'--tpl-radius': '10px',
'--tpl-font-sans': 'Inter, system-ui, sans-serif',
dark: {
'--tpl-color-primary': '#22d3ee',
'--tpl-color-accent': '#a78bfa',
'--tpl-color-background': '#0b1220',
},
},
})Drop into any page — host CSS can't interfere
The editor mounts inside a Shadow DOM by default. Your app's stylesheets, design system preflight, and CMS template resets stop at the boundary — they never cascade into the toolbar, sidebar, or canvas.
Embed in any framework, CMS, or legacy app — no resets, no !important wars, no surprises after a design-system bump.
- Shadow DOM mount by default — no host CSS leaks in
- Editor styles can't leak out either (tpl: Tailwind prefix in light-DOM mode)
- Project your brand across the shadow boundary via --tpl-user-* CSS variables
- Opt out with shadowDom: false for light-DOM mount when you need it
- Multi-instance safe — each editor gets its own shadow root
const editor = await init({
container: '#editor',
// Shadow DOM by default — host stylesheets stop at the boundary.
// Your design system's preflight, *{ box-sizing }, and body font
// can't cascade into the editor. Set to false for a light-DOM
// mount if you need to inspect editor nodes from host scripts.
shadowDom: true,
// To project your brand across the shadow boundary, set
// --tpl-user-* CSS variables on the container (or any ancestor).
// They inherit through the shadow root.
theme: {
'--tpl-user-color-primary': '#0d9488',
'--tpl-user-font-sans': 'Inter, system-ui, sans-serif',
'--tpl-user-radius': '10px',
},
})Built-in accessibility linting
Live WCAG checks while authoring — surfaced in a dedicated sidebar tab and as inline badges on the canvas. Deterministic rules, configurable severity, no AI guesswork.
Catch alt-text, contrast, and structure issues before send — not after.
- Live checks: errors, warnings, and info — grouped in the sidebar
- Inline canvas badges with one-click jump and auto-fix where safe
- Per-rule severity overrides and configurable thresholds (contrast, font size, touch targets)
- Locale-aware vague-text dictionaries
- Same engine runs standalone — validate templates in CI, on save, or in pre-send pipelines
const editor = await init({
container: '#editor',
// Powered by the optional peer @templatical/quality.
// Lazy-loaded on first use
accessibility: {
// Per-rule severity overrides — 'error' | 'warn' | 'info' | 'off'.
rules: {
'img-alt-missing': 'error',
'img-alt-filename-like': 'warn',
'link-target-blank-no-rel': 'off',
},
thresholds: {
contrastNormal: 4.5,
contrastLarge: 3,
minFontSize: 12,
minTouchTargetPx: 44,
},
// Or set disabled: true to disable all accessibility checks
},
})Pluggable media library
A single onRequestMedia hook lets the editor open your media browser — S3, Cloudinary, your own CMS, anything. No vendor storage, no asset egress fees, no lock-in.
Reuse the asset pipeline you already run, end-to-end.
- One async hook returns { url, alt } — bring any backend
- Triggered from image blocks, image fields, and the toolbar
- Context-aware accept hint — the editor tells you what it wants
- No upload happens through Templatical — your storage, your auth
- Cloud build adds a managed media browser when you opt in
const editor = await init({
container: '#editor',
// Editor calls onRequestMedia when the user picks an image —
// open your own asset browser (S3, Cloudinary, your CMS, etc.)
// and resolve with { url, alt } — or null on cancel.
async onRequestMedia({ accept } = {}) {
const picked = await openAssetBrowser({
accept, // e.g. ['images']
endpoint: '/api/assets',
})
if (!picked) return null
return { url: picked.url, alt: picked.alt }
},
})Template & block defaults
Define your brand once. New templates and blocks pick up your defaults automatically — colors, fonts, padding, layout.
Brand consistency without the copy-paste tax.
- Brand defaults set once at init() time
- Per-block-type defaults: button, divider, spacer, image, social
- Template-level defaults: width, background, font family
- Override per-template via the templateDefaults field
const editor = await init({
container: '#editor',
blockDefaults: {
button: {
backgroundColor: '#0f3460',
textColor: '#ffffff',
borderRadius: 2,
fontSize: 14,
buttonPadding: { top: 14, right: 28, bottom: 14, left: 28 },
},
divider: { color: '#e5e7eb', thickness: 1 },
spacer: { height: 24 },
image: { align: 'center' },
},
templateDefaults: {
width: 640,
backgroundColor: '#f8f9fa',
fontFamily: 'Georgia, serif',
},
})Everything else you expect — done right.
Drop-in mount, portable JSON, MJML output, framework-agnostic. Plus the polish — dark mode, i18n, undo/redo.
- Drop-in framework integration
- One init() call to mount, one to unmount. First-class examples for React, Vue, Svelte, Angular, and vanilla JS.
- JSON in, MJML out
- Templates are portable JSON. Output is MJML — render in the browser or on your server, send through any provider. No hosted render service required.
- Dark mode
- First-class dark mode with auto-detect or manual toggle. Both themes are designed, not an afterthought.
- Internationalization
- English and German built in. Load custom translations for any language.
- Undo / Redo
- Full history stack. Debounced to group rapid changes into sensible undo steps.
- Responsive preview
- Toggle desktop, tablet, and mobile viewports to see how every email renders on every device.
Already in another editor? Bring your templates with you.
Import existing templates from major hosted editors — or any HTML email you already have. Free, open-source migration tools, no manual rebuilding, no vendor lock-in.
- Import legacy JSON templates directly
- Convert raw HTML emails — MJML, Mailchimp, SendGrid, hand-coded
- Automatic block mapping and style preservation
- Free and open-source migration tools
Pick your starting point.
Install the SDK
Add the package, mount with one init() call, ship. First-class examples for every major framework.
Migrate your templates
Already in a hosted editor — or sitting on a folder of HTML emails? Import them with automatic block mapping, no manual rebuild.