N
Naveenr.dev
Chapter 03
22 min read2026-06-30

EDS Blocks

Block architecture in EDS. Covers how scripts.js discovers blocks, how decorate() works, key-value versus multi-cell patterns, and common mistakes.

Content Objective

This chapter covers:

  • The exact anatomy of an EDS block and what each file does
  • How scripts.js discovers and loads blocks automatically
  • How to write a decorate() function that correctly reads authored content
  • The difference between key-value blocks and multi-cell blocks
  • How Universal Editor stores field values and how your JS reads them
  • Why some blocks break silently with no errors in the console
  • The CSS class naming rules that differ from everything you know about BEM

If Chapter 1 explained what EDS is and Chapter 2 explained how to wire it up, this chapter explains how to actually build things.

Everything in EDS is a block. Understanding how blocks work — not just the happy path but the edge cases, the constraints, and the failure modes — is the most valuable skill in EDS development.

Most production bugs in EDS projects are block bugs. Most block bugs are the same three or four mistakes repeated.

By the end of this chapter, you will recognize those mistakes instantly.

The Anatomy of a Block

A block in EDS is exactly three things:

blocks/
└── hero/
    ├── hero.js       ← The decorator: transforms raw HTML into UI
    ├── hero.css      ← Styles scoped to this block
    └── _hero.json    ← Universal Editor model (xwalk only)

Nothing else is required. No build configuration. No registration file (the registration happens in component-definition.json which we covered in Chapter 2). No server-side code.

The block name is the folder name. The JS and CSS must be named identically to the folder. This is enforced by the EDS loader — it looks for blocks/{blockname}/{blockname}.js.

Folder: blocks/adc-button/
JS:     blocks/adc-button/adc-button.js   ← must match
CSS:    blocks/adc-button/adc-button.css  ← must match

If the JS file is named button.js inside the adc-button folder, it will never load. No error. No warning. Just silence.

How scripts.js Discovers Blocks

When a page loads, scripts.js runs automatically. One of its first jobs is to find all blocks on the page and load their code.

The process works like this:

1. Page HTML arrives from AEM
2. scripts.js runs
3. scripts.js queries: document.querySelectorAll('div.block')
4. For each .block element:
   a. Read the block name from data-block-name attribute
      (or from the first class after 'block')
   b. Dynamically import: /blocks/{name}/{name}.js
   c. Call the default exported function: module.default(block)
   d. Load the CSS: /blocks/{name}/{name}.css
5. Block is decorated

The key point: scripts.js calls module.default(block) — the default export. This is why your decorator must use export default function decorate(block).

// WRONG — named export, will fail silently
export function decorate(block) {
  block.textContent = 'Hello';
}

// CORRECT — default export
export default function decorate(block) {
  block.textContent = 'Hello';
}

When you use a named export, module.default is undefined. The call undefined(block) throws a TypeError. But because scripts.js wraps block loading in a try/catch, this error is often swallowed. The block simply does not render and no visible error appears.

This is the number one silent failure in EDS blocks.

What the Block DOM Looks Like

Before we can read content from a block, we need to understand what the block's HTML actually looks like when it arrives from AEM.

When an author creates a block in Document Authoring, they create a table:

| Hero Banner             |
|-------------------------|
| desktopImage  | [image] |
| title         | My Title|
| size          | tall    |

EDS converts this table into a <div> structure:

<div class="hero-banner block" data-block-name="hero-banner" data-block-status="loading">
  <div>
    <div>desktopImage</div>
    <div><picture>...</picture></div>
  </div>
  <div>
    <div>title</div>
    <div>My Title</div>
  </div>
  <div>
    <div>size</div>
    <div>tall</div>
  </div>
</div>

Each row in the table becomes a <div> containing two child <div> elements: the key and the value.

For Universal Editor (xwalk), the structure is the same. Field values from the UE properties panel are stored in the content and rendered as the same key-value table structure.

Your decorate() function receives this raw div structure as block. Your job is to transform it into the final UI.

Two Types of Blocks

EDS blocks come in two structural patterns.

Multi-Cell Blocks (Default)

In a multi-cell block, each row represents a piece of content. The position matters more than the label.

| Cards                               |
|-------------------------------------|
| Image 1  | Title 1  | Description 1 |
| Image 2  | Title 2  | Description 2 |
| Image 3  | Title 3  | Description 3 |

In the DOM:

<div class="cards block">
  <div>
    <div><picture>...</picture></div>
    <div>Title 1</div>
    <div>Description 1</div>
  </div>
  <!-- more rows -->
</div>

Reading multi-cell content in JS is positional:

export default function decorate(block) {
  [...block.querySelectorAll(':scope > div')].forEach((row) => {
    const [imageCell, titleCell, descCell] = row.querySelectorAll(':scope > div');
    // imageCell.querySelector('picture')
    // titleCell.textContent.trim()
    // descCell.innerHTML
  });
}

