N
Naveenr.dev
Chapter 10
24 min read2026-06-30

CSS Architecture in EDS

CSS architecture in EDS. Covers block scoping, custom properties, cascade order, responsive rules, theming, and reuse without a build step.

Content Objective

This chapter covers:

  • Why EDS uses no-BEM CSS and what replaces it
  • How CSS custom properties replace SCSS variables in EDS
  • The token hierarchy: global → block-level → variant-level
  • The cascade order for base rules, variant overrides, and responsive breakpoints
  • The exact breakpoints used in EDS and the mobile-first approach
  • How to scope block CSS correctly to prevent leakage
  • How to build a multi-brand theme system with CSS custom properties
  • Reusing styles across blocks without a preprocessor or build step
  • The most common CSS architecture mistakes in EDS projects

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.


Why No BEM

EDS's linting configuration explicitly disallows BEM class naming (__ double underscore, -- double dash for modifiers in BEM sense).

The reasons are practical:

  1. 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.

  2. 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.

  3. 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.

What Replaces BEM

BEM PurposeEDS Replacement
Block identifierThe 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 modifierCSS 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.


CSS Custom Properties as Design Tokens

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.

Token Hierarchy

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.

Level 1: Global Tokens in styles.css

/* 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.

Level 2: Block Tokens in block.css

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.

Level 3: Theme Tokens in theme files

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.


The Cascade Order

Understanding the CSS cascade in EDS is critical for predicting which rule wins.

Load Order (earliest to latest)

  1. Browser default styles
  2. styles/styles.css (global critical — eager phase)
  3. Block CSS files (loaded per-block as blocks are discovered — lazy phase)
  4. styles/lazy-styles.css (global non-critical — lazy phase)
  5. Theme CSS files (if loaded via scripts.js)

A rule in a block's CSS file can override a rule in styles.css only if it has higher specificity.

Rule Priority Within a Block's CSS

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; }

Breakpoints and Mobile-First

EDS uses a mobile-first responsive approach. The base CSS (no media query) targets mobile. Media queries add tablet and desktop behavior.

The Standard EDS Breakpoints

/* 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-First Block Layout

/* 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));
  }
}

Scoping Block CSS Correctly

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;
}

The Two-Level Scoping Rule

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; }

Exception: Block Variant Selectors

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); }

Sharing CSS Across Blocks Without a Build Tool

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.

Pattern 1: Global CSS for Truly Shared Utilities

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.

Pattern 2: CSS Custom Property Inheritance

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.

Pattern 3: Shared Button CSS via styles.css

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.


The Section-Level CSS Pattern

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.


Multi-Theme CSS Architecture

A complete multi-brand theme system in EDS uses three layers:

Layer 1: Brand-neutral global tokens

/* 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;
}

Layer 2: Block CSS uses only semantic tokens

/* 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);
}

Layer 3: Brand theme overrides semantic tokens

/* 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.


Common CSS Architecture Mistakes

Mistake 1: Hardcoded hex values in block CSS

/* BAD — not themeable, not maintainable */
.hero .hero-header { color: #ffd100; }

/* GOOD — uses token */
.hero .hero-header { color: var(--color-primary); }

Mistake 2: Using descendant selector for variant rules

/* NEVER MATCHES — hero-tall is on the block root */
.hero .hero-tall .hero-section { height: 900px; }

/* CORRECT */
.hero.hero-tall .hero-section { height: 900px; }

Mistake 3: Pixel values instead of CSS custom properties for spacing

/* 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);
}

Mistake 4: Not accounting for content inherited from global styles

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.

Mistake 5: Loading fonts in styles.css

/* 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.


Key Takeaways

  • No BEM — use flat kebab-case: .hero-section not .hero__section, .hero.hero-tall not .hero--tall
  • CSS custom properties replace SCSS variables — defined at :root, available everywhere at runtime
  • Three-level token hierarchy: global (styles.css) → block (.hero { --hero-... }) → theme (themes/brand/brand.css)
  • Override tokens in variants, not property values — single property declaration, token override per variant
  • Always scope to block class.hero .hero-section, never bare .hero-section
  • Compound selectors for variants.hero.hero-tall, not .hero .hero-tall (no space)
  • Two standard breakpoints: min-width: 600px (tablet), min-width: 900px (desktop)
  • Mobile-first — base styles target mobile, media queries add larger viewport behavior
  • Shared button/utility CSS goes in styles.css — not duplicated per block
  • Section styling uses metadata classesmain > div.dark-bg, not a separate block

Next Steps

In 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.

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.