N
Naveenr.dev
Chapter 15
28 min read2026-06-30

Accessibility in EDS Blocks

Accessibility for EDS blocks. Covers semantic HTML, ARIA, keyboard navigation, focus management, heading hierarchy, image alt text, color contrast, screen reader testing, and production checks.

Content Objective

This chapter covers:

  • Why EDS blocks require explicit accessibility work (no Core Components baseline)
  • Semantic HTML decisions that eliminate the need for ARIA in most cases
  • When ARIA is required and when it makes things worse
  • Keyboard navigation patterns for interactive blocks (tabs, modals, dropdowns)
  • Focus management: trapping, restoring, and moving focus programmatically
  • Heading hierarchy rules and how to enforce them in blocks
  • Image alt text conventions for authored content in EDS
  • Color contrast requirements with CSS token design
  • The WCAG AA checklist every block must pass before production
  • Tools for testing accessibility in EDS projects

The Accessibility Difference Between AEM and EDS

In AEM, Core Components provide an accessibility baseline:

  • Aria attributes are added by HTL templates
  • Form components have built-in label associations
  • Image components force alt text in the dialog
  • Navigation component generates correct semantic list structure

In EDS, your decorate() function builds the DOM from scratch. When you write block.innerHTML = '<div>...</div>', you own every accessibility decision. There is no framework that adds ARIA for you. There is no dialog that enforces alt text.

For enterprise projects, especially in healthcare, WCAG AA (Level AA of the Web Content Accessibility Guidelines) is not optional — it is a legal requirement in many countries and a contract requirement with regulated clients.

WCAG AA compliance is not a feature you add at the end. It is a quality standard you build in from the start.


The Foundation: Semantic HTML First

The most impactful accessibility improvement is using the correct HTML element. Native HTML elements have built-in keyboard behavior, ARIA semantics, and screen reader support. When you use <button>, screen readers announce "button". When you use <nav>, screen readers can navigate directly to it. When you use <div> for interactive elements, you get nothing.

ARIA Rule 1: Use Native HTML Before ARIA

// BAD — div pretending to be a button requires manual ARIA and keyboard handling
const btn = document.createElement('div');
btn.setAttribute('role', 'button');
btn.setAttribute('aria-pressed', 'false');
btn.setAttribute('tabindex', '0');
btn.addEventListener('click', handleClick);
btn.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') handleClick();
});

// GOOD — button has all of this by default
const btn = document.createElement('button');
btn.type = 'button';
btn.addEventListener('click', handleClick);

Native elements with free accessibility:

ElementWhat you get for free
<button>Keyboard focusable, Enter/Space activation, "button" role announcement
<a href>Keyboard focusable, Enter activation, "link" role, destination announced
<nav>"navigation" landmark role, screen reader jump target
<main>"main" landmark role, skip target
<header>"banner" landmark role
<footer>"contentinfo" landmark role
<aside>"complementary" landmark role
<section> with aria-label"region" landmark role
<ul>/<ol>List semantics, item count announced
<figure>/<figcaption>Groups image with caption
<details>/<summary>Expandable/collapsible with keyboard support
<input type="checkbox">Checked/unchecked state, keyboard toggle

Block-Specific Semantic HTML Requirements

Hero Block

export default function decorate(block) {
  // Correct: use <section> for the hero region with descriptive label
  const section = document.createElement('section');
  section.setAttribute('aria-label', 'Hero');

  // Correct: heading level depends on page context
  // Hero heading is almost always the page's primary h1
  const heading = document.createElement('h1');
  heading.classList.add('hero-header');
  heading.textContent = title;

  // Correct: paragraph for descriptive text
  const subtitle = document.createElement('p');
  subtitle.classList.add('hero-subtitle');
  subtitle.textContent = subtitleText;

  // Correct: nav for CTA group when multiple links
  const ctaGroup = document.createElement('div');
  ctaGroup.classList.add('hero-cta');
  ctaGroup.setAttribute('role', 'group');
  ctaGroup.setAttribute('aria-label', 'Call to action');

  section.append(heading, subtitle, ctaGroup);
  block.append(section);
}

Cards Block

