Skip to content
Build a theme B4 8 min read Customisation Copy-along · 60 min

Create a theme

Author a new theme dir — fork or scratch, ship in 60 minutes.

You shipped a sample inside the default theme in Create a sample. Sometimes one sample isn't enough — your brand needs its own logo, its own primary colour, its own headline typography. This doc walks you through creating a brand-owned theme dir end-to-end. Time budget: 60 minutes.

Two paths from here, picked by ambition:

  1. Fork the default theme (recommended for your first theme) — copy themes/default/, rename, swap the brand assets. You inherit 30+ tuned templates and 50+ samples.
  2. Author from scratch (only when you need a radically different rendered HTML shape) — start from the shipped themes/example-mini/ blank-slate. Tiny surface; you write each template yourself.

This doc walks path 1 in lock-step (copy, swap, register). Path 2 is documented at the bottom for buyers whose brand needs render-shape ownership.

Step 1 — Pre-flight

Confirm the dev server boots and the gallery shows 43 cards (or 44 if you completed the previous doc):

Bash
php -S localhost:8000 -t demo/

Step 2 — Pick brand identity

Decide three things — write them on paper:

  1. Theme dir name — folder under demo/themes/<name>/. Use kebab-case so it matches the shipped naming (example-mini). For this walkthrough: acme-brand.
  2. Brand title — display label in the gallery + theme picker. For this walkthrough: "Acme Brand".
  3. Brand colour palette — primary, primary foreground, accent (3 hex values). For this walkthrough: #5b21b6 purple, #ffffff white, #a855f7 lighter purple.

Keep within the 4-colour cap (RULE-H8). Two greyscale tones (off-white + dark grey) plus your primary + accent puts you at 4 — exactly the limit.

Step 3 — Fork the default theme

Copy the dir at the filesystem level:

That's ~50 MB (the shipped imagery is most of it). The copy preserves every template, sample, and asset.
You now have a complete theme dir under your brand name. Every sample in it works without any further change — but the gallery doesn't know it exists. Next step.

Step 4 — Edit the manifest

Open demo/themes/acme-brand/index.json. Three fields change:

Keep the existing `templates` + `pages` + `configData` + `formats` arrays — your fork inherits everything. The three you change:
  • name — must match the dir name.
  • title — buyer-facing label.
  • description — gallery card subtitle.

Plus add the optional _brand block with your three colours. The shipped templates don't read this block (they read formats.* overrides per-page); it's a buyer-facing convention so future you remembers what the palette is.

Step 5 — Swap brand-critical assets

Three assets typically change between brands:

  1. Logo — replace demo/themes/acme-brand/master/assets/image/email/logo.png with your brand's logo PNG. Same filename so JSON pointers don't change. Recommended: 260×80 px source for retina-clean rendering.
  2. Favicon — replace any favicon.svg or favicon.png referenced by templates.
  3. Brand glyphs — if your shipped samples reference any social-icon or logo sprite, drop your replacements in the same paths.

The shipped imagery library has ~150 photos + illustrations. You can keep most of them — they're royalty-free and brand-agnostic. Only swap what's actually brand-bearing.

Step 6 — Edit the brand-critical templates

