WCAG 2.1 AA on an Elementor site: what actually needed fixing
By Rolando Escobar, WordPress developer for agencies · · 5 min read
Elementor gets you part of the way to WCAG 2.1 AA, then leaves gaps that only show up once you put the mouse away. When I rebuilt rolandowp.com this month with Elementor 4.3 and its V4 atomic elements, these are the fixes that took every page to a Lighthouse accessibility score of 100 and zero axe-core violations.
None of them needed a plugin. They live in a Hello Elementor child theme: a few actions, two shortcodes, a little JavaScript and one CSS layer. Keep the WCAG 2.1 quick reference open while you read; each fix maps to a success criterion there.
One focus ring for every interactive element
Keyboard users need to see where they are (2.4.7 Focus Visible). Elementor widgets, the parent theme and old custom CSS all style focus differently, and my site still had a leftover snippet that removed the outline from buttons. I deleted it and replaced every focus style with one rule set:
:root {
--rw-focus: #1B4FD8;
--rw-focus-on-dark: #8AB4FF;
}
/* 2.4.7: one high-contrast ring for everything that can take focus. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 3px solid var(--rw-focus) !important;
outline-offset: 3px !important;
}
/* On navy sections the blue ring falls below 3:1, so switch to light blue. */
:where(.section-dark, .section-dark *):focus-visible {
outline-color: var(--rw-focus-on-dark) !important;
}
/* Drop the ring for mouse clicks only, never for keyboard users. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus:not(:focus-visible) {
outline: none;
}
:where() keeps the element list from adding specificity, so each rule weighs no more than a single pseudo-class. The !important on the outline is the one deliberate exception: no widget style gets to remove the ring.
The color matters as much as the width. #1B4FD8 on white is 6.65:1, but on the #0B1220 navy of my dark sections it drops to 2.81:1, below the 3:1 that 1.4.11 Non-text Contrast asks of focus indicators. The light blue reaches 8.96:1 on the same navy.
A skip link that has somewhere to go
Hello Elementor prints a “Skip to content” link that points to #content. On pages using Elementor’s full-width template, and on Theme Builder singles, nothing had that ID. The link did nothing, and the page had no <main> landmark either (2.4.1 Bypass Blocks). Elementor fires actions right before and after it renders the content in both cases, which is all you need:
<?php
/**
* Wrap Elementor content in one <main> landmark for the skip link to target.
* The counter keeps it to a single <main> if both pairs of hooks fire.
*/
function rw_main_depth( int $change = 0 ): int {
static $depth = 0;
$depth += $change;
return $depth;
}
function rw_open_main() {
if ( 1 === rw_main_depth( 1 ) ) {
echo '<main id="content" class="rw-main" tabindex="-1">';
}
}
function rw_close_main() {
if ( 0 === rw_main_depth( -1 ) ) {
echo '</main>';
}
}
// "Elementor Full Width" page template.
add_action( 'elementor/page_templates/header-footer/before_content', 'rw_open_main' );
add_action( 'elementor/page_templates/header-footer/after_content', 'rw_close_main' );
// Theme Builder single templates.
add_action( 'elementor/theme/before_do_single', 'rw_open_main' );
add_action( 'elementor/theme/after_do_single', 'rw_close_main' );
The version on my site is two plain functions. The counter is a guard worth adding, because a Theme Builder single that uses the full-width layout can trigger both pairs, and two nested <main> elements are their own axe violation. tabindex="-1" lets the browser move focus to the landmark, so the next Tab continues from the content instead of the header. The landmark itself is not a control, so the CSS removes the ring on .rw-main:focus and makes the skip link visible when it receives focus.
Navigation landmarks with names
The V3 nav-menu widget rendered badly inside V4 containers, so I replaced it with a shortcode. Shortcodes are an underrated escape hatch, in Elementor and outside it: on the Orange County Dental Society site, which I built with WordPress blocks from a Figma design, features like the tag-filtered searches are custom plugins inserted as shortcodes. Here, the menu and the language switcher are:
<?php
/**
* [rw_primary_nav]: the header menu as a labelled <nav>, no JavaScript.
*/
function rw_primary_nav_shortcode() {
$es = function_exists( 'pll_current_language' ) && 'es' === pll_current_language();
$menu = wp_nav_menu(
[
'menu' => $es ? 'header-menu-es' : 'header-menu',
'container' => 'nav',
'container_aria_label' => $es ? 'Principal' : 'Main',
'menu_class' => 'rw-nav__list',
'depth' => 1,
'fallback_cb' => false,
'echo' => false,
]
);
return $menu ? $menu : '';
}
add_shortcode( 'rw_primary_nav', 'rw_primary_nav_shortcode' );
/**
* [rw_lang_switcher]: plain links in their own labelled <nav>.
*/
function rw_lang_switcher_shortcode() {
if ( ! function_exists( 'pll_the_languages' ) ) {
return '';
}
$languages = pll_the_languages( [ 'raw' => 1, 'hide_if_empty' => 0 ] );
$label = 'es' === pll_current_language() ? 'Idioma' : 'Language';
$items = '';
foreach ( (array) $languages as $lang ) {
$items .= sprintf(
'<li><a href="%1$s" lang="%2$s" hreflang="%2$s"%3$s>%4$s</a></li>',
esc_url( $lang['url'] ),
esc_attr( str_replace( '_', '-', $lang['locale'] ) ), // en_US becomes en-US.
empty( $lang['current_lang'] ) ? '' : ' aria-current="true"',
esc_html( $lang['name'] )
);
}
return sprintf( '<nav class="rw-lang" aria-label="%s"><ul>%s</ul></nav>', esc_attr( $label ), $items );
}
add_shortcode( 'rw_lang_switcher', 'rw_lang_switcher_shortcode' );
Two <nav> elements on one page need different names, or screen reader users hear “navigation” twice with no way to tell them apart. container_aria_label has been part of wp_nav_menu() since WordPress 5.5. The lang attribute on “Español” tells a screen reader to switch pronunciation (3.1.2 Language of Parts), and aria-current="true" marks the active language without relying on bold text alone.
On phones there is no hamburger. The menu becomes a second row that is always visible, so there is no disclosure button to get right and no hidden links.
Forms: labels, purpose and announcements
The contact form has visible labels (not placeholder-only fields) and a note that all fields are required (3.3.2 Labels or Instructions). The name and email fields need autocomplete="name" and autocomplete="email" for 1.3.5 Identify Input Purpose, but the V4 input only toggles autocomplete on or off. A few lines in the footer fill in the tokens by field ID:
<?php
/**
* 1.3.5: V4 form inputs only switch autocomplete on or off,
* so add the real tokens using the field IDs set in the form widget.
*/
function rw_contact_form_autocomplete() {
$js = <<<'JS'
(() => {
const tokens = { 'contact-name': 'name', 'contact-email': 'email' };
for (const [id, token] of Object.entries(tokens)) {
document.getElementById(id)?.setAttribute('autocomplete', token);
}
})();
JS;
wp_print_inline_script_tag( $js );
}
add_action( 'wp_footer', 'rw_contact_form_autocomplete', 100 );
Success and error messages sit in aria-live regions, so a screen reader announces the result without moving focus (4.1.3 Status Messages).
Then came the gotcha I found later. V4 forms render with the class .e-form-base, not .e-form, so every rule I had written for .e-form silently never applied. No error, no warning. Matching both names fixes it and survives either markup:
/* V4 forms render as .e-form-base; .e-form alone matches nothing. */
:is(.e-form, .e-form-base) input:not([type="checkbox"]):not([type="radio"]),
:is(.e-form, .e-form-base) textarea {
color: var(--ink, #0F172A) !important;
background-color: var(--surface, #FFFFFF) !important;
border: 1.5px solid var(--line-strong, #7B8AA3) !important; /* 3.50:1 on white */
border-radius: 0.5rem !important;
min-height: 3rem;
}
:is(.e-form, .e-form-base) :is(input, textarea)::placeholder {
color: var(--rw-placeholder, #5B6B82); /* 5.43:1 on white */
opacity: 1;
}
:is(.e-form, .e-form-base) [aria-invalid="true"] {
border-color: var(--rw-danger, #B42318) !important;
}
Contrast, checked by a script
Every text and background pair on the site passes AA: 4.5:1 for body text and 3:1 for large text (1.4.3 Contrast (Minimum)). Form field borders reach at least 3:1. I checked the pairs with a small script that computes the WCAG ratio for each combination of global variables, which is faster to rerun after a palette change than clicking through a contrast checker. For reference, body text #475569 on white is 7.58:1 and the field border above is 3.50:1.
Semantics the page builder won’t add for you
- Project facts (client, role, stack) render as a
<dl>from a shortcode, so each label stays paired with its value (1.3.1 Info and Relationships). - The project grid has list semantics, so screen readers announce how many projects there are before reading them.
- Headings should follow the page outline: one
h1per page, thenh2andh3. The V4 heading element makes the tag a setting, separate from its style, so check the tag on every heading instead of trusting the visual size.
Motion and reflow
The CSS respects prefers-reduced-motion: animations, transitions and smooth scrolling are cut to near zero for people who ask for less motion. Strictly speaking, the related criterion (2.3.3) is AAA, but the rule costs a dozen lines.
1.4.10 Reflow is AA, and it’s the one people skip. The layout has to work at 320 CSS pixels wide without horizontal scrolling. Set the DevTools viewport to 320px, scroll every page, and look for long URLs, email addresses, tables and fixed-width containers that push past the edge.
Results, and what the tools can’t tell you
After these changes, Lighthouse gives every page 100 in accessibility, and axe-core reports zero WCAG 2.1 AA violations in both light and dark mode. Automated checks cover only part of WCAG, though. They can’t tell you whether the focus order makes sense, whether an error message is helpful, or whether a skip link lands where you expect. Tab through each template, activate the skip link, and submit the form empty before you call it done.
What to watch out for
- Old snippets in Elementor Custom Code, the Customizer or a snippets plugin that set
outline: noneon buttons or links. - Class names that change between Elementor versions, like
.e-formand.e-form-base. Inspect the rendered DOM before blaming specificity. - A second
<main>. Rundocument.querySelectorAll('main').lengthin the console on each template; it should return 1. - Third-party banners. My cookie banner focuses its own container on load; that container isn’t a control, so the site removes the ring from it and keeps it on the banner’s buttons.
- Every color mode you ship. If you offer dark mode, run axe again with it switched on.
This is the kind of work I take on for clients: WordPress accessibility