Skip to content
WordPress Tips

WordPress Accessibility Compliance in 2026: WCAG Standards for Theme Developers

The European Accessibility Act is enforceable and WCAG 2.2 is the new baseline. Learn how to build compliant WordPress block themes with proper FSE landmarks, accessible menu walkers, theme.json enforcement, focus management, and a real testing workflow — no overlay shortcuts.

Thakur Aarti
13 min read
Person using a laptop representing web accessibility and WCAG compliance

The European Accessibility Act has been enforceable since June 2025. Your client sells anything digital into the EU, and their WordPress theme ships with three identical “Read More” links per archive page, a hamburger menu that traps keyboard focus, and a color palette pulled from a Figma board nobody ran through a contrast checker. That theme is now a legal liability — not a design choice.

The good news: building a WCAG 2.2 AA-compliant block theme in 2026 is entirely doable without adding a single accessibility plugin. The bad news: most of the themes on the WordPress.org repository (yes, including ones tagged “accessibility-ready”) won’t pass an audit. This is the post I wish every theme developer would read before the next client project.

The accessibility overlay industry is a trap

Let’s get this out of the way. If you’re considering dropping in accessiBe, UserWay, or any other AI-driven accessibility widget as a compliance shortcut — stop. In 2024, accessiBe settled with the FTC over deceptive advertising claims. Overlays have been named in hundreds of ADA lawsuits in the US. Disability advocacy groups, including the National Federation of the Blind, have publicly denounced them. Installing an overlay doesn’t make you compliant. It often makes things worse by overriding legitimate assistive technology behavior and creating a false sense of security for the site owner.

Overlays are popular because they let agencies charge a monthly fee for a checkbox solution. That’s the only real reason they exist. Build accessibility into the theme itself. It’s cheaper, more effective, and doesn’t evaporate the moment an overlay vendor changes their pricing.

What WCAG 2.2 actually changed for theme developers

WCAG 2.1 was the previous baseline, and the EU’s EN 301 549 standard still formally points to 2.1 AA — but the EU has confirmed 2.2 is the recommended target, and courts in member states are already citing it. Build to 2.2 now and you’ll be ahead of the next update.

Nine new success criteria landed with 2.2. Four of them directly affect theme code in ways most developers miss.

2.4.11 Focus Not Obscured (Minimum, AA): When an element receives keyboard focus, it must not be entirely hidden by sticky headers, cookie banners, or scroll-reveal panels. This is the one that catches almost every modern theme with a sticky nav. Fix it with scroll-margin-top on focusable elements, not with JavaScript hacks.

2.5.8 Target Size (Minimum, AA): Interactive targets must be at least 24×24 CSS pixels unless they’re inline in text. This breaks a lot of icon-only social buttons and close-X buttons in popups. theme.json lets you enforce this for block patterns — more on that in a minute.

3.2.6 Consistent Help, 3.3.7 Redundant Entry, 3.3.8 Accessible Authentication: These matter more for full sites than themes, but if your theme ships with a login block or a contact pattern, they apply.

2.5.7 Dragging Movements (AA): Any drag interaction must have a single-pointer alternative. If your theme includes a carousel block with drag-only navigation, it fails this criterion.

The focus-not-obscured rule is the single most common 2.2 failure I see in theme audits. Sticky headers with no offset compensation. Test it yourself: tab through a page with a sticky nav on your theme right now. If the focused link ever disappears behind the header, that’s a fail.

Landmarks in a block theme — FSE changed everything

Full Site Editing changed the landmark game. Under a classic theme, you’d write <header role=”banner”> directly in header.php. Under FSE, the header is a template part, and the default Header template part block outputs <header class=”wp-block-template-part”> — no role, no landmark unless you set it.

WordPress 6.5+ added the tagName attribute to template parts so you can set it explicitly in the block editor. But if you’re shipping a theme, you need to enforce this at the theme level, not rely on the editor user to remember. Your header.html template part reference should look like this:

<!-- wp:template-part {"slug":"header","tagName":"header"} /-->

Inside the header template part itself:

<!-- wp:group {"tagName":"div","layout":{"type":"constrained"}} -->
<div class="wp-block-group">
    <!-- wp:site-title /-->
    <!-- wp:navigation {"ariaLabel":"Primary"} /-->
</div>
<!-- /wp:group -->

The ariaLabel on the Navigation block is what separates a pass from a fail when a page has more than one nav landmark (header + footer). Screen readers announce “Primary navigation” and “Footer navigation” instead of “navigation, navigation.” That’s WCAG 2.4.6 (Headings and Labels) in practice.

For main and footer, the pattern is the same:

<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

