Theming and Theme Inheritance in EDS
How to build an author-driven theme system in EDS. Covers per-page theming, folder/country-level inheritance via the metadata sheet, breaking inheritance with page overrides, and an honest comparison to AEM property inheritance.
Content Objective
This chapter covers:
- How to build a multi-theme system in EDS with zero build step
- How a single page gets a theme, and how a handful of pages get their own
- How to apply one theme to a whole country/folder at once (AEM-style inheritance)
- How to break that inheritance and override the theme on a single page
- The exact precedence rules that decide which theme wins
- What is possible in EDS versus AEM, honestly, and where the gaps are
Coming from AEM, "theming" means a page property that a page inherits from its ancestors via getInherited, resolved server-side by a Sling Model. EDS has no server-side page hierarchy at render time — every page is a standalone static document. So the interesting question is not "how do I set a theme" but "how do I make a theme cascade down a folder like AEM does, when there is no live inheritance?"
This chapter answers that end to end, using a real two-theme setup (theme1 "Abbott" and theme2 "Ocean").
The Mental Model Shift
In AEM, theme inheritance is a runtime tree walk. A page asks "what is my theme?", and if it has no value, the resource resolver walks up the content tree to an ancestor that does. The inheritance is live, server-side, and automatic.
In EDS there is no tree to walk at render time. Each published page is a flat HTML document delivered from the CDN. There is no ancestor to ask. So EDS reconstructs inheritance a different way:
Inheritance in EDS is resolved at publish time and expressed as a single
<meta name="theme">tag on each page.
Everything downstream — the CSS that loads, the colors that render — keys off that one tag. The whole design problem reduces to: how does the right theme meta tag end up in each page's <head>?
There are exactly two authoring mechanisms that put it there, and understanding how they combine is the entire theme system.
The Three Layers
The theme system is three layers stacked on top of each other:
- CSS token files (
theme1.css,theme2.css) — what a theme looks like. loadTheme()inscripts.js— reads thethememeta tag and loads the matching CSS.- Authoring: page property or metadata sheet — decides the theme value.
Layers 1 and 2 are code (written once). Layer 3 is where authors work every day.
Layer 1 — Theme files are just CSS custom properties
A theme is a file of design tokens. No build, no SCSS, no compilation.
/* styles/themes/theme1.css — "Abbott" */
:root {
--color-charcoal: #222731;
--color-yellow: #ffd100;
--color-blue: #001489;
}
.header-global { --header-bg: var(--color-charcoal); }
.footer-global { --footer-bg-color: var(--color-charcoal); }
/* styles/themes/theme2.css — "Ocean" */
:root {
--color-charcoal: #0a1f44; /* deep navy */
--color-yellow: #00b0b9; /* teal */
--color-blue: #0057b8;
}
.header-global { --header-bg: var(--color-charcoal); }
.footer-global { --footer-bg-color: var(--color-charcoal); }
Both files define the same token names with different values. Header and footer CSS read those tokens (var(--header-bg)), so swapping the file swaps the entire look. This is the CSS custom property token hierarchy applied to whole-site theming.
Layer 2 — loadTheme() is the whole engine
async function loadTheme() {
const theme = document.querySelector('meta[name="theme"]')?.content?.trim() || 'theme1';
document.body.classList.add(`theme-${theme}`);
await loadCSS(`${window.hlx.codeBasePath}/styles/themes/${theme}.css`);
}
Three lines of logic:
- Read the
thememeta tag (defaulttheme1if absent). - Add a
theme-xbody class (a hook if any CSS ever needs to key off the theme). - Load the matching CSS file.
It runs in loadEager — before first paint — so there is no flash of the wrong theme.
Notice there is no country logic, no path map, nothing hardcoded. That is deliberate. The code never decides the theme; authors do. This is the single most important design decision in the whole system: keep the code generic, move the decision to content.
Layer 3 — Authoring decides the value
This is where "single page", "few pages", and "whole country" all live. Two mechanisms feed the same meta tag.
Setup — The Files You Touch (Once)
Before any scenario works, the theme system has to exist in code. This is a one-time setup. After this, everything is authoring.
| File | Purpose | Touched when |
|---|---|---|
styles/themes/theme1.css | Default theme tokens | Adding/editing a theme |
styles/themes/theme2.css | Second theme tokens | Adding/editing a theme |
scripts/scripts.js → loadTheme() | Reads the meta, loads the CSS | Once (generic, never per-country) |
component-models.json | Adds the Theme dropdown to the UE Page panel | Enabling Scenario A/B |
models/_page.json | Source of the page model — keep in sync | Enabling Scenario A/B |
metadata sheet (in AEM content) | Folder/country theme rules | Enabling Scenario C |
1. Create the theme CSS files
Add one file per theme under styles/themes/. Each defines the same token names with different values (see Layer 1 above). Nothing else references a specific theme — the loader builds the path from the theme name.
2. Wire loadTheme() into scripts.js
Add the function (Layer 2) and call it from loadEager() before the main content decorates, so the theme CSS is in place before first paint:
async function loadEager(doc) {
document.documentElement.lang = 'en';
decorateTemplateAndTheme();
await loadTheme(); // ← theme resolved before paint
// ...decorateMain, etc.
}
3. Add the Theme dropdown to the page model (enables Scenario A/B)
The UE Page panel is driven by the page-metadata model. Add a select field named exactly theme (the value must match the CSS file name and what loadTheme() reads).
component-models.json (the generated/served file the editor reads):
{
"id": "page-metadata",
"fields": [
{ "component": "text", "valueType": "string", "name": "jcr:title", "label": "Title" },
{ "component": "text", "valueType": "string", "name": "jcr:description", "label": "Description" },
{ "component": "text", "valueType": "string", "name": "keywords", "multi": true, "label": "Keywords" },
{
"component": "select",
"valueType": "string",
"name": "theme",
"label": "Theme",
"options": [
{ "name": "Theme 1 (Default)", "value": "theme1" },
{ "name": "Theme 2", "value": "theme2" }
]
}
]
}
Critical sync gotcha. In this project the
themefield was added to the generatedcomponent-models.jsonbut not to the sourcemodels/_page.json. The generated files are built from the sources (models/_*.json). If a rebuild ever runs, it will overwritecomponent-models.jsonand wipe the Theme field. To make it durable, add the samethemefield tomodels/_page.jsonas well, so source and generated stay in sync. Two files, identical field.
With these four things in place, Scenarios A and B work. Scenario C needs the metadata sheet (documented in its own section below).
Scenario A — One Page (working today)
Goal: theme just this page.
Files involved: none to edit — this is pure authoring, powered by the setup above (component-models.json provides the field, loadTheme() + theme2.css do the rest).
Steps:
- Open the page in the Universal Editor — e.g.
https://experience.adobe.com/#/aem/editor/canvas/author-p153710-e1614654.adobeaemcloud.com/content/2026/36/astutejaguar70099/index.html(Not the classic Sites → Properties dialog — that dialog does not show EDS model fields.) - In the right rail, select the page root (the top-level Page node, not a block).
- Set Theme → Theme 2.
- Click Publish.
AEM writes <meta name="theme" content="theme2"> into that page. On the published .aem.page URL, loadTheme() reads it and loads theme2.css.
The theme renders on the published/preview page, not repainted live inside the UE canvas. Judge the result on the published URL.
Scenario B — A Few Pages
Goal: theme a handful of specific pages (a campaign page, a seasonal microsite).
Files involved: none — repeat Scenario A per page.
This is fine for a few exceptions. It does not scale to hundreds of pages and it does not cascade — each page is set independently. If you find yourself setting the dropdown on more than a few pages, use Scenario C.
Scenario C — A Whole Country / Folder at Once (AEM-style inheritance)
Goal: every page under /keenpanther20891 uses Theme 2, automatically, including pages that do not exist yet — set once.
This is the EDS equivalent of AEM inherited page properties, and it is done with the metadata sheet — a spreadsheet named metadata at the site content root. The docs confirm this works with AEM as the content source, not just Google/SharePoint.
File/asset involved: a new metadata spreadsheet in AEM (content, not code). No code changes.
The sheet contents
One sheet, at least two columns:
| URL | theme |
|---|---|
/** | theme1 |
/keenpanther20891/** | theme2 |
Rules that make it behave like inheritance:
**matches any depth./keenpanther20891/**covers every current and future child page. Set once, applies to all — exactly the AEM mental model.- Order matters — broad first. The site-wide
/**default must be the first row; more specific folders go below and win. - The URL column is the published path, not the
/content/...AEM path. Verify the real.aem.pagepath of a folder page first (see below). - Publish the sheet. Like any page, preview + publish the
metadatasheet for it to take effect.
Step-by-step (AEM crosswalk)
- Confirm the published path. Open any page in the target folder and note its
.aem.pageURL, e.g.https://main--eds-poc--naveenrapelly34.aem.page/keenpanther20891/about. The path after the domain (/keenpanther20891/...) is what the glob must match — not/content/2026/36/.... - Create the
metadataspreadsheet at the content root (the same level the site root/maps to). In an AEM-authored site you create it as a spreadsheet resource namedmetadata— one worksheet, the columns above. - Fill the rows top-to-bottom: broad
/**first, then each specific folder. - Preview and Publish the sheet (Sidekick / the same publish action you use for pages). This generates
/metadata.json. - Republish (or reload) an affected page and open its published URL — it now carries
<meta name="theme" content="theme2">and renders Theme 2.
At publish time, Helix injects the meta tag into every matching page. The pages themselves stay untouched — you never open them. Adding a new country later is one new row and a republish. No developer, no deploy.
Published once, the metadata sheet maps path globs to theme values:
/**→theme1(the default for the whole site)./keenpanther20891/**→theme2(one country, all of its pages).
Every page under /keenpanther20891/ — about, products, contact, and any page added later — inherits theme2 with no per-page work.
Verifying it worked
On a published country page, open DevTools and check the <head>:
<meta name="theme" content="theme2">
If it's there, loadTheme() will load theme2.css. If it's missing: the sheet wasn't published, the glob doesn't match the real path, or a page-level override is winning (see Scenario D).
Scenario D — Breaking Inheritance (per-page override)
Goal: the whole country is Theme 2, but one page inside it must be Theme 1.
Because the per-page dropdown (Scenario A) takes precedence over the metadata sheet (Scenario C), you simply open that one page in the Universal Editor and set Theme → Theme 1. Its own <meta> wins over the folder default. Every sibling stays Theme 2.
This is the direct equivalent of overriding an inherited property on a single AEM page — and it works the same way conceptually.
The Precedence Rules (the one thing to remember)
flowchart TD
A[Page renders] --> B{Page has its own<br/>Theme meta<br/>UE dropdown?}
B -->|Yes| C[Use that theme<br/>page override wins]
B -->|No| D{URL matches a row<br/>in metadata sheet?}
D -->|Yes| E[Use folder theme<br/>e.g. theme2]
D -->|No| F[Default: theme1]
Page property > metadata sheet > code default. That single ordering gives you: a site-wide default, per-country overrides, and per-page exceptions — the full AEM inheritance behavior, reassembled from static parts.
What Is Possible in EDS vs AEM
| Capability | AEM | EDS | Notes |
|---|---|---|---|
| Theme on a single page | Page property | UE Theme dropdown | Direct equivalent |
| Theme on a folder subtree | Inherited property (getInherited) | Metadata sheet /folder/** | EDS resolves at publish, not runtime |
| Override on one child page | Set property on the page | UE dropdown on the page | Direct equivalent — breaks inheritance |
| New pages auto-inherit | Yes, live | Yes, via ** glob on publish | Same outcome, different timing |
| Change theme site-wide instantly | Rollout / republish | Edit one sheet row + republish | EDS is often simpler here |
| Runtime tree walk for theme | Yes (server-side) | No — flat meta tag per page | The core architectural difference |
| No build step for theme CSS | No (ClientLib compile) | Yes — plain CSS files | EDS advantage |
| Author sees theme live while editing | Yes (in-page) | Only on published/preview render | EDS limitation |
The honest gaps
- No live inheritance at render time. EDS fakes inheritance at publish time. If content moves folders, republish so the new path picks up the right glob.
- The metadata sheet uses the published URL path, not the
/content/...AEM path. Always verify the real.aem.pagepath before writing a glob. - UE canvas does not repaint the theme reliably. Judge the result on the published page, not the editor.
Where EDS is actually better
- A theme is plain CSS — no ClientLib, no SCSS compile, no bundle. Editing a token is instant.
- Changing a whole country's look is one spreadsheet row, not a rollout.
- The theme decision lives entirely in content, so developers never touch code to add a country.
Putting It Together — A Recommended Setup
For a multi-country site:
- Code (once): one CSS file per theme in
styles/themes/, and the genericloadTheme()inscripts.js. No country names in code, ever. - Default (once): a
metadatasheet with/** → theme1as the site-wide baseline. - Per country (once each): one row per country folder, e.g.
/uk/** → theme2,/us/** → theme1. - Exceptions (as needed): the UE Theme dropdown on individual pages that must differ.
That gives you AEM-style inheritance, per-page overrides, and instant site-wide changes — with a three-line theme loader and zero hardcoding.
Key Takeaways
- EDS has no runtime inheritance; it reconstructs it as a single
<meta name="theme">tag resolved at publish time. - Keep the code generic.
loadTheme()reads the meta and loads CSS — it never decides the theme. - Single/few pages → UE Theme dropdown. Whole country → metadata sheet with
/**globs. Break inheritance → dropdown override on one page. - Precedence is always page property > metadata sheet > default.
- A theme is just CSS custom properties — no build step, which makes EDS theming simpler than AEM in day-to-day work, at the cost of live editor preview and runtime tree-walking.
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.