export default function decorate(block) {
  // Correct: section with heading for the collection
  const section = document.createElement('section');
  section.setAttribute('aria-labelledby', 'cards-heading');

  // If there's a section title
  const sectionHeading = document.createElement('h2');
  sectionHeading.id = 'cards-heading';
  sectionHeading.textContent = collectionTitle;

  // Correct: ul/li for a collection of cards
  const list = document.createElement('ul');
  list.classList.add('cards-items');

  items.forEach((item) => {
    const li = document.createElement('li');
    li.classList.add('cards-item');

    // Each card is an article (self-contained, syndicatable content)
    const article = document.createElement('article');

    // Card heading — use h3 if section uses h2 (maintain hierarchy)
    const cardHeading = document.createElement('h3');
    cardHeading.textContent = item.title;

    // Link wraps the whole card OR is a separate CTA — pick one
    // Wrapping: entire card is clickable (simpler)
    const link = document.createElement('a');
    link.href = item.href;
    // Descriptive link text — not "Read more" for every card
    link.setAttribute('aria-label', `Read more about ${item.title}`);

    article.append(cardHeading);
    li.append(article);
    list.append(li);
  });

  section.append(sectionHeading, list);
  block.append(section);
}
export default function decorate(block) {
  const nav = document.createElement('nav');
  nav.setAttribute('aria-label', 'Main navigation');

  // The toggle button for mobile hamburger
  const toggle = document.createElement('button');
  toggle.type = 'button';
  toggle.classList.add('nav-toggle');
  toggle.setAttribute('aria-expanded', 'false');
  toggle.setAttribute('aria-controls', 'nav-menu');
  toggle.setAttribute('aria-label', 'Open navigation menu');

  const menuList = document.createElement('ul');
  menuList.id = 'nav-menu';
  menuList.classList.add('nav-menu');

  toggle.addEventListener('click', () => {
    const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
    toggle.setAttribute('aria-expanded', String(!isExpanded));
    toggle.setAttribute('aria-label',
      isExpanded ? 'Open navigation menu' : 'Close navigation menu');
    menuList.classList.toggle('nav-menu-open', !isExpanded);
  });

  nav.append(toggle, menuList);
  block.append(nav);
}

When to Use ARIA

ARIA should be used when native HTML semantics are insufficient. The four main ARIA use cases in EDS blocks:

Use Case 1: Live Regions (Dynamic Content Updates)

When your block updates content dynamically (search results, form validation, notifications):

// Announce to screen readers when content updates
const results = document.createElement('div');
results.setAttribute('aria-live', 'polite');    // polite: waits for user to be idle
results.setAttribute('aria-atomic', 'true');     // announce the whole region on change

// Update content — screen reader will announce it
results.textContent = `${data.length} results found for "${query}"`;

aria-live values:

  • polite — announces after current speech finishes (use for most updates)
  • assertive — interrupts current speech immediately (use only for critical errors)
  • off — no announcement (default)

Use Case 2: Modal Dialogs

function openModal(triggerButton) {
  const modal = document.createElement('div');
  modal.setAttribute('role', 'dialog');
  modal.setAttribute('aria-modal', 'true');
  modal.setAttribute('aria-labelledby', 'modal-title');

  const title = document.createElement('h2');
  title.id = 'modal-title';
  title.textContent = 'Confirm action';

  const closeBtn = document.createElement('button');
  closeBtn.type = 'button';
  closeBtn.setAttribute('aria-label', 'Close dialog');
  closeBtn.addEventListener('click', () => closeModal(modal, triggerButton));

  modal.append(title, closeBtn);
  document.body.append(modal);

  // Move focus to first focusable element in modal
  trapFocus(modal);
  modal.querySelector('button, [href], input, select, textarea, [tabindex]')?.focus();
}

function closeModal(modal, triggerButton) {
  modal.remove();
  // Return focus to the element that opened the modal
  triggerButton?.focus();
}

Use Case 3: Tab Components