And wrap your post content template in a group with “tagName”:”main”:

<!-- wp:group {"tagName":"main","className":"wp-site-blocks__main"} -->
    <!-- wp:post-content /-->
<!-- /wp:group -->

One <main> per page. Not two. Not zero. If you’re using a classic theme or a hybrid setup, enforce it in functions.php:

add_filter('render_block_core/group', function ($block_content, $block) {
    if (empty($block['attrs']['className'])) {
        return $block_content;
    }
    if (str_contains($block['attrs']['className'], 'wp-site-blocks__main')) {
        return preg_replace('/^<div/', '<main', preg_replace('/<\/div>$/', '</main>', $block_content));
    }
    return $block_content;
}, 10, 2);

The custom menu walker that doesn’t break keyboard access

Most custom walker classes I audit have one of these three bugs: they add aria-haspopup=”true” to every top-level item whether or not it has children, they use onmouseover for dropdowns with no keyboard equivalent, or they wrap the dropdown trigger in a <div> so tab order skips it entirely.

Here’s a walker that actually works. It adds an explicit submenu toggle button (not hover-only), proper aria-expanded state, and only marks items with children:

<?php
// inc/class-accessible-walker-nav-menu.php

class SwiftlyWP_Accessible_Walker extends Walker_Nav_Menu {

    public function start_lvl(&$output, $depth = 0, $args = null) {
        $indent  = str_repeat("\t", $depth);
        $output .= "\n{$indent}<ul class=\"sub-menu\" role=\"menu\">\n";
    }

    public function start_el(&$output, $item, $depth = 0, $args = null, $id = 0) {
        $has_children = in_array('menu-item-has-children', $item->classes, true);
        $classes     = empty($item->classes) ? [] : (array) $item->classes;
        $class_names = esc_attr(implode(' ', array_filter($classes)));

        $output .= sprintf(
            '<li class="%1$s"%2$s>',
            $class_names,
            $has_children ? ' role="none"' : ''
        );

        $attrs = [
            'href'  => esc_url($item->url),
            'class' => 'menu-link',
        ];
        if ($has_children) {
            $attrs['aria-expanded'] = 'false';
            $attrs['aria-haspopup'] = 'true';
        }

        $attr_string = '';
        foreach ($attrs as $key => $value) {
            $attr_string .= sprintf(' %s="%s"', $key, esc_attr($value));
        }

        $output .= sprintf('<a%1$s>%2$s</a>', $attr_string, esc_html($item->title));

        if ($has_children) {
            $output .= sprintf(
                '<button type="button" class="submenu-toggle" aria-expanded="false" aria-label="%s"><span aria-hidden="true">+</span></button>',
                esc_attr(sprintf(__('Toggle submenu for %s', 'swiftlywp'), $item->title))
            );
        }
    }

    public function end_el(&$output, $item, $depth = 0, $args = null) {
        $output .= "</li>\n";
    }
}

Pair it with a short JavaScript handler that toggles aria-expanded on both the trigger and the parent <li>, listens for Escape to close, and manages focus return. No jQuery, no dependencies:

document.addEventListener('DOMContentLoaded', () => {
    const toggles = document.querySelectorAll('.submenu-toggle');

    toggles.forEach((toggle) => {
        toggle.addEventListener('click', () => {
            const expanded = toggle.getAttribute('aria-expanded') === 'true';
            toggle.setAttribute('aria-expanded', String(!expanded));
            const parentLink = toggle.previousElementSibling;
            if (parentLink && parentLink.tagName === 'A') {
                parentLink.setAttribute('aria-expanded', String(!expanded));
            }
        });
    });

    document.addEventListener('keydown', (e) => {
        if (e.key !== 'Escape') return;
        const open = document.querySelector('.submenu-toggle[aria-expanded="true"]');
        if (open) {
            open.setAttribute('aria-expanded', 'false');
            open.focus();
        }
    });
});

This is the minimum. It’s not fancy. That’s the point — accessibility code shouldn’t be.

theme.json is your accessibility enforcement layer

Most theme developers treat theme.json as a styling config. It’s also a compliance tool. Three settings matter.

Color contrast. Every color pair you expose via settings.color.palette should be tested against each other. Don’t ship a palette where “accent” on “base” fails 4.5:1 — editor users will combine them. Run every pair through a contrast checker before shipping. Here’s a minimal setup that passes:

