Skip to content

Theming

Templatical ships with a polished default theme. Two ways to override any color, radius, shadow, or font:

  1. CSS variables on the container (--tpl-user-*) — the recommended approach. Works in both shadow DOM (default) and light DOM modes. Pure CSS — no JS round-trip.
  2. ThemeOverrides config (JSON) — pass colors at init() time. Useful when colors come from runtime data (user preference, multi-tenant brand config).

Both approaches can be combined — the ThemeOverrides config takes precedence because it applies as inline styles, which beat class-bound variables.

CSS variables on the container

Set --tpl-user-* variables on the container you pass to init() (or any ancestor). They inherit across the shadow boundary into the editor's root and override the built-in defaults:

html
<div
  id="editor"
  style="
    --tpl-user-primary: oklch(65% 0.2 280);
    --tpl-user-primary-hover: oklch(58% 0.2 280);
    --tpl-user-primary-light: oklch(94% 0.05 280);
    --tpl-user-radius: 14px;
  "
></div>

Or in a stylesheet:

css
#editor {
  --tpl-user-primary: oklch(65% 0.2 280);
  --tpl-user-primary-hover: oklch(58% 0.2 280);
  --tpl-user-primary-light: oklch(94% 0.05 280);
  --tpl-user-radius: 14px;
}

CSS-custom-property inheritance crosses shadow roots, so the variables you set in the host page reach the editor regardless of mount mode. You don't need separate code paths for shadowDom: true vs shadowDom: false.

Light-mode tokens

Every token follows the same shape: declare --tpl-user-<name> on the container to override the SDK default.

Override variablePurpose
--tpl-user-bgMain background
--tpl-user-bg-elevatedElevated surfaces (panels, dropdowns)
--tpl-user-bg-hoverHover state background
--tpl-user-bg-activeActive/pressed state background
--tpl-user-borderDefault border color
--tpl-user-border-lightSubtle border (dividers, separators)
--tpl-user-textPrimary text
--tpl-user-text-mutedSecondary text
--tpl-user-text-dimDisabled or hint text
--tpl-user-primaryPrimary brand color (buttons, links)
--tpl-user-primary-hoverPrimary hover state
--tpl-user-primary-lightPrimary tinted background
--tpl-user-secondarySecondary accent color
--tpl-user-secondary-hoverSecondary hover state
--tpl-user-secondary-lightSecondary tinted background
--tpl-user-successSuccess state color
--tpl-user-success-lightSuccess tinted background
--tpl-user-warningWarning state color
--tpl-user-warning-lightWarning tinted background
--tpl-user-dangerDanger/error state color
--tpl-user-danger-lightDanger tinted background
--tpl-user-canvas-bgCanvas area behind the email template
--tpl-user-base-sizeEditor UI sizing unit (16px) — see Editor size
--tpl-user-radiusDefault border radius (10px)
--tpl-user-radius-smSmall border radius (7px)
--tpl-user-radius-lgLarge border radius (14px)
--tpl-user-font-familyUI font stack
--tpl-user-shadow-smSubtle shadow
--tpl-user-shadowDefault shadow
--tpl-user-shadow-mdMedium shadow
--tpl-user-shadow-lgLarge shadow
--tpl-user-shadow-xlExtra-large shadow
--tpl-user-overlayModal backdrop overlay
--tpl-user-ringFocus ring
--tpl-user-transitionSpring easing transition

Dark-mode tokens

Dark mode uses a parallel --tpl-user-dark-* namespace, so you can theme light and dark independently:

css
#editor {
  /* Light overrides */
  --tpl-user-primary: oklch(65% 0.2 280);

  /* Dark overrides */
  --tpl-user-dark-primary: oklch(75% 0.16 280);
  --tpl-user-dark-bg: oklch(18% 0.005 280);
  --tpl-user-dark-text: oklch(94% 0.005 280);
}

Replace --tpl-user- with --tpl-user-dark- in any token name from the table above to target dark mode. The editor activates dark mode via data-tpl-theme="dark" on its root and reads the dark-namespace defaults; your --tpl-user-dark-* overrides plug in there.

Dark mode is opt-in via the uiTheme config — set 'dark' or 'auto' to enable. See Dark mode below.

Editor size and the host root font-size

The editor's UI sizing — spacing, font sizes, border radii — is anchored to a single base unit, --tpl-user-base-size, which defaults to 16px.

Because the default is a fixed pixel value (not rem), the editor renders at a consistent size regardless of your page's root font-size. If your design system sets a custom html { font-size } — for example 8px to make 1rem = 8px — the editor is unaffected and stays at its intended size. (A rem always resolves against the document root, even inside the editor's shadow root, so without this anchor a custom root font-size would scale the whole editor up or down.)

Scaling the editor up or down

Set --tpl-user-base-size on the container (or any ancestor) to scale the entire editor UI proportionally:

css
#editor {
  --tpl-user-base-size: 18px; /* ~12% larger chrome, text, spacing, radii */
}
css
#editor {
  --tpl-user-base-size: 14px; /* more compact */
}

Any CSS length works. If you prefer the editor to track your custom root font-size after all, set a rem value — e.g. --tpl-user-base-size: 2rem against an 8px root resolves to 16px.

This affects the editor chrome only. The email content on the canvas uses the pixel sizes stored on each block, so your template renders identically no matter how the editor UI is scaled.

