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:
- 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. - 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):
php -S localhost:8000 -t demo/- http://localhost:8000/gallery.php renders without errors.
- http://localhost:8000/builder.php renders the Minimal sample.
Step 2 — Pick brand identity
Decide three things — write them on paper:
- Theme dir name — folder under
demo/themes/<name>/. Usekebab-caseso it matches the shipped naming (example-mini). For this walkthrough:acme-brand. - Brand title — display label in the gallery + theme picker. For this walkthrough:
"Acme Brand". - Brand colour palette — primary, primary foreground, accent (3 hex values). For this walkthrough:
#5b21b6purple,#ffffffwhite,#a855f7lighter 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:
Step 4 — Edit the manifest
Open demo/themes/acme-brand/index.json. Three fields 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:
- Logo — replace
demo/themes/acme-brand/master/assets/image/email/logo.pngwith your brand's logo PNG. Same filename so JSON pointers don't change. Recommended: 260×80 px source for retina-clean rendering. - Favicon — replace any
favicon.svgorfavicon.pngreferenced by templates. - 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'sformats.font_family) - The container width via
container_width(narrow600 px /medium800 px /wide1024 px /xl1200 px /full) - The default-block background via
default_block_background_colorin the manifest'sconfigData - 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:
Option B — Add a registry.json inside the theme dir (preferred for hand-off cleanliness):
Create demo/themes/acme-brand/registry.json:
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-brandrows. - 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:
'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:
npx playwright test e2e/master-samples-smoke.spec.js -g acme-brand- All samples in
acme-brandpass 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.phpand 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 inregistry.json. Runphp -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, yourPage.template.htmlif customised, error fromnpx playwright testoutput.