function buildTabs(tabsData) {
  const tabList = document.createElement('div');
  tabList.setAttribute('role', 'tablist');
  tabList.setAttribute('aria-label', 'Content sections');

  tabsData.forEach((tab, idx) => {
    const button = document.createElement('button');
    button.setAttribute('role', 'tab');
    button.setAttribute('aria-selected', idx === 0 ? 'true' : 'false');
    button.setAttribute('aria-controls', `tab-panel-${idx}`);
    button.id = `tab-${idx}`;
    button.textContent = tab.label;
    // Non-selected tabs are NOT in tab order — use roving tabindex
    button.setAttribute('tabindex', idx === 0 ? '0' : '-1');

    button.addEventListener('click', () => activateTab(idx, tabsData));
    button.addEventListener('keydown', (e) => handleTabKeydown(e, idx, tabsData));
    tabList.append(button);

    const panel = document.createElement('div');
    panel.setAttribute('role', 'tabpanel');
    panel.id = `tab-panel-${idx}`;
    panel.setAttribute('aria-labelledby', `tab-${idx}`);
    panel.hidden = idx !== 0;
    // ... panel content
  });
}

function handleTabKeydown(e, currentIdx, tabsData) {
  const count = tabsData.length;
  let newIdx;

  if (e.key === 'ArrowRight') newIdx = (currentIdx + 1) % count;
  else if (e.key === 'ArrowLeft') newIdx = (currentIdx - 1 + count) % count;
  else if (e.key === 'Home') newIdx = 0;
  else if (e.key === 'End') newIdx = count - 1;
  else return;

  e.preventDefault();
  activateTab(newIdx, tabsData);
  document.getElementById(`tab-${newIdx}`)?.focus();
}

Use Case 4: Expandable Sections (Accordion)

function buildAccordionItem(title, content) {
  const item = document.createElement('div');
  item.classList.add('accordion-item');

  const heading = document.createElement('h3');

  const trigger = document.createElement('button');
  trigger.type = 'button';
  trigger.classList.add('accordion-trigger');
  trigger.setAttribute('aria-expanded', 'false');
  trigger.setAttribute('aria-controls', `panel-${id}`);
  trigger.textContent = title;

  const panel = document.createElement('div');
  panel.id = `panel-${id}`;
  panel.classList.add('accordion-panel');
  panel.hidden = true;

  trigger.addEventListener('click', () => {
    const isExpanded = trigger.getAttribute('aria-expanded') === 'true';
    trigger.setAttribute('aria-expanded', String(!isExpanded));
    panel.hidden = isExpanded;
  });

  heading.append(trigger);
  item.append(heading, panel);
  return item;
}

Focus Management

Keyboard Navigation Requirements

Every interactive element must be keyboard accessible:

Tab key → moves focus forward through interactive elements
Shift+Tab → moves focus backward
Enter → activates links and buttons
Space → activates buttons (not links)
Escape → closes dialogs, dropdowns, overlays
Arrow keys → moves within component (tabs, menus, sliders)

Focus Trap for Modals

When a modal is open, focus must be trapped inside it:

function trapFocus(container) {
  const focusableSelectors = [
    'a[href]', 'button:not([disabled])', 'input:not([disabled])',
    'select:not([disabled])', 'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])',
  ].join(', ');

  const focusableEls = [...container.querySelectorAll(focusableSelectors)];
  const firstEl = focusableEls[0];
  const lastEl = focusableEls[focusableEls.length - 1];

  container.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      if (document.activeElement === firstEl) {
        e.preventDefault();
        lastEl.focus();
      }
    } else {
      if (document.activeElement === lastEl) {
        e.preventDefault();
        firstEl.focus();
      }
    }
  });

  // Close on Escape
  container.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') container.dispatchEvent(new Event('close'));
  });
}

Focus Indicator (Visible Focus Ring)

EDS blocks must show a visible focus indicator. Do not remove the focus ring:

/* WRONG — removes focus indicator, fails WCAG 2.4.7 */
.hero .btn-primary:focus {
  outline: none;
}

/* CORRECT — custom focus ring that matches brand */
.hero .btn-primary:focus-visible {
  outline: 2px solid var(--color-neutral-900);
  outline-offset: 3px;
}

Use :focus-visible (not :focus) — this shows the ring only for keyboard navigation, not mouse clicks, giving a better visual experience without sacrificing keyboard accessibility.


Heading Hierarchy

WCAG 1.3.1 and 2.4.6 require proper heading hierarchy. Screen readers use headings to navigate pages. A broken hierarchy makes the page unusable for users who navigate by heading.

The Rule

Page must have exactly one <h1>
<h2> for major sections
<h3> for subsections within h2 sections
Never skip levels: h1 → h3 (skipping h2) is wrong

