Edge Delivery Services · 16 of 29
CSS architecture in EDS. Covers block scoping, custom properties, cascade order, responsive rules, theming, and reuse without a build step.
This chapter covers:
Coming from AEM, your CSS mental model is probably: SCSS variables → ClientLib compilation → BEM class naming → delivered bundle. In EDS, every one of those assumptions changes.
The switch from AEM CSS to EDS CSS is not about learning new syntax. It is about unlearning a build pipeline that no longer exists.
EDS's linting configuration explicitly disallows BEM class naming (__ double underscore, -- double dash for modifiers in BEM sense).
The reasons are practical:
BEM was designed for large-team CSS isolation in global stylesheets. In EDS, each block's CSS is a separate file loaded only when that block is on the page. The isolation problem BEM solves does not exist at the same scale.
BEM double-dash conflicts with CSS custom properties. -- is the prefix for CSS custom properties (variables). A class name like .hero--tall creates visual confusion with --hero-tall (a variable). EDS chooses flat kebab-case to eliminate that confusion.
Variant classes live on the block root, not child elements. BEM's modifier concept (.hero--tall on a child) doesn't match how EDS variants work (.hero-tall on the block root element). The pattern is fundamentally different.
| BEM Purpose | EDS Replacement |
|---|---|
| Block identifier | The block's root class (.hero, .cards) |
Element (__) | Child class with block prefix (.hero-section, .hero-header) |
Modifier (--) | Variant class on block root (.hero-tall, .hero-dark) |
| Theme modifier | CSS custom property override in theme file |
/* BEM style — DO NOT USE in EDS */
.hero { }
.hero__section { }
.hero__header { }
.hero--tall .hero__section { }
.hero--tall .hero__header { }
/* EDS style */
.hero { }
.hero .hero-section { }
.hero .hero-header { }
.hero.hero-tall .hero-section { }
.hero.hero-tall .hero-header { }
The structure is the same, the syntax is different. Note the critical detail: .hero.hero-tall (compound — no space) for the variant, and .hero.hero-tall .hero-section (compound then descendant) for elements inside the variant.
In AEM, design tokens live in SCSS variables ($color-yellow: #ffd100). These are compiled at build time and do not exist at runtime.
In EDS, there is no build time. Tokens are CSS custom properties (CSS variables), which live in the browser and are readable and overridable at runtime.
EDS uses a three-level token system:
Level 1: Global tokens — styles/styles.css
Level 2: Block tokens — blocks/{name}/{name}.css
Level 3: Theme tokens — themes/{brand}/{brand}.css (optional)
Each level can read and override the previous level.
/* styles/styles.css */
:root {
/* Brand Colors */
--color-primary: #ffd100;
--color-primary-dark: #e6bc00;
--color-primary-light: #fff0aa;
--color-neutral-900: #222731;
--color-neutral-100: #f7f7f9;
--color-white: #fff;
--color-black: #000;
/* Typography */
--font-family-sans: 'Myriad Pro', 'Open Sans', sans-serif;
--font-family-mono: 'Courier New', monospace;
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
--font-size-2xl: 1.5rem; /* 24px */
--font-size-3xl: 2rem; /* 32px */
--font-size-4xl: 2.5rem; /* 40px */
--font-size-5xl: 3rem; /* 48px */
/* Spacing */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
/* Layout */
--section-max-width: 1440px;
--section-padding-inline: 1.5rem;
--content-max-width: 1200px;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 3px rgb(0 0 0 / 12%);
--shadow-md: 0 4px 12px rgb(0 0 0 / 12%);
--shadow-lg: 0 8px 24px rgb(0 0 0 / 16%);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 400ms ease;
/* Z-index scale */
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-modal: 300;
--z-toast: 400;
}
Why define everything at :root? CSS custom properties defined at :root are available to every element on the page. Any block can read var(--color-primary) without importing anything.
Blocks define their own tokens, scoped to the block:
/* blocks/hero/hero.css */
.hero {
/* Block-specific tokens — consume global tokens */
--hero-bg-color: var(--color-neutral-900);
--hero-text-color: var(--color-white);
--hero-heading-size: var(--font-size-5xl);
--hero-section-height-mobile: 480px;
--hero-section-height-tablet: 700px;
--hero-section-height-desktop: 640px;
--hero-content-padding-top: 104px;
/* Local defaults — no global token equivalent */
--hero-gradient: linear-gradient(180deg, #ffd100 50%, #fff0aa 100%);
--hero-circle-size: 720px;
--hero-image-overlap: 360px;
}
Now within the hero block, use only the block tokens — never the global tokens directly:
/* Good — uses block token, making overrides easy */
.hero .hero-header {
color: var(--hero-text-color);
font-size: var(--hero-heading-size);
}
/* Avoid — uses global token directly, makes block-level theming harder */
.hero .hero-header {
color: var(--color-white);
font-size: var(--font-size-5xl);
}
Why this matters: if you need to change the hero heading color in one variant, you override --hero-text-color in the variant selector — one line of CSS. If you used var(--color-white) directly, you have to re-specify the property name in every rule that uses it.
Theme files override global tokens for a specific brand:
/* themes/brand-b/brand-b.css */
:root {
/* Override brand color */
--color-primary: #0052cc;
--color-primary-dark: #003d99;
--color-primary-light: #b3ceff;
/* Override typography */
--font-family-sans: 'Inter', 'Helvetica Neue', sans-serif;
}
Because block tokens consume global tokens (--hero-bg-color: var(--color-neutral-900)), and theme tokens override global tokens, the entire block palette re-themes automatically without touching any block CSS.
Understanding the CSS cascade in EDS is critical for predicting which rule wins.
styles/styles.css (global critical — eager phase)styles/lazy-styles.css (global non-critical — lazy phase)A rule in a block's CSS file can override a rule in styles.css only if it has higher specificity.
Structure your block CSS in this order:
/* 1. Block tokens (lowest specificity — customizable) */
.hero {
--hero-height: 640px;
}
/* 2. Block base rules (applies to all instances) */
.hero .hero-section {
height: var(--hero-height);
}
/* 3. Variant rules (higher specificity — overrides base) */
.hero.hero-tall .hero-section {
--hero-height: 900px; /* Override token for this variant */
}
/* 4. Responsive overrides (at each breakpoint, same specificity as base) */
@media (max-width: 599px) {
.hero .hero-section {
--hero-height: 480px; /* Mobile token value */
height: var(--hero-height);
}
}
Key technique: Override tokens in variants, not property values. This keeps the property declaration in one place (the base rule) and variant-specific token overrides in variant rules.
/* Anti-pattern — duplicates the height property declaration in every variant */
.hero .hero-section { height: 640px; }
.hero.hero-tall .hero-section { height: 900px; }
.hero.hero-medium .hero-section { height: 500px; }
.hero.hero-short .hero-section { height: 320px; }
/* Better — single property declaration, variant just overrides the token */
.hero { --hero-section-height: 640px; }
.hero .hero-section { height: var(--hero-section-height); }
.hero.hero-tall { --hero-section-height: 900px; }
.hero.hero-medium { --hero-section-height: 500px; }
.hero.hero-short { --hero-section-height: 320px; }
EDS uses a mobile-first responsive approach. The base CSS (no media query) targets mobile. Media queries add tablet and desktop behavior.
/* Mobile: base styles, no query (< 600px) */
.hero .hero-header { font-size: 2rem; }
/* Tablet: 600px and above */
@media (min-width: 600px) {
.hero .hero-header { font-size: 2.5rem; }
}
/* Desktop: 900px and above */
@media (min-width: 900px) {
.hero .hero-header { font-size: 3rem; }
}
The boilerplate uses exactly these two breakpoints: 600px (tablet) and 900px (desktop). These are intentionally simple — two breakpoints cover the vast majority of responsive design needs.
If you need additional breakpoints for edge cases, add them consistently:
/* Large desktop — use sparingly */
@media (min-width: 1200px) {
.hero .hero-section { max-width: 1440px; }
}
/* Mobile: single column (block) */
.cards .cards-items {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
/* Tablet: 2 columns */
@media (min-width: 600px) {
.cards .cards-items {
flex-direction: row;
flex-wrap: wrap;
}
.cards .cards-item {
flex: 0 0 calc(50% - var(--space-3));
}
}
/* Desktop: 3 columns */
@media (min-width: 900px) {
.cards .cards-item {
flex: 0 0 calc(33.333% - var(--space-4));
}
}
Every rule in a block's CSS file must be scoped to the block class. If a rule is not scoped, it leaks to the entire page.
/* LEAKS — applies to every h1 on the page, not just inside .hero */
h1 {
font-size: 3rem;
}
/* CORRECT — scoped to hero block */
.hero h1,
.hero .hero-header {
font-size: 3rem;
}
All block rules follow the pattern:
.{block-name} .{child-class} { }
Or for the block root itself:
.{block-name} { }
Never write a bare class without the block prefix inside a block's CSS file:
/* BAD — can conflict with any other block that has .button */
.button { border-radius: 4px; }
/* GOOD — scoped */
.hero .button { border-radius: 4px; }
Variant rules use the compound selector (no space):
/* Variant on the block root — compound selector */
.hero.hero-yellow { --hero-bg-color: var(--color-primary); }
/* Element inside a variant — compound then descendant */
.hero.hero-yellow .hero-section { background: var(--hero-bg-color); }
In AEM, shared CSS lives in a base ClientLib that other ClientLibs depend on. In EDS, there is no dependency system for CSS. Here are the patterns that work.
If multiple blocks use the same utility class, define it in styles/lazy-styles.css:
/* styles/lazy-styles.css */
/* Visually hidden (accessibility) — used by many blocks */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip to content link — used by all pages */
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: var(--space-2) var(--space-4);
background: var(--color-primary);
z-index: var(--z-modal);
transition: top var(--transition-fast);
}
.skip-link:focus {
top: 0;
}
Only put classes here if they will be used in at least 3 different blocks. Otherwise it is premature abstraction.
Instead of sharing CSS rules, share design intent through tokens. Both blocks read the same global token:
/* blocks/hero/hero.css */
.hero .btn-primary {
background: var(--color-primary); /* reads global token */
color: var(--color-neutral-900);
}
/* blocks/cards/cards.css */
.cards .btn-primary {
background: var(--color-primary); /* same token, different scope */
color: var(--color-neutral-900);
}
When brand changes --color-primary, both blocks update automatically.
For UI elements that appear in many blocks (buttons, badges, tags), define their base styles in styles.css using global classes:
/* styles/styles.css — button base styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-full);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: 700;
text-transform: capitalize;
text-decoration: none;
cursor: pointer;
border: 2px solid transparent;
transition: background var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
.btn:focus-visible {
outline: 2px solid var(--color-black);
outline-offset: 2px;
}
.btn-primary {
background: var(--color-primary);
color: var(--color-neutral-900);
border-color: var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
}
.btn-secondary {
background: transparent;
color: var(--color-neutral-900);
border-color: var(--color-neutral-900);
}
.btn-secondary:hover {
background: var(--color-neutral-900);
color: var(--color-white);
}
Now every block that uses .btn.btn-primary gets consistent styling without each block defining its own button rules.
Sections (the <div> containers inside <main>) can be styled using section metadata. In the authored content:
| Section Metadata |
|---------------------|
| Style | dark-bg |
This produces <div class="dark-bg"> on the section element.
In styles.css or lazy-styles.css:
/* Section variant styles */
main > div.dark-bg {
background: var(--color-neutral-900);
color: var(--color-white);
}
main > div.yellow-bg {
background: var(--color-primary);
color: var(--color-neutral-900);
}
main > div.contained {
max-width: var(--content-max-width);
margin-inline: auto;
padding-inline: var(--section-padding-inline);
}
This is the EDS way to apply background colors and layout constraints to entire page sections — no extra block needed.
A complete multi-brand theme system in EDS uses three layers:
/* styles/styles.css — semantic tokens, not brand-specific */
:root {
--color-primary: #ffd100; /* Yellow — default brand */
--color-primary-dark: #e6bc00;
--font-family-sans: 'Myriad Pro', sans-serif;
--heading-weight: 700;
--border-radius-btn: 9999px;
}
/* blocks/hero/hero.css — no hex values, only var() references */
.hero {
--hero-accent: var(--color-primary);
}
.hero.hero-yellow {
background: var(--color-primary);
}
.hero .btn-primary {
background: var(--color-primary);
border-radius: var(--border-radius-btn);
}
/* themes/brand-b/brand-b.css */
:root {
--color-primary: #0052cc;
--color-primary-dark: #003d99;
--font-family-sans: 'Inter', sans-serif;
--heading-weight: 600;
--border-radius-btn: var(--radius-md); /* Rectangular buttons for brand-b */
}
In scripts.js, load the theme CSS based on the page's template metadata or URL:
// scripts/scripts.js
const template = getMetadata('template');
const brand = getMetadata('brand');
if (brand) {
await loadCSS(`/themes/${brand}/${brand}.css`);
}
When Brand B theme loads, every block that uses var(--color-primary) and var(--border-radius-btn) updates to Brand B values automatically — without any block CSS changes.
/* BAD — not themeable, not maintainable */
.hero .hero-header { color: #ffd100; }
/* GOOD — uses token */
.hero .hero-header { color: var(--color-primary); }
/* NEVER MATCHES — hero-tall is on the block root */
.hero .hero-tall .hero-section { height: 900px; }
/* CORRECT */
.hero.hero-tall .hero-section { height: 900px; }
/* BAD — spacing is not consistent across blocks */
.hero .hero-content { padding: 104px 48px; }
/* GOOD — uses spacing scale */
.hero .hero-content {
padding: var(--hero-content-padding-top) var(--space-12);
}
If styles.css sets:
h1 { font-size: var(--font-size-5xl); }
And your block also sets:
.hero .hero-header { font-size: var(--hero-heading-size); }
Both rules have different specificity. The global h1 rule has specificity 1 (element). Your block rule has specificity 20 (class + class + element approximation). Your block rule wins — which is correct. But if the author uses an actual <h1> element inside the hero, it will pick up both rules. The block rule wins due to specificity, but you need to be aware of the inheritance.
/* BAD — fonts in critical CSS block render */
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
}
Fonts belong in styles/fonts.css which loads in the lazy phase. Adding @font-face to styles.css does not block rendering on its own (font loading is lazy by default), but keeping fonts in fonts.css is the convention and ensures they load at the right phase.
.hero-section not .hero__section, .hero.hero-tall not .hero--tall:root, available everywhere at runtimestyles.css) → block (.hero { --hero-... }) → theme (themes/brand/brand.css).hero .hero-section, never bare .hero-section.hero.hero-tall, not .hero .hero-tall (no space)min-width: 600px (tablet), min-width: 900px (desktop)styles.css — not duplicated per blockmain > div.dark-bg, not a separate blockIn the next chapter, we cover the topic most xwalk developers stumble on: Universal Editor Annotations.
Without data-aue-resource, data-aue-type, and data-aue-prop attributes on your rendered HTML, clicking on a block in the UE editor does nothing. We will cover the complete annotation system, every attribute type, and how to add annotations correctly in your block's decorate() function.
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.