Email Design Tokens and Shared Component Libraries
Ship a versioned email component library: design tokens compiled to inline styles, semantic colour pairs for dark mode, package versioning, and safe rollout across teams.
Once more than one team sends email from the same brand, template drift becomes inevitable. The billing service's button is two pixels shorter than the product team's, one uses the old brand red, and a fix applied to the Outlook border trick lands in three templates out of eleven. This section covers the layer that stops that: a versioned package of design tokens and components that every template imports, so a fix is published once and consumed everywhere.
The email constraint that makes this different from a web design system is that there is no runtime. A web component library can ship CSS custom properties and let the browser resolve them; email has to resolve everything at build time and emit literal values into inline style attributes. Tokens in email are therefore a compile-time concept, and the library's job is to turn a semantic name into a literal string before the message is ever sent.
Tokens Are Compile-Time Constants
A token is a named value with one definition and many consumers. In email the useful layering is three deep, and collapsing it is the most common design mistake.
Primitive tokens name raw values: red-600, space-4, font-size-3. They carry no meaning about usage and should never appear in a template.
Semantic tokens name intent: color-action-background, color-text-muted, space-section. Templates use these exclusively, which is what makes a rebrand a change to one mapping file rather than a search across every template.
Component tokens name a part: button-background, button-padding-y. They resolve to semantic tokens and exist so a component's internals can change without touching either the primitives or the templates.
Core Implementation: Tokens Compiled to Both Outputs
Email needs the same token set in two shapes: JavaScript objects for JSX-based templates and attribute values for markup-based ones. Generating both from a single source keeps them from diverging.
// tokens/source.js — the single definition. Everything else is generated.
export const primitives = {
rose600: "#a53860",
rose400: "#da627d",
ink900: "#450920",
slate700: "#334155",
slate500: "#64748b",
paper: "#ffffff",
paperAlt: "#fdf2f5",
};
// Semantic tokens carry the light/dark PAIR, because email cannot resolve a
// variable at read time — both literals must be emitted, and the dark one is
// selected by a media query in the clients that support one.
export const semantic = {
colorTextBody: { light: primitives.slate700, dark: "#e7dade" },
colorTextMuted: { light: primitives.slate500, dark: "#c3b0b8" },
colorSurface: { light: primitives.paper, dark: "#1e141a" },
colorSurfaceAlt: { light: primitives.paperAlt, dark: "#241820" },
colorActionBg: { light: primitives.rose600, dark: primitives.rose600 },
colorActionText: { light: primitives.paper, dark: primitives.paper },
colorBorder: { light: "#e2e8f0", dark: "#3d2a33" },
};
// Type and space are single-valued: they do not change between schemes.
export const scale = {
fontBody: "16px",
fontSmall: "14px",
fontHeading: "28px",
lineBody: "24px",
lineHeading: "34px",
spaceSection: "32px",
spaceStack: "16px",
radius: "6px",
};
// tokens/build.js — emit the two consumable shapes from the one source.
import { writeFileSync } from "node:fs";
import { semantic, scale } from "./source.js";
// 1. A flat light-mode map for inline style construction in any engine.
const flat = Object.fromEntries([
...Object.entries(semantic).map(([k, v]) => [k, v.light]),
...Object.entries(scale),
]);
writeFileSync("dist/tokens.json", JSON.stringify(flat, null, 2));
// 2. The dark-mode overrides as a media-query block. Apple Mail and iOS Mail
// apply it; Gmail and the Word engine ignore it and keep the light literals.
const darkRules = Object.entries(semantic)
.filter(([, v]) => v.dark !== v.light)
.map(([k, v]) => ` [data-token="${k}"] { color: ${v.dark} !important; }`)
.join("\n");
writeFileSync("dist/dark.css",
`@media (prefers-color-scheme: dark) {\n${darkRules}\n}\n`);
The pairing of light and dark values in the semantic layer is the part that is specific to email. Because there is no runtime resolution, both literals must reach the message: the light value inline, the dark value in a media-query block that the dark mode CSS layer applies in clients that honour it. A token system that stores only one value per name cannot express this, and teams that discover it late end up hard-coding dark overrides in every template.
Second Pattern: Components as the Only Public API
Tokens alone do not stop drift, because two developers will assemble the same tokens into two different buttons. The library's public surface should be components, with tokens exposed only for the rare case a component cannot cover.
// components/Button.jsx — the only supported way to render a call to action.
// Every client-specific workaround lives here, once.
import { tokens } from "../tokens/dist/tokens.json";
export function Button({ href, children, width = 220 }) {
return (
<table role="presentation" cellPadding="0" cellSpacing="0" border={0}>
<tbody>
<tr>
<td
align="center"
style={{
// Border reproduces the padding the Word engine discards on an
// inline anchor — see the bulletproof button pattern.
borderRadius: tokens.radius,
backgroundColor: tokens.colorActionBg,
}}
>
<a
href={href}
style={{
display: "inline-block",
fontFamily: "Arial, sans-serif",
fontSize: tokens.fontBody,
lineHeight: "24px",
msoLineHeightRule: "exactly",
color: tokens.colorActionText,
textDecoration: "none",
// 14px border on each axis gives a 52px target, clearing the
// 44px iOS minimum without relying on padding.
border: `14px solid ${tokens.colorActionBg}`,
borderRadius: tokens.radius,
minWidth: `${width}px`,
}}
>
{children}
</a>
</td>
</tr>
</tbody>
</table>
);
}
The same component can be expressed as an MJML component or a Jinja2 macro; what matters is that exactly one implementation exists per engine, and that a template cannot bypass it without an obvious code-review signal.
Versioning and Rollout
Email component libraries need stricter versioning discipline than web ones, because a visual regression ships to inboxes and cannot be rolled back. Three rules cover most of it.
| Change | Version bump | Why |
|---|---|---|
| New component or optional prop | Minor | Additive; existing templates render identically |
| Client-rendering fix with no visual change | Patch | Consumers should take it without thinking |
| Any change to a rendered dimension or colour | Major | It will look different in an inbox, so it needs a deliberate upgrade |
| Token renamed or removed | Major | Templates referencing it break at build time |
| Dark-mode value changed | Major | Affects a scheme most reviewers do not check |
Treat a changed pixel as a breaking change. That is stricter than semantic versioning normally implies, and it is correct here: the consumer's release process is the only place where a visual diff will be reviewed against the templates that actually use the component.
Pipeline Integration Steps
- Define tokens in one source file and generate every consumable shape from it — never maintain a second copy by hand.
- Store light and dark values as a pair in the semantic layer, since both literals must reach the message.
- Publish as a versioned package to a registry rather than sharing code through a monorepo path, so consumers upgrade deliberately.
- Snapshot every component in the library's own test suite, using the snapshot approach so a rendering change cannot merge unnoticed.
- Run a client render check on the library's example templates before publishing, not only in the consuming services.
- Bump majors for visual changes and write the changelog entry from the recipient's point of view: what will look different.
- Pin the version in each consumer and upgrade on a schedule, so an upgrade is a reviewed change rather than an ambient one.
Debugging: Named Symptoms, Cause, and Fix
Symptom: two services render visibly different buttons from the same library. Cause: they are on different major versions, or one has a local override that predates the component. Fix: compare the resolved dependency versions first; a local override should be deleted and its requirement expressed as a prop.
Symptom: a token change did not reach a template. Cause: tokens imported at module scope and cached, or a build that copied dist/ rather than regenerating it. Fix: make token generation a build step with no committed output, so a stale copy cannot exist.
Symptom: dark mode works in the library's preview but not in production templates. Cause: the generated dark-mode CSS block is emitted by the preview harness but not injected by the consuming template's layout. Fix: ship the dark block as part of the layout component rather than as a separate file consumers must remember to include.
Symptom: a patch release broke a template's layout. Cause: a fix that changed a rendered dimension, released under the wrong version bump. Fix: correct the classification and publish a major; the recurrence prevention is a visual snapshot test in the library itself.
Validation and Deployment Checklist
Documenting and Previewing the Library
A component library that cannot be browsed is used by the person who wrote it and nobody else. The documentation problem is more acute in email than on the web, because a consumer cannot simply open the component in a browser and trust what they see — the browser rendering is the one environment the component is not built for.
The practical answer is a preview application that renders every component and every example template, served locally and deployed on every merge. It has three jobs. First, it lets a developer see what exists before writing something new, which is the main defence against duplicate components. Second, it renders each component in both colour schemes side by side, so a dark-mode value that was never checked becomes visible during review rather than after a send. Third, and most valuable, it provides the message source: a copy button that yields the exact compiled HTML, ready to paste into a client test.
That last point deserves emphasis. The gap between "it looks right in the preview" and "it looks right in Outlook" is the entire subject of email development, and a preview app that only shows a browser rendering quietly re-opens it. Pairing the preview with a one-click send to a local capture server closes the loop: the developer sees the browser rendering, the raw source and the message as an actual client receives it, from the same page.
Documentation content matters less than its placement. A component's prop table belongs next to the component, generated from the type definitions rather than written by hand, so it cannot drift. What genuinely needs prose is the reasoning: why the button uses a border instead of padding, why the image component carries an explicit width attribute as well as a CSS width. Those explanations are what stop a well-meaning contributor from "simplifying" a workaround they do not recognise as one — and a one-line comment at the point of the oddity is worth more than a page of general guidance elsewhere.
Migrating an Existing Set of Templates
Introducing a library into an organisation that already sends email is a migration, and treating it as a rewrite is how these projects stall. The approach that works is incremental and starts from the bottom of the component tree.
Begin with the leaf components that carry client workarounds — the button, the image, the divider. They are small, they are where the rendering bugs live, and replacing them changes no layout. A template that adopts the shared button and nothing else is already better off, and the diff is reviewable in a minute. Resist starting with the layout wrapper, which is tempting because it is the largest single win and dangerous because it changes every dimension on the page at once.
Next, adopt the token layer without adopting components, by replacing literal hex values and pixel sizes with token references. This is mechanical, it is safe, and it surfaces the inconsistencies: the moment two templates reference color-action-bg and render differently, you have found a hard-coded override worth deleting.
Only then move to layout components, one template at a time, with a visual comparison against the previous render before each merge. At this stage a snapshot suite earns its cost — the diff between the old and new compiled output is exactly the review artefact you want, and an unexpected change in it is the signal that a workaround was lost in translation.
Throughout, keep the old and new paths coexisting. A migration that requires every template to move at once creates a deadline nobody can meet, and the intermediate state — some templates on the library, some not — is stable enough to live in for months.
Frequently Asked Questions
Can I reuse my web design system's tokens directly?
The primitive layer, yes — a brand colour is a brand colour. The semantic layer usually needs its own definitions, because email has roles the web does not (a preheader colour, a Word-engine fallback family) and lacks others (anything depending on hover or focus state). Sharing primitives while keeping a separate semantic layer gives you brand consistency without importing assumptions that email cannot honour.
Should the library ship compiled HTML or source components?
Source components, compiled by the consumer. Shipping compiled HTML freezes the output at publish time, so a consumer cannot pass different content through it, and any per-message data binding becomes string manipulation. The exception is a fully static fragment such as a legal footer, where compiled output is genuinely simpler.
How do we stop teams from bypassing the components?
Make the correct path easier than the alternative and the incorrect path visible in review. A lint rule that flags a raw <a> tag with a background colour inside a template directory catches most bypasses mechanically. Beyond that, the strongest deterrent is that components carry the client workarounds — a hand-rolled button is not just inconsistent, it is broken in Outlook.
Do design tokens help with dark mode, or complicate it?
They are what makes it tractable. Without tokens, every dark-mode override is written per template and drifts immediately. With paired semantic tokens the dark palette is defined once, generated into a single media-query block, and reviewed in one place. The complication is only that the token store has to model a pair rather than a value — a small cost for removing per-template overrides.
Does a small team with three templates need any of this?
Probably not as a package. What is worth doing even at that size is the token file — a single module exporting colours, sizes and spacing that the templates import instead of repeating literals. It costs an afternoon, it makes a rebrand a one-file change, and it is the piece you would otherwise retrofit later under pressure. The versioned package, the preview app and the migration plan only start paying for themselves when more than one repository sends mail, because their whole purpose is coordinating across boundaries that a single repository does not have.
What belongs in the library and what belongs in the template?
The library owns anything a client can break: buttons, layout wrappers, images with their fallbacks, the type scale, the dark-mode block. Templates own the message — the copy, the data binding, the order of sections. If a change would need to be applied to more than one template to be correct, it belongs in the library.
Related
- MJML Component Architecture — the markup-based engine this library pattern most often targets
- React Email Development — where typed props make the component contract explicit
- Email Typography and Web Fonts — the type scale the token set encodes
- Dark Mode Email CSS — how the generated dark block is applied by the clients that read it
- Automated Snapshot Testing — the regression net that makes a component change safe to publish
← Back to Modern Email Templating Engines