In EDS Context

The heading level in your block depends on the page's content hierarchy, not on the block type. A hero block is usually <h1>. A card block heading is usually <h3> (if the section uses <h2>). But if a card block is the only content on a landing page, its heading might be <h1>.

Solution: Make heading levels configurable via block variants or content:

// Let the author's original heading level determine what to use
// Don't force-change h2 to h3 in your block

export default function decorate(block) {
  const heading = block.querySelector('h1, h2, h3, h4');
  // Preserve the original heading level from authored content
  // Do NOT replace h2 with h3 just because your block "feels like" a h3 context
  const headingLevel = heading?.tagName || 'H2';
  
  const newHeading = document.createElement(headingLevel);
  newHeading.innerHTML = DOMPurify.sanitize(heading?.innerHTML || '');
  // ...
}

Image Alt Text in EDS

EDS processes images from content. The alt text comes from the author.

In DA (Document Authoring)

When an author inserts an image in DA, they can set alt text. If they don't, the alt text is empty (alt=""). Empty alt text is correct for decorative images but wrong for informative images.

In UE (xwalk)

The image model field should include an alt text field:

{
  "component": "text",
  "name": "image-alt",
  "label": "Image Alt Text",
  "description": "Describe the image for screen readers. Leave empty only for purely decorative images."
}

In block JS, use the authored alt text:

const picture = block.querySelector('picture');
const img = picture?.querySelector('img');
const altText = block.dataset.imageAlt || img?.alt || '';
if (img) img.alt = altText;

Decorative vs Informative Images

<!-- Informative: describes content — alt text required -->
<img src="doctor.webp" alt="Dr. Smith reviewing patient chart in clinic">

<!-- Decorative: purely visual, adds no information — empty alt required -->
<img src="abstract-bg.webp" alt="">
<!-- Note: alt="" (not alt missing) tells screen readers to skip this image -->

Skip links let keyboard users bypass repetitive navigation and jump to main content. They are required for WCAG 2.4.1.

The EDS boilerplate's styles.css typically includes a skip link. Verify it is present and functional:

/* styles/styles.css */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 0.5rem 1rem;
  background: var(--color-neutral-900);
  color: var(--color-white);
  z-index: var(--z-modal);
  transition: top 200ms ease;
  font-weight: bold;
}

.skip-link:focus {
  top: 0;
}
<!-- head.html or header block — must be the FIRST element in the body -->
<a class="skip-link" href="#main-content">Skip to main content</a>
<!-- In the page structure (scripts.js adds this automatically) -->
<main id="main-content">

Test by pressing Tab on page load — the skip link should be the first thing that appears.


Color Contrast

WCAG AA requires:

  • Normal text (< 18pt, < 14pt bold): contrast ratio ≥ 4.5:1
  • Large text (≥ 18pt, or ≥ 14pt bold): contrast ratio ≥ 3:1
  • UI components and focus indicators: contrast ratio ≥ 3:1

Building Contrast Into Your Token System

:root {
  /* Verified contrast pairs — document these in your design system */
  --color-neutral-900: #222731;  /* 16.5:1 against white ✅ */
  --color-white: #ffffff;

  --color-primary: #ffd100;      /* Yellow — 1.2:1 against white ❌ */
  /* Yellow on white fails 4.5:1 — never use for text on white background */

  --color-primary-on-dark: #ffd100;   /* Yellow on dark #222731: 13.7:1 ✅ */
}