The same --tpl-user-base-size knob applies to the standalone @templatical/media-library SDK. Like every --tpl-user-* variable, it works identically in shadow-DOM and light-DOM modes.

ThemeOverrides config

Use the theme field of init() when you need to apply theme overrides programmatically (multi-tenant branding, user preference toggles, etc.):

ts
import { init } from "@templatical/editor";

const editor = await init({
  container: "#editor",
  theme: {
    primary: "#6d28d9",
    primaryHover: "#5b21b6",
    primaryLight: "#ede9fe",
    bg: "#fafafa",
    text: "#1a1a1a",
  },
});

ThemeOverrides is applied as inline styles on the editor's .tpl root, so it wins over the class-bound defaults and over any --tpl-user-* variables you've set on the container.

Available config keys

The JSON keys mirror the CSS variable names (camelCase instead of kebab-case). All keys are optional — unset keys fall back to --tpl-user-* or the built-in default.

TokenPurpose
bgMain background
bgElevatedElevated surfaces
bgHoverHover state background
bgActiveActive/pressed state background
borderDefault border color
borderLightSubtle border
textPrimary text
textMutedSecondary text
textDimDisabled or hint text
primaryPrimary brand color
primaryHoverPrimary hover state
primaryLightPrimary tinted background
secondarySecondary accent color
secondaryHoverSecondary hover state
secondaryLightSecondary tinted background
successSuccess state color
successLightSuccess tinted background
warningWarning state color
warningLightWarning tinted background
dangerDanger/error state color
dangerLightDanger tinted background
canvasBgCanvas area behind the email template

TypeScript type

ts
import type { ThemeOverrides } from "@templatical/types";

Dark mode

The editor supports a dark theme for its UI chrome (header, sidebars, toolbar, modals). Dark mode is independent of the canvas dark-preview toggle, which simulates how emails look in recipients' dark-themed email clients.

Activation

Set uiTheme in the init config. The default is 'auto', which follows the user's system preference via prefers-color-scheme.

ts
const editor = await init({
  container: "#editor",
  uiTheme: "dark", // 'light' | 'dark' | 'auto'
});

Runtime toggle

Switch the theme without re-initializing:

ts
editor.setTheme("dark");
editor.setTheme("light");
editor.setTheme("auto"); // follow system preference

Dark overrides via ThemeOverrides

Customize the dark palette separately using the dark key inside theme:

ts
const editor = await init({
  container: "#editor",
  uiTheme: "auto",
  theme: {
    // Light mode overrides
    primary: "#6d28d9",
    primaryHover: "#5b21b6",
    // Dark mode overrides
    dark: {
      primary: "#a78bfa",
      primaryHover: "#c4b5fd",
      bg: "#1e1e2e",
      bgElevated: "#2a2a3c",
    },
  },
});

Priority chain. When dark mode is active, only theme.dark overrides are applied as inline styles. Unset dark tokens fall back through --tpl-user-dark-* (if set on the container) to the built-in dark defaults.

ScenarioWhat applies
Light mode, no overridesBuilt-in light defaults
Light mode, --tpl-user-* on containerContainer overrides + remaining light defaults
Light mode, theme configtheme overrides + container --tpl-user-* + remaining light defaults
Dark mode, no overridesBuilt-in dark defaults
Dark mode, --tpl-user-dark-* on containerContainer overrides + remaining dark defaults
Dark mode, theme.dark configtheme.dark overrides + container --tpl-user-dark-* + remaining dark defaults

TypeScript types

ts
import type { ThemeOverrides, UiTheme } from "@templatical/types";

// UiTheme = 'light' | 'dark' | 'auto'
// ThemeOverrides includes a `dark?: Omit<ThemeOverrides, 'dark'>` key

Why two override surfaces?

CSS variables on the container are simpler when:

  • Theme colors are static or known at build time.
  • You want the same theme tokens to apply to both the editor and your surrounding UI (set them on a shared wrapper).
  • You're using CSS Modules, Tailwind, or another style system that already deals in CSS variables.

ThemeOverrides JSON is simpler when:

  • Theme colors come from runtime data (user account preferences, multi-tenant branding).
  • You're working in a framework that doesn't naturally expose a single styled container element.
  • You want a single typed API surface and don't want to spell variable names by hand.

Both surfaces compose: container variables provide the default, ThemeOverrides patches specific tokens on top. The editor's theme config option always wins because it applies as inline styles.

Matching your own UI to the editor's theme

If you want your surrounding chrome (e.g. a wrapper toolbar or status bar) to inherit the editor's palette, set the override variables on a wrapper that contains both your UI and the editor, then reference them from both:

html
<div
  class="my-app"
  style="--tpl-user-primary: #6d28d9; --tpl-user-bg: #fafafa;"
>
  <header class="my-app__header">…</header>
  <div id="editor"></div>
</div>
css
.my-app__header {
  background: var(--tpl-user-bg);
  color: var(--tpl-user-primary);
}

In shadow-DOM mode (the default), the editor's internal --tpl-* variables stay inside the shadow root and aren't visible to host CSS. Your host CSS reads from the --tpl-user-* variables you set yourself, so the two stay in sync.

See the Shadow DOM guide for the mechanics of how variables cross the boundary.