Duration: 20 minutes
Quick navigation
- Context
- Hands-on Lab
- Key Takeaways
Complete SETUP.md if not already done. Exercises can be done in sequence or independently; if independent, ensure SETUP is done and you have the items below.
Required:
- On your feature branch (
jsmith— first initial + last name, lowercase) - Local dev server at
http://localhost:3000 - Code editor open with the repository
- Exercise 1 completed (if doing in sequence)
Verify you're on your branch:
git branch
# Should show: * jsmith (your name)- How the block decoration lifecycle works
- How to enhance a block by detecting content patterns (eyebrow from
<em>) - How to implement a block variation (list layout)
- How to write mobile-first responsive CSS
- The difference between a block enhancement and a block variation
In Exercise 1, you created content as an author. Now you'll see the developer side - writing the code that transforms authored content into styled, interactive components.
The Block Contract: Authors create tables with a specific structure. Developers write JavaScript to decorate (transform) that HTML into the final presentation.
There are two ways to extend a block's capabilities:
Block Enhancements make the block smarter by detecting content patterns:
- Authors use familiar formatting (bold, italic, links) in their content
- The block recognizes these patterns and renders them specially
- No extra configuration needed — it just works
Block Variations can be used to change the block's layout via a class:
- Authors add variation names in parentheses:
Cards (List) - CSS/JS targets the variation class for a different presentation
- Same block codebase → multiple layouts
Real-world scenario: A Cards block can detect italic text and render it as an eyebrow label (enhancement), or switch to a single-column list layout (variation).
1. Author creates table in DA.live
| Cards | |
|-------|----------------------------|
| [image] | *Speaker* (italic) |
| | John Doe |
| | Senior Developer |
2. EDS converts to HTML (before decoration)
<div class="cards">
<div>
<div><picture>...</picture></div>
<div>
<p><em>Speaker</em></p>
<p>John Doe</p>
<p>Senior Developer</p>
</div>
</div>
</div>
3. Your decorate() function transforms it
- Classifies divs as image or body
- Detects <em> in body → extracts as eyebrow label
- Removes the <em> paragraph from body
- Prepends eyebrow div to card body
4. Final HTML (after decoration)
<div class="cards">
<ul>
<li>
<div class="cards-card-image"><picture>...</picture></div>
<div class="cards-card-body">
<div class="cards-card-eyebrow">Speaker</div>
<p>John Doe</p>
<p>Senior Developer</p>
</div>
</li>
</ul>
</div>
5. CSS styles the decorated HTML
Reference: Exploring Blocks
Every block has:
blocks/
cards/
cards.js - Decoration logic (required)
cards.css - Styles (required)
Entrypoint: The runtime calls the default export of each block's .js file. That export must be a decorate(block) function — same for every block. EDS passes the block's root DOM element; your code transforms it.
Naming convention: File names must match block name exactly.
Variation classes: Authors add variations in parentheses:
| Cards (List) |
Becomes:
<div class="cards list">Your CSS/JS can then target .cards.list for variation-specific styling.
Reference: Anatomy of a Project
The repository already has a Cards block. Let's review it:
File: blocks/cards/cards.js
import { createOptimizedPicture } from '../../scripts/aem.js';
export default function decorate(block) {
const ul = document.createElement('ul');
[...block.children].forEach((row) => {
const li = document.createElement('li');
while (row.firstElementChild) li.append(row.firstElementChild);
[...li.children].forEach((div) => {
if (div.children.length === 1 && div.querySelector('picture')) {
div.className = 'cards-card-image';
} else {
div.className = 'cards-card-body';
}
});
ul.append(li);
});
ul.querySelectorAll('picture > img').forEach((img) => {
img.closest('picture').replaceWith(
createOptimizedPicture(img.src, img.alt, false, [{ width: '750' }])
);
});
block.replaceChildren(ul);
}What it does:
- Creates
<ul>list - Each row becomes
<li>(each card) - Classifies content as image or body
- Replaces each image with a responsive, size-optimized
<picture>via createOptimizedPicture() inaem.js - Replaces original block content
File: blocks/cards/cards.css - Uses CSS Grid for responsive layout.
In DA.live, create page: /drafts/jsmith/cards-test (replace jsmith with your name)
Add content to test the existing Cards block (default behavior). Use the Block Library so you get the correct block markup:
- Type
/→ Library (or Blocks) → find Cards and preview variations. - Insert default Cards (add a heading like "Default Cards" if you like).
- Insert the Cards block that includes the eyebrow (italic label). Add a heading like Cards with Eyebrow above it — you'll use this when we add the eyebrow enhancement in Step 3.
You'll add List and View Switcher content later when we implement those variations (Steps 5 and 7). DA.live auto-saves. Click Preview to see the page on localhost, then continue to Step 2.
Open: http://localhost:3000/drafts/jsmith/cards-test (replace jsmith with your name)
Test on desktop and mobile: Use Chrome DevTools responsive view — open DevTools (F12 or Cmd+Option+I), toggle the device toolbar (Cmd+Shift+M / Ctrl+Shift+M) to switch to responsive mode, then resize the viewport or pick a device preset to verify layout at different widths. Use this for all test steps in this exercise.
You should see:
-
Default Cards section showing cards in a grid
-
A second section with cards that have italic (eyebrow) text — still rendered as plain text for now
Why? The eyebrow enhancement isn't implemented yet. We'll add it in the next step.
The eyebrow adds a label above card content. Authors simply italicize the label text in the card body, and the block automatically detects and extracts it as the eyebrow. No variation class needed — this is a smart default behavior.
Author structure (each row = one card, 2 columns):
- Column 1: Image
- Column 2: Body text — italicize the eyebrow label (e.g., Speaker)
How it works: In DA.live, italic text becomes <em> in HTML. The block finds <em> in each card's body, extracts it, and renders it as an eyebrow label. If there's no <em>, nothing changes — the card renders normally.
File: blocks/cards/cards.js
Replace the entire decorate function with:
import { createOptimizedPicture } from '../../scripts/aem.js';
export default function decorate(block) {
const ul = document.createElement('ul');
[...block.children].forEach((row) => {
const li = document.createElement('li');
while (row.firstElementChild) li.append(row.firstElementChild);
[...li.children].forEach((div) => {
if (div.children.length === 1 && div.querySelector('picture')) {
div.className = 'cards-card-image';
} else {
div.className = 'cards-card-body';
}
});
// Detect italic text in body and extract as eyebrow label
const body = li.querySelector('.cards-card-body');
const em = body?.querySelector('em');
if (em) {
const eyebrow = document.createElement('div');
eyebrow.className = 'cards-card-eyebrow';
eyebrow.textContent = em.textContent;
const p = em.closest('p');
if (p) p.remove();
else em.remove();
body.prepend(eyebrow);
}
ul.append(li);
});
ul.querySelectorAll('picture > img').forEach((img) => {
img.closest('picture').replaceWith(
createOptimizedPicture(img.src, img.alt, false, [{ width: '750' }])
);
});
block.replaceChildren(ul);
}What changed:
- After classifying divs, looks for
<em>in each card's body - If found, extracts the italic text as the eyebrow label
- Removes the
<p>containing the<em>from the body - Prepends an eyebrow div at the top of the card body
- No variation class needed — works automatically on any Cards block
- Cards without italic text are unaffected
File: blocks/cards/cards.css
Add at the end of the file:
.cards .cards-card-eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
color: var(--brand-1);
}Key points:
.cards .cards-card-eyebrow— scoped to the block, no variation class needed- All selectors start with
.cards(proper scoping)
Refresh: http://localhost:3000/drafts/jsmith/cards-test (replace jsmith with your name)
You should see:
-
The section with the eyebrow heading now shows "SPEAKER" (or your label) at the top of each card's body
-
Label is uppercase, smaller font, brand color
-
The italic text you authored is no longer displayed inline — it's been extracted into the eyebrow
-
Default Cards section is unaffected (no italic text = no eyebrow)
The list variation displays cards in a single column with centered text. Authors opt in by writing Cards (List) as the block name.
Add content to test it: In DA.live, open your cards-test page. Type / → Library → Blocks → insert Cards (List) (add a heading like "List Variation" if you like). Preview so the page has a section to verify in Step 6.
File: blocks/cards/cards.css
Add at the end of the file:
/* List layout — used by Cards (List) variant and View Switcher toggle */
.cards.list > ul {
grid-template-columns: 1fr;
}
.cards.list .cards-card-body {
text-align: center;
}What this does:
- Forces single column layout (overrides default grid)
- Centers body text
- Images stay full-width (inherited from base styles)
No JavaScript needed - this variation is CSS-only!
Refresh: http://localhost:3000/drafts/jsmith/cards-test (replace jsmith with your name)
You should see:
- List section displays as a single column
- Cards are full width with full-width images
- Text is centered
Now let's add a variation that combines JavaScript and CSS. The view switcher adds toggle buttons that let users switch between grid and list views on the fly.
Add content to test it: In DA.live, open your cards-test page. Type / → Library → Blocks → insert Cards (View Switcher) (add a heading like "View Switcher" if you like). Preview so the page has a section to verify in Step 8.
This is different from list: List is a fixed layout chosen by the author. View switcher gives the end user control over the layout.
File: blocks/cards/cards.js
Add this code at the end of the decorate function, after block.replaceChildren(ul):
if (block.classList.contains('view-switcher')) {
const toolbar = document.createElement('div');
toolbar.className = 'cards-toolbar';
const isList = block.classList.contains('list');
const gridBtn = document.createElement('button');
gridBtn.textContent = 'Grid';
gridBtn.className = `cards-toolbar-btn${isList ? '' : ' active'}`;
const listBtn = document.createElement('button');
listBtn.textContent = 'List';
listBtn.className = `cards-toolbar-btn${isList ? ' active' : ''}`;
gridBtn.addEventListener('click', () => {
block.classList.remove('list');
gridBtn.classList.add('active');
listBtn.classList.remove('active');
});
listBtn.addEventListener('click', () => {
block.classList.add('list');
listBtn.classList.add('active');
gridBtn.classList.remove('active');
});
toolbar.append(gridBtn, listBtn);
block.prepend(toolbar);
}What this does:
- Checks for the
view-switchervariation class - Detects whether
listis already on the block (e.g.Cards (List, View Switcher)) and highlights the correct default button - Grid button removes the
listclass → grid layout - List button adds the
listclass → single column layout (same CSS asCards (List)) - Active button is highlighted
File: blocks/cards/cards.css
Add the toolbar styles at the end of the file:
/* View switcher toolbar — hidden on narrow viewports, shown from 600px up */
.cards.view-switcher .cards-toolbar {
display: none;
justify-content: flex-end;
gap: 8px;
margin-bottom: 1rem;
}
@media (width >= 600px) {
.cards.view-switcher .cards-toolbar {
display: flex;
}
}
.cards.view-switcher .cards-toolbar-btn {
background: transparent;
border: 1px solid rgb(255 255 255 / 20%);
color: var(--muted);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s ease, color 0.2s ease;
}
.cards.view-switcher .cards-toolbar-btn:hover {
border-color: rgb(255 255 255 / 40%);
}
.cards.view-switcher .cards-toolbar-btn.active {
background: var(--brand-1);
color: white;
border-color: var(--brand-1);
}Key insight: The view switcher toggles the list class — the same class used by Cards (List). No duplicate layout CSS needed. When the user clicks "List", the .cards.list styles from Step 5 kick in automatically.
Refresh: http://localhost:3000/drafts/jsmith/cards-test (replace jsmith with your name)
Note: The Grid/List toolbar is hidden on narrow viewports and appears at 600px width and up. Use Chrome DevTools responsive view at ≥600px to see and use the buttons.
You should see:
- View Switcher section shows a toolbar with Grid and List buttons (top right) at desktop/tablet width
- Grid button is active by default — cards display in a grid
- Click List → cards switch to a single-column layout with centered text
- Click Grid → cards switch back to the grid layout
- The eyebrow enhancement still works (italic text → eyebrow label) in both views
# Run linting
npm run lint
# Add changes
git add blocks/cards/
# Commit
git commit -m "feat: add eyebrow enhancement, list and view-switcher variations to cards block"
# Push
git push origin jsmithReplace jsmith with your branch name.
Critical rule: All block styles must be scoped to the block.
Bad (global selector):
.card-title {
font-size: 20px;
}Why bad? Could conflict with other blocks using .card-title.
Good (scoped to block):
.cards .card-title {
font-size: 20px;
}Better (scoped to variation, when applicable):
.cards.list .card-title {
font-size: 20px;
}Reserved classes: Avoid .cards-container and .cards-wrapper - these are used by section decoration.
The cards block uses responsive design without media queries:
grid-template-columns: repeat(auto-fill, minmax(257px, 1fr));How it works:
auto-fill: Creates as many columns as fitminmax(257px, 1fr): Each column is minimum 257px wide- Result: Automatically responsive!
When to use media queries: Only when you need different layouts at specific breakpoints.
/* Mobile first - default */
.cards > ul {
gap: 16px;
}
/* Tablet and up */
@media (min-width: 600px) {
.cards > ul {
gap: 24px;
}
}Standard breakpoints: 600px (tablet), 900px (small desktop), 1200px (large desktop)
Use Case 1: Event Speakers (enhancement)
- Author italicizes "Speaker" in card body → eyebrow label appears automatically
- Displays speaker bio with consistent labeling, no special configuration
Use Case 2: Sponsor Logos (variation)
- Use list variation for a single-column sponsor list
- Clean, full-width layout
Use Case 3: Product Catalog (variation with interactivity)
- Use view-switcher variation so users can toggle between grid and list views
- Combine with eyebrow enhancement for category labels
- Enhancements detect content patterns (like
<em>) and render them specially — no variant class needed - Variations use CSS classes (added by authors in parentheses) for different layouts
- Variations can be CSS-only (list) or require JavaScript (view-switcher)
- JavaScript decorates HTML structure, CSS styles it
- Always scope CSS to the block (
.blockname .selector) - Reuse CSS classes across variations — view-switcher toggles the same
listclass - Enhancements and variations can be combined — they're independent features
- Created test page with card examples (default, eyebrow, list, view-switcher)
- Implemented eyebrow enhancement (JavaScript + CSS) — works on any Cards block
- Implemented list variation (CSS only) — requires
Cards (List)class - Implemented view-switcher variation (JavaScript + CSS) — requires
Cards (View Switcher)class - View-switcher toggles between grid and list views using the same
listclass - Tested in Chrome DevTools responsive view (desktop and mobile)
- Understand the difference between enhancements and variations
- Understand how to reuse CSS classes across variations
- Committed and pushed changes
The complete solution for this exercise is available on the answers branch. The same branch contains solutions for all lab exercises.
Exercise 3: Dynamic Cards - You'll build a block that fetches data from external sources (Sheets and Workers), learning how to integrate dynamic data into EDS blocks.