Key-Value Blocks (xwalk)

In a key-value block, each row is a named field. Position does not matter — the first cell is the field name, the second cell is the value.

| Hero Banner             |
|-------------------------|
| size          | tall    |
| title         | My Title|
| desktopImage  | [image] |

This pattern is required when using Universal Editor with key-value: true in the block model, because UE can add fields in any order.

In the _block.json model file, you declare this:

{
  "definitions": [
    {
      "title": "Hero Banner",
      "id": "hero-banner",
      "plugins": {
        "xwalk": {
          "page": {
            "resourceType": "core/franklin/components/block/v1/block",
            "template": {
              "name": "hero-banner",
              "model": "hero-banner",
              "key-value": true
            }
          }
        }
      }
    }
  ]
}

Reading key-value content in JS requires parsing by key:

const KNOWN_FIELDS = new Set([
  'size', 'title', 'desktopimage', 'subtitle', 'description',
]);

export default function decorate(block) {
  const props = {};

  [...block.querySelectorAll(':scope > div')].forEach((row) => {
    const cells = [...row.querySelectorAll(':scope > div')];
    if (cells.length === 2) {
      const key = cells[0].textContent.trim().toLowerCase().replace(/[\s-]/g, '');
      if (KNOWN_FIELDS.has(key)) {
        props[key] = cells[1];
        return;
      }
    }
  });

  // Now use props.size, props.title, etc.
  const size = props.size?.textContent.trim() || 'tall';
  const title = props.title?.innerHTML || '';
}

The key normalization step is critical:

const key = cells[0].textContent
  .trim()              // Remove whitespace
  .toLowerCase()       // 'desktopImage' → 'desktopimage'
  .replace(/[\s-]/g, '');  // 'desktop image' → 'desktopimage'

This normalization makes the key matching robust regardless of spacing or casing in the authored content.

The key-value Constraint You Must Know

There is one critical constraint with key-value: true blocks in Universal Editor.

You cannot use key-value: true AND a filter (child items / "+" button) in the same block template.

If you try this combination, the UE properties panel will crash with "Something went wrong" when any author tries to edit the block.

This is because key-value: true changes how the block's internal structure is managed in UE, and child items (which use the filter system) are incompatible with that structure.

Practical implication for buttons inside a hero:

If your hero needs two buttons, you cannot use a dynamic child items approach with a key-value hero. Instead, define fixed button fields directly in the block model:

{
  "name": "button1Label", "component": "text", "label": "Button 1 Label" },
{
  "name": "button1Link",  "component": "aem-content", "label": "Button 1 Link" },
{
  "name": "button1Style", "component": "select", "label": "Button 1 Style",
  "options": [
    { "name": "Primary", "value": "primary" },
    { "name": "Secondary", "value": "secondary" }
  ]
}

This gives authors two fixed button slots. If you need dynamic buttons (add as many as needed), that requires a separate block without key-value: true.

Conditions in Block Models

Universal Editor supports showing or hiding fields based on the value of other fields using the condition property.

The condition format uses JSONLogic syntax, not shorthand.

// WRONG — causes "Unrecognized operation" crash in UE
"condition": { "size": "tall" }

// CORRECT — JSONLogic format
"condition": { "==": [{ "var": "size" }, "tall"] }

// Multiple conditions (AND)
"condition": {
  "and": [
    { "==": [{ "var": "size" }, "medium"] },
    { "==": [{ "var": "layout" }, "greybackground"] }
  ]
}

The shorthand { "fieldName": "value" } looks reasonable but it is not valid JSONLogic. UE will throw "Unrecognized operation: fieldName" and the properties panel crashes.

Always use { "==": [{ "var": "fieldName" }, "value"] } for conditions.

CSS in EDS Blocks

EDS has specific CSS conventions that differ from traditional AEM frontend development.

No BEM

EDS uses flat kebab-case class names. BEM-style double underscores (__) and double dashes (--) are not used.

/* AEM / BEM style — DO NOT USE in EDS */
.hero__content { }
.hero__content--tall { }

/* EDS style */
.hero-content { }
.hero-tall .hero-content { }

This is enforced by ESLint in the standard EDS project setup. Your CSS will fail the lint check if it uses BEM-style selectors.

Variant Classes Are on the Block Root

In traditional AEM, modifier classes are typically on child elements. In EDS, variant classes added by your block's decorate() function are added to the block root element (the .hero.block element).

// Correct — add variant to the block root
block.classList.add('hero-tall', 'hero-copy-inside-circle');

This means your CSS selectors for variants must be compound selectors (no space between .hero and the variant):

/* WRONG — this looks for .hero-tall as a child of .hero */
.hero .hero-tall { }