{
  "settings": {
    "color": {
      "palette": [
        { "slug": "base",     "color": "#ffffff", "name": "Base" },
        { "slug": "contrast", "color": "#111111", "name": "Contrast" },
        { "slug": "primary",  "color": "#0B4F6C", "name": "Primary" },
        { "slug": "accent",   "color": "#B3202F", "name": "Accent" }
      ]
    },
    "typography": {
      "fontSizes": [
        { "slug": "small",   "size": "1rem",    "name": "Small" },
        { "slug": "medium",  "size": "1.125rem","name": "Medium" },
        { "slug": "large",   "size": "1.5rem",  "name": "Large" }
      ]
    }
  }
}

Notice what’s not there: no 0.75rem “small” that turns 12px text into an unreadable caption. WCAG 1.4.4 Resize Text allows users to scale up to 200%, and starting from 12px means your “large” resize is still smaller than most users’ default.

Target size enforcement in block patterns. Every button pattern you register should set a minimum padding that produces a 24×24 CSS pixel touch target. In theme.json:

{
  "styles": {
    "blocks": {
      "core/button": {
        "spacing": {
          "padding": {
            "top":    "0.75rem",
            "right":  "1.25rem",
            "bottom": "0.75rem",
            "left":   "1.25rem"
          }
        }
      }
    }
  }
}

At the default 16px root, this produces a 44×~80px button. That clears WCAG 2.5.8 easily and gives you headroom for small text inside the button.

Focus styles you cannot override accidentally. Add a global focus style that can’t be wiped by user CSS. Use :focus-visible so it doesn’t appear on mouse clicks but does appear on keyboard navigation:

{
  "styles": {
    "css": "*:focus-visible { outline: 3px solid var(--wp--preset--color--primary); outline-offset: 2px; border-radius: 2px; }"
  }
}

That single line fixes one of the most common audit failures I find: themes that rely on the browser default outline: auto and then get clobbered by a global reset like *:focus { outline: none } somewhere in an ancestor stylesheet.

Fix the Read More problem in the query loop

The Query Loop block ships with a Read More Link inner block. By default, every card in an archive gets the same “Read More” text. To a screen reader user navigating by links, this is an unmitigated disaster: thirty items, all “Read More,” no context.

The fix is to append the post title as visually-hidden text. This requires a render filter on core/read-more:

add_filter('render_block_core/read-more', function ($block_content, $block) {
    $post_id = get_the_ID();
    if (!$post_id) {
        return $block_content;
    }

    $title = get_the_title($post_id);
    $sr    = sprintf(' <span class="screen-reader-text">%s</span>', esc_html(sprintf(__('about %s', 'swiftlywp'), $title)));

    return preg_replace('/(<\/a>)/', $sr . '$1', $block_content, 1);
}, 10, 2);

Add the screen reader text class to your stylesheet if your theme doesn’t already have it:

.screen-reader-text {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    border: 0;
}

Now a screen reader announces “Read more about How to harden WooCommerce checkout” instead of “Read more, link.” It is the single highest-impact 10-line fix you can make to a blog theme.

Focus management that survives page builders

Block themes often lose focus when a Navigation block dropdown closes, when a modal opens, or when a tab panel switches. WCAG 2.4.3 requires a logical focus order, and 2.4.7 requires visible focus. Both get broken by careless JavaScript.

The rule: whenever you programmatically hide an element that contains focus, move focus to something sensible (usually the trigger that opened it). Whenever you open a modal, save the previously-focused element, trap focus inside the modal, and restore it on close.

Here’s a minimal focus trap you can drop into any theme’s interactive block:

export function trapFocus(container) {
    const focusable = container.querySelectorAll(
        'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
    );
    if (!focusable.length) return () => {};

    const first = focusable[0];
    const last  = focusable[focusable.length - 1];
    const previouslyFocused = document.activeElement;

    const handler = (e) => {
        if (e.key !== 'Tab') return;
        if (e.shiftKey && document.activeElement === first) {
            e.preventDefault();
            last.focus();
        } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault();
            first.focus();
        }
    };

    container.addEventListener('keydown', handler);
    first.focus();

    return () => {
        container.removeEventListener('keydown', handler);
        if (previouslyFocused instanceof HTMLElement) {
            previouslyFocused.focus();
        }
    };
}

Six lines of core logic save you a lawsuit. Worth it.

Block patterns are a hidden compliance hole

Block patterns are an accessibility hole in most commercial themes. A pattern gets designed in Figma, rebuilt in the editor, copied into patterns/hero.php, and shipped. Nobody tests it with a keyboard. Nobody tests it with a screen reader. Nobody checks the heading hierarchy. I audited a theme last month that had fourteen patterns, all starting with an H1 — so any page using more than one pattern had multiple H1s, which is a 1.3.1 Info and Relationships fail.