Common EDS contrast failure: Yellow (#ffd100) is a popular brand color but fails contrast requirements as text on white. Use it for backgrounds and accents, then use dark text on top:

/* FAILS contrast — yellow text on white */
.hero.hero-yellow .hero-header {
  color: var(--color-primary);  /* #ffd100 on white = 1.2:1 ❌ */
}

/* PASSES — dark text on yellow background */
.hero.hero-yellow {
  background: var(--color-primary);  /* yellow background */
}
.hero.hero-yellow .hero-header {
  color: var(--color-neutral-900);   /* dark text on yellow = 13.7:1 ✅ */
}

Testing Contrast

  • Browser extension: axe DevTools, WAVE
  • Design: Figma Contrast plugin
  • Code: npx a11y-color-contrast or use WebAIM Contrast Checker
  • In DevTools: Elements → Accessibility → "Computed" tab shows contrast ratio for selected element

Accessibility Testing for EDS Blocks

Automated Testing

# Install axe-core CLI
npm install -g @axe-core/cli

# Test against your preview URL
axe https://main--eds-poc--org.aem.page/ --exit

Or use the axe DevTools browser extension — it shows violations directly on the rendered page.

Manual Testing Checklist

Keyboard testing (most important):

1. Disconnect your mouse
2. Tab through the entire page
3. Every interactive element must be reachable
4. Focus indicator must be visible at every step
5. Enter/Space must activate buttons and links
6. Escape must close modals and dropdowns
7. Tab order must match visual reading order

Screen reader testing:

  • macOS: VoiceOver (Cmd+F5 to toggle), Safari browser
  • Windows: NVDA (free), Chrome browser
  • Mobile: iOS VoiceOver, Android TalkBack

Screen reader test script for a hero block:

1. Navigate to the page
2. Screen reader should announce: page title, then "Hero" (section label)
3. Heading navigation (H key in NVDA/VoiceOver): should jump to h1 inside hero
4. Tab to CTA button: should announce "Get Started, link" or "Get Started, button"
5. Activate CTA: should navigate to target URL

The WCAG AA Block Checklist

Every block must pass these before production. WCAG success criterion referenced in brackets.

Perceivable:
[ ] All images have alt text — informative images describe content,
    decorative images have alt="" [1.1.1]
[ ] Color is not the only way to convey information [1.4.1]
[ ] Text contrast ratio ≥ 4.5:1 for normal text, 3:1 for large text [1.4.3]
[ ] Text can be resized to 200% without loss of content or functionality [1.4.4]
[ ] Content reflows at 400% zoom without horizontal scrolling [1.4.10]
[ ] Non-text contrast (UI components, focus indicators) ≥ 3:1 [1.4.11]

Operable:
[ ] All functionality available via keyboard [2.1.1]
[ ] No keyboard trap — user can always Tab away from any component [2.1.2]
[ ] Skip link to main content exists and is first in focus order [2.4.1]
[ ] Page has a meaningful title [2.4.2]
[ ] Focus indicator is visible [2.4.7]
[ ] Link purpose is clear from link text or context [2.4.4]
[ ] Heading hierarchy is correct (h1 → h2 → h3, no skipped levels) [2.4.6]

Understandable:
[ ] Language of page is set (lang attribute on <html>) [3.1.1]
[ ] Error messages identify the field and describe the error [3.3.1]
[ ] Labels or instructions for user input are programmatically associated [3.3.2]

Robust:
[ ] HTML is valid (no duplicate IDs, no broken nesting) [4.1.1]
[ ] UI components have accessible name and role [4.1.2]
[ ] Status messages are programmatically determinable without focus [4.1.3]

Block-specific:
[ ] Interactive elements use native HTML or correct ARIA role
[ ] Dynamic updates announced via aria-live
[ ] Modals trap focus and return focus on close
[ ] Tab components use roving tabindex and arrow key navigation
[ ] Accordions use button elements with aria-expanded

Key Takeaways

  • AEM Core Components handle a11y for you. EDS does not. Every accessibility decision is in your decorate() function.
  • Use native HTML elements first<button>, <nav>, <main>, <section>, <article> give you semantics, keyboard behavior, and ARIA roles for free
  • ARIA rule: only use ARIA when native HTML is insufficient; wrong ARIA is worse than no ARIA
  • Keyboard navigation is the baseline — test without a mouse before shipping any block
  • Focus ring must be visible — use :focus-visible to show it for keyboard, hide it for mouse
  • Heading hierarchy matters — preserve the author's heading level; don't force h3 everywhere
  • Yellow on white fails contrast — design token comments should document passing contrast pairs
  • alt="" (empty) is correct for decorative images — missing alt is wrong; empty alt is intentional
  • aria-live="polite" for dynamic content — search results, form feedback, notifications
  • Modals must trap focus and return it on close — this is the most common oversight

Next Steps

In the next chapter, we cover SEO Configuration in EDS — the metadata block, helix-sitemap.yaml, helix-query.yaml, JSON-LD structured data, Open Graph tags, canonical URLs, hreflang, and how to verify your EDS pages are correctly indexed by Google.

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.