/* CORRECT — .hero element that also has class hero-tall */
.hero.hero-tall { }

/* CORRECT — target children inside the variant */
.hero.hero-tall .hero-section { height: 640px; }

This is a subtle but critical distinction. If you use descendant selectors for variant classes, the rules will never match and the variant will have no visual effect.

CSS Custom Properties for Design Tokens

EDS uses CSS custom properties (CSS variables) instead of Sass variables or build-time tokens.

/* Define on the block root */
.hero {
  --hero-header-color: #222731;
  --hero-section-height-desktop: 640px;
  --hero-yellow: #ffd100;
}

/* Consume anywhere within the block */
.hero .hero-header {
  color: var(--hero-header-color);
}

This approach means tokens can be changed at runtime and themed without rebuilding anything.

Block CSS Is Scoped

Each block's CSS file is loaded only when that block is on the page. This means you can freely use class names like .hero-content, .hero-section, .hero-media inside hero.css without worrying about conflicts with other blocks.

However, you must still scope everything within .hero (the block class) to avoid leaking styles to the rest of the page:

/* BAD — leaks to entire page */
.container { max-width: 1140px; }

/* GOOD — scoped to the hero block */
.hero .container { max-width: 1140px; }

Extracting Images Correctly

Images in EDS blocks come from the content as <picture> elements (for optimized responsive images) or <img> elements or URL strings.

A robust image extraction utility handles all three cases:

function extractImage(cell) {
  if (!cell) return null;

  // Case 1: picture element (most common in optimized content)
  const pic = cell.querySelector('picture');
  if (pic) return pic;

  // Case 2: bare img element
  const img = cell.querySelector('img');
  if (img) return img;

  // Case 3: link wrapping an image
  const a = cell.querySelector('a');
  if (a) {
    const linkedImg = a.querySelector('img');
    if (linkedImg) return linkedImg;
    const href = a.getAttribute('href') || '';
    if (href.startsWith('/') || href.startsWith('http')) {
      const el = document.createElement('img');
      el.src = href;
      el.alt = a.textContent.trim();
      el.loading = 'lazy';
      return el;
    }
  }

  // Case 4: raw URL as text content
  const src = cell.textContent.trim();
  if (src.startsWith('/') || src.startsWith('http')) {
    const el = document.createElement('img');
    el.src = src;
    el.alt = '';
    el.loading = 'lazy';
    return el;
  }

  return null;
}

Always use a utility like this rather than directly calling cell.querySelector('img'). If the authored content structure changes (UE sometimes wraps images in anchors), a direct querySelector will silently return null.

Building DOM Correctly

Never use innerHTML to build block structure. Use DOM APIs.

// BAD — XSS risk, hard to debug, string concatenation errors
block.innerHTML = `
  <section class="hero-section">
    <div class="hero-content">
      <h1>${props.title}</h1>
    </div>
  </section>
`;

// GOOD — safe, explicit, easy to inspect in DevTools
const section = document.createElement('section');
section.className = 'hero-section';

const content = document.createElement('div');
content.className = 'hero-content';

const h1 = document.createElement('h1');
h1.className = 'hero-header';
h1.innerHTML = props.title.innerHTML;  // innerHTML from a trusted DOM element is fine

content.append(h1);
section.append(content);
block.append(section);

The only exception is when copying innerHTML from an already-sanitized DOM element (like a cell from the authored content). That is safe because the content was already parsed by the browser and does not contain user input.

The block.innerHTML = '' Pattern

A common pattern in EDS blocks is to clear the block's original content at the start of decorate() and rebuild it completely:

export default function decorate(block) {
  // Parse original content
  const allRows = [...block.querySelectorAll(':scope > div')];
  const props = {};
  // ... parse rows into props ...

  // Clear and rebuild
  block.innerHTML = '';

  // Build new DOM structure
  const section = document.createElement('section');
  // ...
  block.append(section);
}

This is the standard approach. The reason: the original key-value table rows in the HTML are not the final UI. You parse the data out of them, then replace the entire block content with the correctly structured UI.

A Complete Minimal Block Example

Here is the smallest possible working key-value block:

// blocks/info-card/info-card.js

const FIELDS = new Set(['title', 'description', 'image']);

