Installation
Active development
Templatical is under active development and ships frequently. The public API is stabilizing — we follow SemVer, use changesets for every release, and document breaking changes in the changelog. Pin a version in production and watch GitHub releases to stay current.
Have a feature request or hit a rough edge? Open a discussion — feedback shapes the roadmap.
Requirements
- Modern browser -- support depends on which mount mode you use:
- Default mode (
shadowDom: true, Shadow DOM) — Chrome 80+, Edge 80+, Firefox 101+, Safari 16.4+. Firefox and Safari minimums are driven by theadoptedStyleSheetsAPI the shadow path relies on. - Opt-out mode (
shadowDom: false, light DOM) — Chrome 80+, Edge 80+, Firefox 80+, Safari 14+. Use this if you need to support older Firefox or Safari, or if your integration requires light-DOM access to editor internals. See the Shadow DOM guide for trade-offs.
- Default mode (
- Container element -- must have a defined height (the editor fills its container). In default mode, must be an element type that can host a shadow root (e.g.
<div>,<section>,<article>). See container element requirements. - No
transform, and no stacking context, on an ancestor of the container --transform,filter,perspective,will-change,opacitybelow1,isolation,contain, and positioned elements with az-indexeach change where the editor's overlays are painted or positioned. These are plain CSS rules, not Templatical-specific limitations, and they affect any library that positions overlays withposition: fixed. See The editor's container for what each one breaks and how to work around it. - No required peer dependencies -- Vue, TipTap, and all internal libraries are bundled into the editor. You don't need to install Vue or any framework runtime, regardless of which framework your app uses. (
@templatical/renderer,@templatical/quality,@templatical/media-library, andpusher-jsare optional peers — install them only if you use the corresponding feature; see Optional peers below.)
Network requests
The editor makes no requests to Templatical. There is no license key, no client ID, no activation call, no entitlement check, and no telemetry. Nothing about the editor is enabled or disabled remotely, so an installed copy keeps working indefinitely.
It does make exactly one third-party request, and you should know about it before you deploy:
| Request | Made by | When |
|---|---|---|
https://fonts.bunny.net/css?family=geist:400,500,600 | A CSS @import at the top of the editor stylesheet | Whenever the stylesheet is parsed, in both DOM modes |
Geist is the editor's default UI font. If the request to load it is blocked or fails, the editor works normally — text falls back to the next family in the stack. Two cases where you might notice:
- Strict Content Security Policy — a policy such as
style-src 'self'blocks the@import. Addhttps://fonts.bunny.nettostyle-srcandfont-src, or accept the fallback font. - Air-gapped or offline deployments — the request fails and the fallback font is used.
To stop the editor depending on Geist, override the font token:
.tpl,
#your-editor-container {
--tpl-user-font-family: system-ui, sans-serif;
}The @import is still present in the stylesheet, so the request is still attempted. To remove it outright, self-host Geist and strip the @import from your copy of dist/style.css as a build step. See Theming for the full font token surface.
The editor's container
The editor mounts its dialogs into a popover root at z-index: 10000, inside the container you pass to init(). A z-index only competes within its own stacking context, so the container must not establish one — otherwise every editor dialog is confined to it, and any chrome of yours with a higher z-index in the parent context paints over them.
These properties on the container, or on any ancestor between it and the stacking context your chrome lives in, create one:
| Property | Creating value |
|---|---|
isolation | isolate |
transform / translate / rotate / scale | anything but none |
filter / backdrop-filter | anything but none |
perspective | anything but none |
opacity | less than 1 |
will-change | transform, filter, opacity, perspective |
contain | paint, layout, content, strict |
mix-blend-mode | anything but normal |
position: fixed / sticky | always |
position: relative / absolute | with any z-index other than auto |
If one of them is unavoidable — a route transition that animates transform, a wrapper you do not control — give that element a z-index higher than your own header, sidebar, or toast layer:
#your-editor-container {
/* Above your own chrome, so the editor's dialogs are too. */
z-index: 200;
position: relative;
}Why a higher z-index on the dialog will not help
A fixed descendant cannot escape a stacking context at any z-index — the comparison happens between the context's root and your chrome, and the descendant's own value never enters it. So the value has to go on the container, not on the dialog. Raising the container is safe because its own box does not overlap your chrome; only the dialogs, which are fixed and cover the viewport, are affected.
The same properties also move things
transform, filter, perspective, will-change and contain do two jobs at once: alongside the stacking context above, each establishes a containing block for position: fixed. A fixed descendant then resolves its coordinates against that ancestor instead of the viewport — and this applies even while the computed transform reads none, because a running or animated transform still promotes the element.
What that means for the editor:
| Overlay | Under a transformed ancestor |
|---|---|
| Color pickers, rich-text toolbars, merge-tag autocomplete | Unaffected. They anchor absolute inside the popover root and convert viewport coordinates to root-local ones, so the ancestor's offset cancels out. |
| Dialog height | Unaffected. Every dialog caps against its own backdrop rather than the viewport, so it stays inside whatever box it is given. |
| Drag-and-drop ghost | Offset. The ghost is position: fixed and placed from viewport coordinates, so it drifts away from the cursor by the ancestor's offset. |
So if you use drag-and-drop, keep transform off every ancestor of the container.
Do not substitute opacity
Animating opacity instead of transform avoids the containing-block problem and walks straight into the stacking one — opacity below 1 creates a stacking context, so your chrome starts painting over the editor's dialogs. There is no property that dodges both. For a scroll or entrance effect, put it on an element that does not wrap the editor's container.
npm
npm install @templatical/editorpnpm add @templatical/editoryarn add @templatical/editorbun add @templatical/editor@templatical/editor is the visual editor. To convert templates to MJML, also install @templatical/renderer:
npm install @templatical/rendererpnpm add @templatical/rendereryarn add @templatical/rendererbun add @templatical/rendererThe renderer is optional. Install it where you need MJML output:
- Browser, with the editor — when calling
editor.toMjml()to export from the user's session. - Node.js / server — when you only have stored template JSON and want to convert it to MJML server-side. You don't need the editor for this; install just the renderer.
If you call editor.toMjml() without the renderer installed, it throws a clear error naming the missing package.
Package overview
| Package | Description | When to install |
|---|---|---|
@templatical/editor | Visual drag-and-drop editor and init() entry point. Self-contained — Vue, TipTap, and @templatical/core//types are bundled inside. | Required |
@templatical/renderer | Converts templates to MJML for email sending. | Optional — install where you call editor.toMjml() (browser) or renderToMjml() (Node.js, server) |
@templatical/quality | Template linters (accessibility, structure, links) that drive the editor's Issues panel and a headless / CI check. | Optional — install to turn on the Issues sidebar tab and inline block badges |
@templatical/media-library | Standalone media library (types, composable, API client, Vue components) used by initCloud(). | Optional — required only when using initCloud() for the media browser |
@templatical/types | Shared TypeScript types, block factory functions, type guards. | Only if you build templates programmatically without the editor (e.g. server-side workflows) |
@templatical/core | Framework-agnostic editor logic (state, history) for headless setups. | Only for headless / non-editor consumers |
@templatical/import-beefree | Converts BeeFree JSON templates to Templatical format. | Optional |
@templatical/import-unlayer | Converts Unlayer JSON design templates to Templatical format. | Optional |
@templatical/import-html | Converts existing HTML email templates (table-based) to Templatical format. | Optional |
@templatical/editor ships as a single self-contained ESM bundle: every runtime dependency it needs (Vue, TipTap, vue-draggable-plus, @templatical/core, @templatical/types, etc.) is inlined. You never install them separately — and you never get duplicate copies in your app's node_modules.
Optional peers
The editor lazy-loads four optional peers via dynamic import() at runtime, gated by feature use:
| Peer | When loaded | Install if you |
|---|---|---|
@templatical/renderer | First call to editor.toMjml() | Need MJML export from the browser |
@templatical/quality | Editor mount (Issues panel) | Want accessibility, structure, and link lint in the Issues sidebar |
@templatical/media-library | First open of the media browser | Use initCloud() |
pusher-js | Cloud realtime connect | Use initCloud() |
If you don't install them, the corresponding feature disables itself — the editor still mounts and runs.
A note on bundler output
The editor works out of the box with every modern bundler — no consumer configuration is required regardless of which optional peers you install. Vite, esbuild, Rollup, and Rolldown handle the optional dynamic imports silently. Webpack 5 is slightly more verbose: it statically analyzes every import() and prints a harmless Module not found warning for each uninstalled optional peer. The build still succeeds and the editor runs correctly — these warnings are cosmetic only.
If you'd prefer a clean Webpack log, you can opt into silencing them with ignoreWarnings:
// webpack.config.js — optional, only if the warnings bother you
module.exports = {
ignoreWarnings: [
{
module: /@templatical[\\/]editor/,
message:
/Can't resolve '(pusher-js|@templatical\/(quality|media-library|renderer))'/,
},
],
};Framework integration
Templatical mounts into any DOM element. It creates its own isolated application internally, so it works with any framework — or no framework at all.
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
const editor = await init({
container: "#editor",
onChange(content) {
console.log("Content changed", content);
},
});
// Later, when removing the editor:
editor.unmount();import { useRef, useEffect } from "react";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";
export function EmailEditor() {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<TemplaticalEditor | null>(null);
useEffect(() => {
if (!containerRef.current) return;
let cancelled = false;
(async () => {
const ed = await init({
container: containerRef.current,
onChange(content) {
console.log("Content changed", content);
},
});
if (!cancelled) editorRef.current = ed;
})();
return () => {
cancelled = true;
editorRef.current?.unmount();
};
}, []);
return <div ref={containerRef} style={{ height: "100vh" }} />;
}<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";
const container = ref<HTMLElement>();
let editor: TemplaticalEditor | null = null;
onMounted(async () => {
if (!container.value) return;
editor = await init({
container: container.value,
onChange(content) {
console.log("Content changed", content);
},
});
});
onUnmounted(() => {
editor?.unmount();
});
</script>
<template>
<div ref="container" style="height: 100vh" />
</template><script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { init } from '@templatical/editor';
import '@templatical/editor/style.css';
import type { TemplaticalEditor } from '@templatical/editor';
let containerEl: HTMLElement;
let editor: TemplaticalEditor | null = null;
onMount(async () => {
editor = await init({
container: containerEl,
onChange(content) {
console.log('Content changed', content);
},
});
});
onDestroy(() => {
editor?.unmount();
});
</script>
<div bind:this={containerEl} style="height: 100vh;" />import {
Component,
ElementRef,
OnDestroy,
OnInit,
ViewChild,
} from "@angular/core";
import { init } from "@templatical/editor";
import "@templatical/editor/style.css";
import type { TemplaticalEditor } from "@templatical/editor";
@Component({
selector: "app-email-editor",
standalone: true,
template: `<div #editorContainer style="height: 100vh"></div>`,
})
export class EmailEditorComponent implements OnInit, OnDestroy {
@ViewChild("editorContainer", { static: true })
containerRef!: ElementRef<HTMLElement>;
private editor: TemplaticalEditor | null = null;
async ngOnInit(): Promise<void> {
this.editor = await init({
container: this.containerRef.nativeElement,
onChange(content) {
console.log("Content changed", content);
},
});
}
ngOnDestroy(): void {
this.editor?.unmount();
}
}Important
Always call unmount() when removing the editor from the page. This cleans up event listeners, timers, and DOM elements. This is especially important in single-page applications where components mount and unmount during navigation.
TypeScript support
All packages ship with full TypeScript type definitions. Configuration options, callback payloads, block types, and instance methods are fully typed:
import { init, unmount } from "@templatical/editor";
import type {
TemplaticalEditor,
TemplaticalEditorConfig,
} from "@templatical/editor";
import type {
TemplateContent,
Block,
ThemeOverrides,
FontsConfig,
} from "@templatical/types";CDN
If you prefer not to use a package manager, load the editor directly via script tags:
<link
rel="stylesheet"
href="https://unpkg.com/@templatical/editor/dist/cdn/editor.css"
/>
<script type="module">
import { init } from "https://unpkg.com/@templatical/editor/dist/cdn/editor.js";
const editor = await init({
container: "#editor",
});
</script>
<div id="editor" style="height: 100vh;"></div>The CDN build is fully self-contained — all dependencies are bundled. Heavy libraries (TipTap, Vue, Pusher, etc.) are code-split into separate chunks and loaded on demand.