Open demo/themes/acme-brand/Page.template.html. The shipped Page template emits a full <html><head><style>…</style></head><body> shell + a single <%- page %> slot for the rendered content tree. Most brand changes hit:

  • The <body> font-family (defaults to Arial; override via the manifest's formats.font_family)
  • The container width via container_width (narrow 600 px / medium 800 px / wide 1024 px / xl 1200 px / full)
  • The default-block background via default_block_background_color in the manifest's configData
  • Optional brand strip at the top (a 4 px colour bar above the page content — add a top-of-<body> <div>)

For this walkthrough, leave Page unchanged — the default looks fine for Acme.

Now the heading templates — one per level (H1.template.html, H2.template.html, …, H6.template.html). Each renders <h1 style="<%- formatter.toStyleStringAll() %>"><%- text %></h1> and reads its CSS from the formats your getControls() writes. Brand typography typically lives in the Page.template.html's formats block (font-family) — the heading templates inherit it.

Button.template.html is where your brand most often shows up — primary CTA shape, hover state, primary colour. Open it and confirm formats.background_color flows through to the rendered <a> element. Yes — it does. So your brand colour applies via per-element overrides; you don't need to edit the template.

This matters: most theme forks leave templates untouched and rely on formats.* overrides. You only edit a template when you want a structural change the shipped formats can't express (e.g. a Heading wrapped in a custom <div class="brand-headline">).

Step 7 — Register the theme

Two paths to register; pick one.

Option A — Edit ThemeRegistry::SEED (matches the way the shipped themes ship):

Open demo/backend/_lib/ThemeRegistry.php, find private const SEED, append a new bucket:

Each row points at one sample inside the `acme-brand` dir (which inherits all the shipped sample files because of the dir copy in Step 3). You're effectively re-publishing the shipped samples under your brand name.

Option B — Add a registry.json inside the theme dir (preferred for hand-off cleanliness):

Create demo/themes/acme-brand/registry.json:

The `ThemeRegistry::loadAll()` discovery loop reads `registry.json` from every theme dir on first request. Option B keeps your theme-specific config out of the shared `ThemeRegistry::SEED` constant, so future merges don't conflict.

Step 8 — Verify the theme shows up

Reload the gallery: http://localhost:8000/gallery.php.

  • The "Standard email" / "Rich content" / "Page templates" sections now include your acme-brand rows.
  • Click any of them. The builder opens with your sample loaded against your theme.
  • The page-level background, font, and button colours should pick up from the formats / brand block you edited.

Open the new card; in DevTools, confirm THEME_CONFIG_DATA._brand.primary === '#5b21b6' to verify the manifest threaded through.

Step 9 — Set the buyer-default theme

When demo/builder.php boots without a ?theme= query, it loads the default theme. To make acme-brand the default default:

Open demo/builder.php, find the $builderConfig block at the top, and change:

to:
PHP
'defaultTheme' => 'acme-brand',

Reload http://localhost:8000/builder.php (no query string). The Minimal sample now renders against your branded theme.

Step 10 — Smoke E2E

Run the master-samples-smoke spec — every sample in your new theme is auto-discovered:

Bash
npx playwright test e2e/master-samples-smoke.spec.js -g acme-brand
  • All samples in acme-brand pass the boot test.
  • If a sample 404s on render, check that step 5's logo replacement didn't break paths.

Author from scratch (path 2)

If your brand requires fundamentally different rendered HTML — a wholly custom Page shape, a non-table-based email layout, or a templating language other than EJS-via-the-engine — you author from scratch starting from themes/example-mini/.

Read its layout: 4 files (index.json, Block.template.html, Page.template.html, master/sample/email/...). That's the legal minimum: one page wrapper, one block wrapper, one sample. Add element templates as you need them (Heading, Image, Button, …); each one you don't add narrows what samples can use the theme.

This path is rare. Most teams that think they need it actually need a fork. Start with fork; switch to scratch only after you've felt the pain of editing 30+ templates' worth of inheritance.

Did you make it?

You should now have:

  • ✅ A demo/themes/acme-brand/ directory with the shipped templates + samples + your brand manifest.
  • ✅ Logos / brand assets swapped at the same paths as the original.
  • ✅ A registry.json (or new SEED rows) registering your samples in the gallery.
  • ✅ Your theme appearing as cards in /gallery.php and clicking through to the builder.
  • ✅ All shipped samples in your theme passing the smoke E2E.

If a step didn't land:

  • 🩹 Theme not in gallery → check ThemeRegistry::loadAll() log. The most common cause is a JSON parse error in registry.json. Run php -r "json_decode(file_get_contents('demo/themes/acme-brand/registry.json')); echo json_last_error_msg();".
  • 🩹 Logo doesn't update → confirm the new file is exactly at demo/themes/acme-brand/master/assets/image/email/logo.png. JSON pointers are case-sensitive.
  • 🩹 Built-in samples render but the brand colour doesn't apply → the shipped JSON has hardcoded colour values per element. Either edit the JSON to reference your _brand.primary, or accept that brand colour is per-sample, not per-theme. Most projects bake brand colour into the JSON of each sample.

What's next?

  • Layout best practice — the rules every theme must respect, distilled.
  • 🔗 examples/basic/3-custom-theme/ — a runnable end-to-end demo of a host page that loads a custom theme via the ?theme=<dir> URL.
  • 🔗 themes/example-mini/README.md — the author-from-scratch starter kit.

Stuck?

  • 🐛 grep themes/example-mini/ for the absolute minimum required theme.
  • 🐛 if your fork breaks one of the shipped samples (template error after editing), revert that template — the engine logs which template threw at the failing element.
  • 🐛 file an issue with: your index.json, your Page.template.html if customised, error from npx playwright test output.