The rule for shipping patterns: start every pattern with an H2 unless it’s explicitly a page hero pattern marked as such, never use an H3 without an H2 above it, never use a heading to style large text, and always include a visible text label next to decorative icons. If you need larger text that isn’t a heading, use a paragraph with a font size class. Headings are structural, not decorative.

Run this quick audit on your patterns directory to find hierarchy problems:

grep -l 'wp:heading.*"level":1' patterns/*.php

That finds patterns that start with an H1. Fix every match before shipping. The inserter preview uses your pattern’s heading hierarchy as-is, so a pattern with a standalone H2 dropped into a page that already has an H2 will look fine in preview but fail an audit later. Expose a heading attribute in your pattern metadata so authors can pick the level per context.

A testing workflow that actually catches things

You can’t fix what you don’t test. I’ve seen agencies ship WCAG AA compliant themes that have never been run through a screen reader. Here’s the workflow that catches 95% of issues before a client ever sees the site.

Step 1: axe-core on every build. Install @axe-core/cli and run it against your local dev URL during build. It catches missing alt attributes, form label problems, ARIA misuse, and contrast failures automatically:

npm install -D @axe-core/cli
npx axe http://swiftlywp.local --save reports/axe.json --exit

Hook it into your package.json scripts so nobody can merge without a clean axe report. Axe catches about 30–40% of WCAG issues — don’t treat it as complete, but do treat it as the minimum bar.

Step 2: keyboard-only pass. Unplug your mouse. Navigate the home page, the blog archive, a single post, and the contact form using only Tab, Shift+Tab, Enter, Space, and arrow keys. If you can’t reach something, your users can’t either. If focus disappears, that’s a 2.4.7 fail.

Step 3: real screen reader testing. NVDA on Windows (free), VoiceOver on macOS (built-in). Spend 20 minutes with NVDA. Turn your monitor off for 5 of them. You’ll find problems no automated tool reports. Budget 2 hours per theme — it’s not optional if you’re charging a client for compliance.

Step 4: Pa11y in CI. For ongoing monitoring, Pa11y-ci runs WCAG checks across a sitemap in your continuous integration pipeline. Wire it to fail the build on new errors, not all errors — otherwise nobody fixes the backlog and everyone ignores the alerts.

Step 5: the 200% zoom test. Open Chrome, hit Ctrl+ five times to reach 200% zoom, and browse your theme. Horizontal scrollbars appearing? That’s WCAG 1.4.10 Reflow failing. Text getting cut off? 1.4.4 Resize Text failing. Fix it with CSS logical properties and clamp() for font sizes, not by hardcoding pixel widths.

Step 6: reduced motion testing. In your OS, enable reduce motion and reload the site. Any animation still moving? Wrap animations in @media (prefers-reduced-motion: no-preference) so they’re opt-in by default. This is WCAG 2.3.3, and it’s a 2.1 criterion most themes still ignore five years on.

Where to go from here

Compliance is not a plugin. It’s not an overlay. It’s a dozen small decisions scattered across theme.json, your template parts, your menu walker, your JavaScript, and your build pipeline. Start with the four things that give you the highest audit impact per hour: fix your landmarks, fix the Read More problem, add a real focus style, and run your theme through NVDA for 20 minutes. Those four changes alone will move most themes from “likely to fail” to “likely to pass” a WCAG 2.2 AA audit.

Then do the rest. Target sizes in every button pattern. Color palette pairs tested against each other. A menu walker that handles keyboard and screen readers without hacks. axe and Pa11y in CI so you never regress. None of this is optional in 2026. The EAA is enforceable. US ADA lawsuits have been climbing every year since 2018. Your clients are asking about it whether they know what to call it or not.

One last thing: document every accessibility decision you make in a short ACCESSIBILITY.md in your theme repo. Which criteria you’ve tested against, which screen readers you used, which known limitations exist, and how to report issues. This file has saved me from scope disputes with three different clients. When a new audit comes in six months later and finds something, you have a record of what was tested and when. Without it, every finding becomes a blame game. With it, you’re a professional with a paper trail. Take the 20 minutes. Future-you will thank present-you the next time a legal team sends a 40-page remediation request.

While you’re at it, add a visible accessibility statement page to every client site — linked from the footer, covering conformance level, contact details for issues, and which testing methodology you used. The EAA requires it, and you’d be amazed how many compliance investigations end the moment the inspector sees a serious statement in place. Fifteen minutes of writing for real legal cover.

If you need a professional accessibility audit and remediation on an existing WordPress theme, our WordPress Plugin Customization and Custom WordPress Development services handle theme-level WCAG 2.2 AA work on real production sites — not overlay shortcuts, actual code fixes.

Leave a Reply

Your email address will not be published. Required fields are marked *