export default function decorate(block) {
  const props = {};

  [...block.querySelectorAll(':scope > div')].forEach((row) => {
    const [keyCell, valCell] = [...row.querySelectorAll(':scope > div')];
    if (keyCell && valCell) {
      const key = keyCell.textContent.trim().toLowerCase().replace(/[\s-]/g, '');
      if (FIELDS.has(key)) props[key] = valCell;
    }
  });

  block.innerHTML = '';

  const card = document.createElement('div');
  card.className = 'info-card-inner';

  if (props.image) {
    const imgWrap = document.createElement('div');
    imgWrap.className = 'info-card-image';
    imgWrap.append(props.image.querySelector('picture') || props.image.querySelector('img'));
    card.append(imgWrap);
  }

  if (props.title) {
    const h2 = document.createElement('h2');
    h2.className = 'info-card-title';
    h2.textContent = props.title.textContent.trim();
    card.append(h2);
  }

  if (props.description) {
    const p = document.createElement('p');
    p.className = 'info-card-body';
    p.innerHTML = props.description.innerHTML;
    card.append(p);
  }

  block.append(card);
}
/* blocks/info-card/info-card.css */
.info-card {
  --card-border-radius: 8px;
  --card-padding: 1.5rem;
}

.info-card-inner {
  border-radius: var(--card-border-radius);
  padding: var(--card-padding);
  box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}

.info-card-image img {
  width: 100%;
  height: auto;
  border-radius: var(--card-border-radius) var(--card-border-radius) 0 0;
}

.info-card-title {
  font-size: 1.25rem;
  font-weight: 700;
  margin: 1rem 0 0.5rem;
}

.info-card-body {
  color: #555;
  line-height: 1.6;
}

This example demonstrates every key concept: key parsing with normalization, a KNOWN_FIELDS set, DOM building, and scoped CSS.

The Most Common Block Mistakes

After working through complex blocks, these are the mistakes that appear most frequently.

Mistake 1: Named export instead of default export

Already covered, but worth repeating because it is the most common:

// Silent failure
export function decorate(block) { }

// Correct
export default function decorate(block) { }

Mistake 2: Block folder name does not match file names

blocks/
└── hero-banner/
    ├── hero.js       ← WRONG — must be hero-banner.js
    └── hero.css      ← WRONG — must be hero-banner.css

Mistake 3: Using the wrong selector to query rows

// WRONG — queries ALL divs, includes nested divs from content
block.querySelectorAll('div')

// CORRECT — only direct children
block.querySelectorAll(':scope > div')

The :scope > div selector is critical. Without it, you pick up nested divs from within cell content (images, rich text) as if they were row containers.

Mistake 4: Variant classes as descendant selectors

/* Never matches — hero-tall is on the block root, not a child */
.hero .hero-tall { }

/* Correct — compound selector */
.hero.hero-tall { }

Mistake 5: Not overriding base block styles for variant elements

If your block has base CSS rules like:

.hero .hero-image {
  position: absolute;
  top: 0; left: 0; width: 100%; height: 100%;
}

And a variant places .hero-image in a different layout context, you must explicitly override those base rules in the variant:

.hero.hero-medium.hero-image-in-circle .hero-image {
  position: relative;
  top: auto; left: auto;
  width: auto; height: auto;
}

Not overriding base rules is a common cause of "image not showing" and "element in wrong position" bugs in variant blocks.

Mistake 6: Not accounting for the picture element

EDS wraps responsive images in <picture> elements. If you do cell.querySelector('img') instead of cell.querySelector('picture') || cell.querySelector('img'), the image will load but you will lose the responsive source sets.

Mistake 7: Modifying block.classList before it has variant classes

In a complex block, the variant classes (added via block.classList.add()) must be added before you build the DOM, because the DOM structure for different variants may differ.

export default function decorate(block) {
  // Parse fields first
  const props = parseProps(block);
  const size = props.size?.textContent.trim() || 'tall';

  // Add variant classes BEFORE clearing and rebuilding
  block.classList.add(`hero-${size}`);

  // Now clear and rebuild
  block.innerHTML = '';
  // ...
}

Key Takeaways

  • Every block needs three files: {name}.js, {name}.css, _{name}.json — all named identically to the folder
  • export default function decorate(block) is mandatory — named exports cause silent failure
  • Two block patterns exist: multi-cell (positional) and key-value (named fields)
  • key-value: true and child items (filter) cannot coexist in the same UE block template
  • Conditions use JSONLogic: { "==": [{ "var": "field" }, "value"] } — shorthand causes a crash
  • Variant classes are on the block root — use compound selectors .hero.hero-tall, not .hero .hero-tall
  • CSS uses flat kebab-case — no BEM __ or -- notation
  • Always use :scope > div to query direct row children
  • Base CSS rules can conflict with variant layouts — always override explicitly
  • Parse, then clear, then rebuild — the standard block decoration pattern

Next Steps

In the next chapter, we will cover the Project Folder Structure in depth.

We will walk through every file and folder in the EDS project, explain what happens if you rename or delete them, and cover the naming conventions that the framework enforces.

We will also cover:

  • How scripts.js loads in phases (eager, lazy, delayed)
  • What goes in styles.css vs lazy-styles.css
  • The fonts.css loading strategy and why it matters for CLS
  • How icons are loaded and when to inline vs reference them

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.