The editor is designed to disappear into your product’s UI rather than look like a third-party widget. Around 70 colour tokens cover every surface, and assigning a new theme object re-themes live with no remount.
The eight tokens that matter
You do not need all seventy. These eight get you most of the way, and the rest derive sensibly:
await init({
container: "#editor",
theme: {
primary: "#3f5e4a",
background: "#f6f8f6",
surface: "#ffffff",
text: "#101613",
border: "#dde5e0",
headerBg: "#ffffff",
sidebarBg: "#ffffff",
canvasBg: "#ecf1ee",
},
})
Start there, look at it, then reach for the specific tokens below only where something is off.
Re-theming at runtime
handle.setTheme({ primary: "#3f5e4a" })
Themes are reactive. If your app has a theme switcher, wire it to setTheme and the editor moves with it — no remount, no flash. Multiple editors on the same page can carry different themes.
<EmailEditor :theme="theme" :color-mode="mode" />
Assigning a new object to theme is enough; the editor watches it.
Dark mode
colorMode: "auto" | "light" | "dark" // default "auto"
auto follows the host page: the editor watches for a dark class on <html> or <body> and mirrors it onto its own root. If you already have a Tailwind-style theme switcher, that is zero wiring — flipping the class moves the editor in the same frame.
Pass light or dark to pin the editor regardless of the page. That is what you want for a light editor deliberately embedded in a dark shell, or when your switcher signals something other than a class.
Supply dark values alongside the light ones:
theme: {
primary: "#3f5e4a",
background: "#f6f8f6",
dark: {
primary: "#8fbb9c",
background: "#0e1311",
surface: "#151c19",
text: "#e9eeeb",
border: "#26302b",
},
}
|
A dark accent cannot reach a 4.5:1 contrast ratio on a dark background at any usable saturation. Dark-mode accents need to invert, not merely darken. If your brand colour is a deep tone, pick a light tint of the same hue for |
The full token list
Brand
| Token | Applies to |
|---|---|
|
Primary buttons, active states, focus rings, links |
|
Hover state of the above |
|
Text and icons drawn on |
Surfaces
| Token | Applies to |
|---|---|
|
The editor’s outermost background |
|
Panels, cards, dropdown bodies |
|
Recessed areas — input backgrounds, inactive tabs |
|
Hover state for list rows and menu items |
Text
| Token | Applies to |
|---|---|
|
Primary copy |
|
Secondary copy, helper text |
|
Placeholders, disabled labels |
Status
danger · onDanger · success · warning · info
Plus paired background, foreground and border tokens for status blocks:
successBg successFg successBorder · infoBg infoFg infoBorder · warningBg warningFg warningBorder · dangerBg dangerFg dangerBorder
Chrome
| Token group | Applies to |
|---|---|
|
The editor header bar |
|
Both sidebars |
|
The area around the email canvas |
|
Floating toolbars — rich-text, canvas actions |
|
Dialogs, popovers, pickers |
|
Tooltips |
Selection
| Token | Applies to |
|---|---|
|
The selected component’s outline and handles |
|
The selected row — deliberately distinct from component selection |
Buttons
buttonPrimaryBg buttonPrimaryText buttonPrimaryHoverBg · buttonSecondaryBg buttonSecondaryText buttonSecondaryHoverBg
Where tokens land, and why it matters
Theme tokens are written to the editor component’s own root element, not to <html>.
|
This is not an implementation detail you can work around. A custom property declared on an element beats any value inherited from an ancestor, whatever the specificity. The package’s own default tokens land on the editor root, so a theme written to If you are trying to theme the editor by setting CSS variables on |
The theme is applied in the mounted hook rather than during setup, because the root element does not exist yet during setup. Vue runs mounted hooks synchronously at the end of the patch, before the browser paints, so there is no frame of unthemed editor.
Style isolation
By default the editor renders inside a shadow root, so its CSS cannot reach your page and your page’s CSS cannot reach it.
In light-DOM mode (shadowDom: false) every rule in the editor’s stylesheet is prefixed with :where(.md-editor-scope). :where() adds no specificity, so the cascade inside the editor is unchanged, and none of those rules can escape the scope. The trade-off in light DOM is that your page’s CSS can reach in.
Matching this documentation site
For reference, the sage palette used across these docs:
| Token | Light | Dark |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const maildenoSage = {
primary: "#3f5e4a",
primaryHover: "#33503e",
onPrimary: "#ffffff",
background: "#ffffff",
surface: "#f6f8f6",
surfaceMuted: "#ecf1ee",
text: "#101613",
textMuted: "#63706a",
border: "#dde5e0",
headerBg: "#ffffff",
sidebarBg: "#f6f8f6",
canvasBg: "#ecf1ee",
dark: {
primary: "#8fbb9c",
primaryHover: "#a6cdb1",
onPrimary: "#0e1311",
background: "#0e1311",
surface: "#151c19",
surfaceMuted: "#1d2622",
text: "#e9eeeb",
textMuted: "#8a9791",
border: "#26302b",
headerBg: "#151c19",
sidebarBg: "#151c19",
canvasBg: "#0a0f0c",
},
}
Checking your theme
Two things worth verifying before you ship a theme:
Contrast. text on surface, onPrimary on primary, and textMuted on background all need at least 4.5:1. Borders and large text need 3:1.
Both modes. Toggle the host page’s dark class and look at the result. The most common theming bug is a dark map that was copied from the light one and never adjusted, leaving an unreadable accent.
The one piece of chrome a theme cannot restyle away is the desktop-only notice, since it replaces the entire editor below the breakpoint. Use brandName to control the wordmark it shows, or pass an empty string to drop the attribution line entirely.
|
Where to go next
-
Editor API reference —
ThemeOptionsin the type list -
@maildeno/editor— mounting and host-page requirements