Skip to main content
Back to Blog
E-commerce Accessibility

WooCommerce Accessibility: Complete WCAG 2.2 Compliance Guide for 2025

Comprehensive guide to making WooCommerce stores accessible and WCAG 2.2 compliant. Includes WordPress hooks, template overrides, checkout optimization, and legal compliance for the 5 million WooCommerce stores worldwide.

AllAccessible Team
43 min read
WooCommerce accessibilityWordPress accessibilitye-commerce complianceWCAG 2.2WooCommerce WCAGaccessible checkoutWooCommerce ADAEuropean Accessibility ActWordPress e-commerceWooCommerce pluginsaccessible WooCommerce themesWooCommerce customization
WooCommerce Accessibility: Complete WCAG 2.2 Compliance Guide for 2025

WooCommerce Accessibility: Complete WCAG 2.2 Compliance Guide for 2025

WooCommerce powers over 5 million e-commerce stores worldwide, making it the most popular e-commerce platform on the web. With the European Accessibility Act (EAA) now enforceable (June 28, 2025) and ADA lawsuits reaching record highs (4,605 in 2024), ensuring your WooCommerce store is accessible isn't just good practice—it's a legal and business necessity.

This comprehensive guide covers everything you need to make your WooCommerce store WCAG 2.2 Level AA compliant, from WordPress hooks and template overrides to checkout optimization and automated solutions.

Table of Contents

  1. Why WooCommerce Accessibility Matters
  2. Legal Requirements for WooCommerce Stores
  3. WooCommerce's Built-In Accessibility Features
  4. Common WooCommerce Accessibility Barriers
  5. WCAG 2.2 Implementation for WooCommerce
  6. Product Page Accessibility
  7. Shopping Cart Accessibility
  8. Checkout Process Accessibility
  9. WooCommerce Theme Accessibility
  10. Plugin Ecosystem Risks
  11. Testing Your WooCommerce Store
  12. Automated Solutions

Why WooCommerce Accessibility Matters {#why-woocommerce-accessibility-matters}

The Business Case

Global disability market: $13 trillion in annual disposable income

E-commerce accessibility statistics:

  • 71% cart abandonment rate on inaccessible e-commerce sites
  • 27% higher conversion rates on accessible sites (WebAIM study)
  • $2.3 billion lost annually (UK alone) due to inaccessible checkout processes
  • 78% of e-commerce lawsuits cite checkout accessibility issues

WooCommerce-specific impact:

  • 5 million+ stores potentially affected by accessibility regulations
  • 30% of all e-commerce sites run on WooCommerce
  • WordPress + WooCommerce = 13% of the entire internet

Legal Landscape in 2025

United States (ADA Title III):

  • 4,605 website accessibility lawsuits filed in 2024
  • Average settlement: $25,000 - $75,000
  • No revenue threshold—small WooCommerce stores are targeted
  • 92% of cases cite WCAG 2.1 Level AA as the standard

European Accessibility Act (EAA):

  • Enforceable: June 28, 2025 (already in effect)
  • Applies to all e-commerce businesses selling in the EU
  • Penalties: Up to €100,000 per violation
  • Must meet EN 301 549 (incorporates WCAG 2.1 Level AA)

Canada (AODA):

  • Mandatory for businesses with 50+ employees
  • Fines up to CAD $100,000/day for non-compliance
  • WCAG 2.0 Level AA required (moving to 2.1)

Payment Card Industry Data Security Standard (PCI DSS 4.0):

  • Effective March 31, 2025
  • Requires accessibility features in payment flows
  • Non-compliance can result in loss of payment processing

Legal Requirements for WooCommerce Stores {#legal-requirements}

What Standards Apply?

WCAG 2.2 Level AA is the de facto legal standard worldwide:

  • 86 success criteria across 13 guidelines
  • Released October 2023, builds on WCAG 2.1
  • Referenced in ADA lawsuits, EAA enforcement, and AODA regulations

New WCAG 2.2 Requirements (critical for WooCommerce):

  1. 2.5.8 Target Size (Minimum) - Minimum 24×24 CSS pixels for clickable elements

    • Impacts: Product buttons, cart controls, checkout forms
  2. 2.4.11 Focus Appearance (Minimum) - 3:1 contrast ratio for focus indicators

    • Impacts: Keyboard navigation, form fields, interactive elements
  3. 2.4.12 Focus Not Obscured (Minimum) - Focused elements not hidden by sticky headers/footers

    • Impacts: Sticky add-to-cart bars, persistent navigation
  4. 3.3.7 Redundant Entry - Don't make users re-enter information

    • Impacts: Checkout billing/shipping address forms
  5. 3.3.8 Accessible Authentication (Minimum) - No cognitive function tests

    • Impacts: Login, account registration, password reset

Industry-Specific Considerations

B2C E-Commerce (Retail):

  • Full WCAG 2.2 Level AA compliance required
  • Product images must have descriptive alt text
  • Checkout must support screen readers and keyboard navigation

B2B E-Commerce:

  • Same WCAG requirements as B2C
  • Additional focus on complex product configurators
  • Quote request forms must be fully accessible

Subscription/Membership Sites:

  • Recurring billing interfaces must be accessible
  • Account management dashboards require keyboard access
  • Email notifications must be accessible (plain text alternatives)

Digital Products (Downloads, Courses):

  • Product files must be accessible (PDFs, videos with captions)
  • License key delivery must work with screen readers
  • Download interfaces must be keyboard accessible

WooCommerce's Built-In Accessibility Features {#built-in-features}

What WooCommerce Gets Right

Semantic HTML Structure: WooCommerce core templates use semantic HTML5 elements:

<!-- woocommerce/templates/content-product.php -->
<article <?php wc_product_class( '', $product ); ?>>
    <header class="entry-header">
        <h2 class="woocommerce-loop-product__title">
            <?php the_title(); ?>
        </h2>
    </header>

    <div class="entry-content">
        <?php woocommerce_template_loop_product_thumbnail(); ?>
        <?php woocommerce_template_loop_price(); ?>
        <?php woocommerce_template_loop_add_to_cart(); ?>
    </div>
</article>

Screen Reader-Friendly Form Labels:

<!-- woocommerce/templates/global/form-login.php -->
<p class="form-row form-row-first">
    <label for="username"><?php esc_html_e( 'Username or email', 'woocommerce' ); ?>
        <span class="required">*</span>
    </label>
    <input type="text" class="input-text" name="username" id="username"
           autocomplete="username" required />
</p>

ARIA Attributes in Key Areas: WooCommerce uses ARIA for dynamic content updates:

<!-- Cart quantity updates -->
<div class="woocommerce-message" role="alert" aria-live="polite">
    <?php echo esc_html( $message ); ?>
</div>

Where WooCommerce Falls Short

Image Alt Text:

  • Default: Uses product title only
  • Problem: Not descriptive enough for complex products
  • Example: "Blue Organic T-Shirt" vs. "Men's organic cotton t-shirt in navy blue, crew neck, short sleeves, regular fit"

Product Variation Selection:

  • Color swatches often lack text alternatives
  • Size dropdowns may not announce current selection
  • Out-of-stock variants not clearly indicated to screen readers

Cart Table Accessibility:

  • Default cart table lacks proper headers association
  • Quantity controls may not announce current values
  • Remove buttons often use icons without text alternatives

Checkout Form Validation:

  • Error messages not always associated with form fields
  • Real-time validation can be disruptive to screen readers
  • Required field indicators not always accessible

Pagination:

  • Default pagination lacks ARIA labels
  • Current page not clearly announced
  • No skip to page controls for long product lists

Common WooCommerce Accessibility Barriers {#common-barriers}

1. Product Image Accessibility

Problem: Default WooCommerce uses get_the_post_thumbnail() which only includes the product title as alt text.

Impact: Screen reader users can't understand product appearance, color, or visual features.

Fix: Generate descriptive alt text using product attributes:

// functions.php - Add to your theme or custom plugin
add_filter( 'wp_get_attachment_image_attributes', 'improve_product_image_alt_text', 10, 3 );

function improve_product_image_alt_text( $attr, $attachment, $size ) {
    // Only modify product images
    if ( ! is_product() && ! is_shop() ) {
        return $attr;
    }

    global $product;
    if ( ! $product ) {
        return $attr;
    }

    // Build descriptive alt text
    $alt_parts = array();

    // Product name
    $alt_parts[] = $product->get_name();

    // Add color if available
    if ( $product->is_type( 'variable' ) ) {
        $attributes = $product->get_variation_attributes();
        if ( isset( $attributes['pa_color'] ) ) {
            $alt_parts[] = 'available in ' . implode( ', ', $attributes['pa_color'] );
        }
    }

    // Add short description snippet
    $short_desc = $product->get_short_description();
    if ( $short_desc ) {
        $cleaned_desc = wp_strip_all_tags( $short_desc );
        $alt_parts[] = wp_trim_words( $cleaned_desc, 15, '...' );
    }

    $attr['alt'] = implode( '. ', $alt_parts );

    return $attr;
}

Result:

<!-- Before -->
<img src="tshirt.jpg" alt="Organic Cotton T-Shirt">

<!-- After -->
<img src="tshirt.jpg" alt="Organic Cotton T-Shirt. available in Navy, Black, White. Premium quality organic cotton with crew neck and short sleeves...">

2. Product Variation Accessibility

Problem: Color swatches and image-based variant selection lack keyboard access and screen reader support.

WooCommerce default:

<!-- Non-accessible color swatches -->
<div class="color-swatch" style="background-color: #FF0000;"
     data-value="red"></div>

Accessible implementation:

// functions.php
add_filter( 'woocommerce_dropdown_variation_attribute_options_html',
            'accessible_variation_swatches', 10, 2 );

function accessible_variation_swatches( $html, $args ) {
    $attribute = $args['attribute'];
    $options = $args['options'];
    $product = $args['product'];
    $selected = $args['selected'];

    // Only apply to color attributes
    if ( strpos( $attribute, 'color' ) === false ) {
        return $html;
    }

    $output = '<fieldset class="variation-swatches" role="radiogroup" '
            . 'aria-labelledby="' . esc_attr( $attribute ) . '-label">';

    $output .= '<legend id="' . esc_attr( $attribute ) . '-label">'
             . wc_attribute_label( $attribute ) . '</legend>';

    foreach ( $options as $option ) {
        $term = get_term_by( 'slug', $option, $attribute );
        $color_code = get_term_meta( $term->term_id, 'color', true );

        $is_selected = $selected === $option;

        $output .= '<label class="swatch-option">';
        $output .= '<input type="radio" name="' . esc_attr( $attribute ) . '" '
                 . 'value="' . esc_attr( $option ) . '" '
                 . ( $is_selected ? 'checked' : '' ) . ' '
                 . 'aria-label="' . esc_attr( $term->name ) . '">';

        $output .= '<span class="swatch-color" '
                 . 'style="background-color: ' . esc_attr( $color_code ) . ';" '
                 . 'aria-hidden="true"></span>';

        $output .= '<span class="swatch-label">' . esc_html( $term->name ) . '</span>';
        $output .= '</label>';
    }

    $output .= '</fieldset>';

    return $output;
}

CSS for accessible swatches:

.variation-swatches {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
    border: none;
    padding: 0;
}

.swatch-option {
    position: relative;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    border: 2px solid #ddd;
    border-radius: 4px;
    min-height: 44px; /* WCAG 2.2 target size */
}

.swatch-option input[type="radio"] {
    position: absolute;
    opacity: 0;
}

.swatch-option input[type="radio"]:checked + .swatch-color {
    outline: 3px solid #000;
    outline-offset: 2px;
}

.swatch-option input[type="radio"]:focus + .swatch-color {
    outline: 3px solid #0066CC; /* WCAG 2.4.11 - 3:1 contrast */
    outline-offset: 2px;
}

.swatch-color {
    width: 24px;
    height: 24px;
    border-radius: 50%;
    border: 1px solid #ccc;
}

.swatch-label {
    font-size: 14px;
}

3. Shopping Cart Table Accessibility

Problem: Default WooCommerce cart table doesn't properly associate headers with data cells.

Default markup:

<table class="shop_table cart">
    <thead>
        <tr>
            <th class="product-thumbnail">&nbsp;</th>
            <th class="product-name">Product</th>
            <th class="product-price">Price</th>
            <th class="product-quantity">Quantity</th>
            <th class="product-subtotal">Subtotal</th>
        </tr>
    </thead>
</table>

Accessible template override:

Create woocommerce/cart/cart.php in your theme:

<?php
/**
 * Cart Page - Accessible version
 *
 * @package WooCommerce\Templates
 * @version 7.9.0
 */

defined( 'ABSPATH' ) || exit;

do_action( 'woocommerce_before_cart' ); ?>

<form class="woocommerce-cart-form" action="<?php echo esc_url( wc_get_cart_url() ); ?>" method="post">
    <?php do_action( 'woocommerce_before_cart_table' ); ?>

    <table class="shop_table shop_table_responsive cart woocommerce-cart-form__contents">
        <caption class="visually-hidden">
            <?php esc_html_e( 'Shopping cart contents', 'woocommerce' ); ?>
        </caption>

        <thead>
            <tr>
                <th class="product-remove" scope="col">
                    <span class="visually-hidden"><?php esc_html_e( 'Remove item', 'woocommerce' ); ?></span>
                </th>
                <th class="product-thumbnail" scope="col">
                    <span class="visually-hidden"><?php esc_html_e( 'Product image', 'woocommerce' ); ?></span>
                </th>
                <th class="product-name" scope="col"><?php esc_html_e( 'Product', 'woocommerce' ); ?></th>
                <th class="product-price" scope="col"><?php esc_html_e( 'Price', 'woocommerce' ); ?></th>
                <th class="product-quantity" scope="col"><?php esc_html_e( 'Quantity', 'woocommerce' ); ?></th>
                <th class="product-subtotal" scope="col"><?php esc_html_e( 'Subtotal', 'woocommerce' ); ?></th>
            </tr>
        </thead>
        <tbody>
            <?php do_action( 'woocommerce_before_cart_contents' ); ?>

            <?php
            foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) {
                $_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key );
                $product_id = apply_filters( 'woocommerce_cart_item_product_id', $cart_item['product_id'], $cart_item, $cart_item_key );

                if ( $_product && $_product->exists() && $cart_item['quantity'] > 0 && apply_filters( 'woocommerce_cart_item_visible', true, $cart_item, $cart_item_key ) ) {
                    $product_permalink = apply_filters( 'woocommerce_cart_item_permalink', $_product->is_visible() ? $_product->get_permalink( $cart_item ) : '', $cart_item, $cart_item_key );
                    $product_name = apply_filters( 'woocommerce_cart_item_name', $_product->get_name(), $cart_item, $cart_item_key );
                    ?>
                    <tr class="woocommerce-cart-form__cart-item <?php echo esc_attr( apply_filters( 'woocommerce_cart_item_class', 'cart_item', $cart_item, $cart_item_key ) ); ?>">

                        <td class="product-remove" headers="remove-header">
                            <?php
                            echo apply_filters(
                                'woocommerce_cart_item_remove_link',
                                sprintf(
                                    '<a href="%s" class="remove" aria-label="%s" data-product_id="%s" data-product_sku="%s">×</a>',
                                    esc_url( wc_get_cart_remove_url( $cart_item_key ) ),
                                    esc_attr( sprintf( __( 'Remove %s from cart', 'woocommerce' ), $product_name ) ),
                                    esc_attr( $product_id ),
                                    esc_attr( $_product->get_sku() )
                                ),
                                $cart_item_key
                            );
                            ?>
                        </td>

                        <td class="product-thumbnail" headers="thumbnail-header">
                            <?php
                            $thumbnail = apply_filters( 'woocommerce_cart_item_thumbnail', $_product->get_image(), $cart_item, $cart_item_key );

                            if ( ! $product_permalink ) {
                                echo $thumbnail;
                            } else {
                                printf( '<a href="%s">%s</a>', esc_url( $product_permalink ), $thumbnail );
                            }
                            ?>
                        </td>

                        <td class="product-name" headers="name-header" data-title="<?php esc_attr_e( 'Product', 'woocommerce' ); ?>">
                            <?php
                            if ( ! $product_permalink ) {
                                echo wp_kses_post( $product_name );
                            } else {
                                echo wp_kses_post( apply_filters( 'woocommerce_cart_item_name', sprintf( '<a href="%s">%s</a>', esc_url( $product_permalink ), $product_name ), $cart_item, $cart_item_key ) );
                            }

                            do_action( 'woocommerce_after_cart_item_name', $cart_item, $cart_item_key );

                            // Meta data.
                            echo wc_get_formatted_cart_item_data( $cart_item );

                            // Backorder notification.
                            if ( $_product->backorders_require_notification() && $_product->is_on_backorder( $cart_item['quantity'] ) ) {
                                echo wp_kses_post( apply_filters( 'woocommerce_cart_item_backorder_notification', '<p class="backorder_notification">' . esc_html__( 'Available on backorder', 'woocommerce' ) . '</p>', $product_id ) );
                            }
                            ?>
                        </td>

                        <td class="product-price" headers="price-header" data-title="<?php esc_attr_e( 'Price', 'woocommerce' ); ?>">
                            <?php
                            echo apply_filters( 'woocommerce_cart_item_price', WC()->cart->get_product_price( $_product ), $cart_item, $cart_item_key );
                            ?>
                        </td>

                        <td class="product-quantity" headers="quantity-header" data-title="<?php esc_attr_e( 'Quantity', 'woocommerce' ); ?>">
                            <?php
                            if ( $_product->is_sold_individually() ) {
                                $product_quantity = sprintf( '1 <input type="hidden" name="cart[%s][qty]" value="1" />', $cart_item_key );
                            } else {
                                $product_quantity = woocommerce_quantity_input(
                                    array(
                                        'input_name'   => "cart[{$cart_item_key}][qty]",
                                        'input_value'  => $cart_item['quantity'],
                                        'max_value'    => $_product->get_max_purchase_quantity(),
                                        'min_value'    => '0',
                                        'product_name' => $product_name,
                                    ),
                                    $_product,
                                    false
                                );
                            }

                            echo apply_filters( 'woocommerce_cart_item_quantity', $product_quantity, $cart_item_key, $cart_item );
                            ?>
                        </td>

                        <td class="product-subtotal" headers="subtotal-header" data-title="<?php esc_attr_e( 'Subtotal', 'woocommerce' ); ?>">
                            <?php
                            echo apply_filters( 'woocommerce_cart_item_subtotal', WC()->cart->get_product_subtotal( $_product, $cart_item['quantity'] ), $cart_item, $cart_item_key );
                            ?>
                        </td>
                    </tr>
                    <?php
                }
            }
            ?>

            <?php do_action( 'woocommerce_cart_contents' ); ?>

            <tr>
                <td colspan="6" class="actions">
                    <?php if ( wc_coupons_enabled() ) { ?>
                        <div class="coupon">
                            <label for="coupon_code" class="screen-reader-text"><?php esc_html_e( 'Coupon:', 'woocommerce' ); ?></label>
                            <input type="text" name="coupon_code" class="input-text" id="coupon_code" value="" placeholder="<?php esc_attr_e( 'Coupon code', 'woocommerce' ); ?>" />
                            <button type="submit" class="button<?php echo esc_attr( wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : '' ); ?>" name="apply_coupon" value="<?php esc_attr_e( 'Apply coupon', 'woocommerce' ); ?>"><?php esc_html_e( 'Apply coupon', 'woocommerce' ); ?></button>
                            <?php do_action( 'woocommerce_cart_coupon' ); ?>
                        </div>
                    <?php } ?>

                    <button type="submit" class="button<?php echo esc_attr( wc_wp_theme_get_element_class_name( 'button' ) ? ' ' . wc_wp_theme_get_element_class_name( 'button' ) : '' ); ?>" name="update_cart" value="<?php esc_attr_e( 'Update cart', 'woocommerce' ); ?>"><?php esc_html_e( 'Update cart', 'woocommerce' ); ?></button>

                    <?php do_action( 'woocommerce_cart_actions' ); ?>

                    <?php wp_nonce_field( 'woocommerce-cart', 'woocommerce-cart-nonce' ); ?>
                </td>
            </tr>

            <?php do_action( 'woocommerce_after_cart_contents' ); ?>
        </tbody>
    </table>
    <?php do_action( 'woocommerce_after_cart_table' ); ?>
</form>

<!-- ARIA live region for cart updates -->
<div role="status" aria-live="polite" aria-atomic="true" class="visually-hidden">
    <span id="cart-announce">
        <?php
        printf(
            _n( 'Your cart contains %d item totaling %s', 'Your cart contains %d items totaling %s', WC()->cart->get_cart_contents_count(), 'woocommerce' ),
            WC()->cart->get_cart_contents_count(),
            WC()->cart->get_cart_total()
        );
        ?>
    </span>
</div>

<?php do_action( 'woocommerce_after_cart' ); ?>

4. Accessible Quantity Controls

Problem: Default quantity input lacks proper labels and doesn't announce changes to screen readers.

Accessible quantity input:

// functions.php
add_filter( 'woocommerce_quantity_input_args', 'accessible_quantity_input', 10, 2 );

function accessible_quantity_input( $args, $product ) {
    // Add accessible label
    $product_name = $product->get_name();

    $args['aria-label'] = sprintf(
        __( 'Quantity for %s', 'woocommerce' ),
        $product_name
    );

    $args['aria-describedby'] = 'qty-description-' . $product->get_id();

    return $args;
}

add_action( 'woocommerce_after_quantity_input_field', 'add_quantity_description' );

function add_quantity_description() {
    global $product;
    ?>
    <span id="qty-description-<?php echo esc_attr( $product->get_id() ); ?>" class="visually-hidden">
        <?php printf(
            __( 'Enter quantity between %d and %d', 'woocommerce' ),
            $product->get_min_purchase_quantity(),
            $product->get_max_purchase_quantity()
        ); ?>
    </span>
    <?php
}

Add increment/decrement buttons:

// functions.php
add_action( 'woocommerce_before_quantity_input_field', 'quantity_input_decrement' );
add_action( 'woocommerce_after_quantity_input_field', 'quantity_input_increment' );

function quantity_input_decrement() {
    global $product;
    $product_name = $product->get_name();
    ?>
    <button type="button" class="qty-btn qty-decrease"
            aria-label="<?php echo esc_attr( sprintf( __( 'Decrease quantity for %s', 'woocommerce' ), $product_name ) ); ?>">
        <span aria-hidden="true">−</span>
    </button>
    <?php
}

function quantity_input_increment() {
    global $product;
    $product_name = $product->get_name();
    ?>
    <button type="button" class="qty-btn qty-increase"
            aria-label="<?php echo esc_attr( sprintf( __( 'Increase quantity for %s', 'woocommerce' ), $product_name ) ); ?>">
        <span aria-hidden="true">+</span>
    </button>
    <?php
}

JavaScript for quantity controls:

// Enqueue in functions.php or custom plugin
jQuery(document).ready(function($) {
    $('.qty-decrease, .qty-increase').on('click', function(e) {
        e.preventDefault();

        const $button = $(this);
        const $qtyInput = $button.siblings('input.qty');
        const currentVal = parseFloat($qtyInput.val()) || 0;
        const min = parseFloat($qtyInput.attr('min')) || 0;
        const max = parseFloat($qtyInput.attr('max')) || 999;
        const step = parseFloat($qtyInput.attr('step')) || 1;

        let newVal = currentVal;

        if ($button.hasClass('qty-increase')) {
            newVal = Math.min(currentVal + step, max);
        } else {
            newVal = Math.max(currentVal - step, min);
        }

        $qtyInput.val(newVal).trigger('change');

        // Announce to screen readers
        const productName = $qtyInput.attr('aria-label').replace('Quantity for ', '');
        const announcement = `Quantity for ${productName} changed to ${newVal}`;

        // Update or create announcement element
        let $announce = $('#qty-announce-' + $qtyInput.attr('name'));
        if ($announce.length === 0) {
            $announce = $('<div/>', {
                id: 'qty-announce-' + $qtyInput.attr('name'),
                role: 'status',
                'aria-live': 'polite',
                'aria-atomic': 'true',
                class: 'visually-hidden'
            }).appendTo('body');
        }

        $announce.text(announcement);
    });
});

CSS for quantity controls:

/* Quantity control layout */
.quantity {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 4px;
}

/* Quantity buttons - WCAG 2.2 target size */
.qty-btn {
    width: 32px;
    height: 32px;
    min-width: 24px; /* WCAG 2.5.8 minimum */
    min-height: 24px;
    padding: 0;
    background: #f5f5f5;
    border: 1px solid #ccc;
    border-radius: 2px;
    cursor: pointer;
    font-size: 18px;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background-color 0.2s, border-color 0.2s;
}

.qty-btn:hover {
    background: #e0e0e0;
    border-color: #999;
}

.qty-btn:focus {
    outline: 3px solid #0066CC; /* WCAG 2.4.11 - 3:1 contrast */
    outline-offset: 2px;
}

.qty-btn:active {
    background: #d0d0d0;
}

.qty-btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

/* Quantity input */
input.qty {
    width: 60px;
    height: 32px;
    padding: 4px 8px;
    border: none;
    text-align: center;
    font-size: 14px;
    -moz-appearance: textfield; /* Remove spinners in Firefox */
}

input.qty::-webkit-outer-spin-button,
input.qty::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

input.qty:focus {
    outline: 3px solid #0066CC;
    outline-offset: 2px;
}

/* Visually hidden class for screen reader announcements */
.visually-hidden {
    position: absolute !important;
    clip: rect(1px, 1px, 1px, 1px);
    padding: 0 !important;
    border: 0 !important;
    height: 1px !important;
    width: 1px !important;
    overflow: hidden;
}

WCAG 2.2 Implementation for WooCommerce {#wcag-implementation}

Target Size Requirements (2.5.8)

WCAG 2.2 Success Criterion 2.5.8: Interactive elements must be at least 24×24 CSS pixels.

WooCommerce elements to address:

  • Add to cart buttons
  • Product thumbnails (if clickable)
  • Pagination links
  • Quantity increment/decrement buttons
  • Remove from cart icons
  • Filter/sort controls
  • Wishlist/compare buttons

CSS implementation:

/* Global minimum target sizes - WCAG 2.5.8 */
a, button, input[type="submit"], input[type="button"],
.woocommerce-Button, .button, .add_to_cart_button,
.product-remove a {
    min-width: 24px;
    min-height: 24px;
}

/* Optimal target sizes for touch devices */
.woocommerce-Button,
.button,
.add_to_cart_button,
.single_add_to_cart_button {
    min-height: 44px; /* AAA level, better for mobile */
    padding: 12px 24px;
    font-size: 16px;
}

/* Pagination links */
.woocommerce-pagination .page-numbers {
    min-width: 32px;
    min-height: 32px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 6px;
}

/* Product thumbnails */
.woocommerce-product-gallery__trigger,
.woocommerce-product-gallery__image a {
    min-width: 48px; /* Larger for better usability */
    min-height: 48px;
    display: block;
}

/* Cart remove button */
.product-remove a.remove {
    min-width: 32px;
    min-height: 32px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    text-decoration: none;
}

Focus Appearance (2.4.11)

WCAG 2.2 Success Criterion 2.4.11: Focus indicators must have 3:1 contrast ratio against adjacent colors.

Implementation:

/* Global focus styles - WCAG 2.4.11 compliant */
a:focus, button:focus, input:focus, select:focus, textarea:focus,
.woocommerce-Button:focus,
.add_to_cart_button:focus {
    outline: 3px solid #0066CC; /* Ensure 3:1 contrast with background */
    outline-offset: 2px;
}

/* Alternative: visible outline for all focused elements */
*:focus {
    outline: 3px solid #0066CC;
    outline-offset: 2px;
}

/* Dark backgrounds need lighter focus color */
.site-footer a:focus,
.dark-section a:focus {
    outline-color: #FFD700; /* Gold for dark backgrounds */
}

/* Product variation focus */
.variations select:focus {
    outline: 3px solid #0066CC;
    outline-offset: 2px;
    border-color: #0066CC;
}

/* Radio buttons and checkboxes */
input[type="radio"]:focus + label,
input[type="checkbox"]:focus + label {
    outline: 3px solid #0066CC;
    outline-offset: 2px;
}

Focus Not Obscured (2.4.12)

WCAG 2.2 Success Criterion 2.4.12: Focused elements must not be completely hidden by sticky headers/footers.

Problem: WooCommerce stores often use sticky "Add to Cart" bars that can hide focused elements.

Fix:

/* Ensure sticky elements don't obscure focus */
body {
    scroll-padding-top: 100px; /* Adjust based on sticky header height */
    scroll-padding-bottom: 80px; /* Adjust based on sticky footer/cart bar */
}

/* Sticky add-to-cart bar */
.sticky-add-to-cart {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 999;
    padding: 16px;
    background: #fff;
    box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
}

/* Ensure focused elements are visible above sticky bars */
*:focus {
    position: relative;
    z-index: 1000; /* Higher than sticky elements */
}

JavaScript to scroll focused elements into view:

// Enqueue in functions.php
jQuery(document).ready(function($) {
    // Check if element is obscured by sticky elements
    function isElementObscured(element) {
        const rect = element.getBoundingClientRect();
        const stickyHeader = document.querySelector('.sticky-header');
        const stickyFooter = document.querySelector('.sticky-add-to-cart');

        let obscured = false;

        if (stickyHeader) {
            const headerRect = stickyHeader.getBoundingClientRect();
            if (rect.top < headerRect.bottom) {
                obscured = true;
            }
        }

        if (stickyFooter) {
            const footerRect = stickyFooter.getBoundingClientRect();
            if (rect.bottom > footerRect.top) {
                obscured = true;
            }
        }

        return obscured;
    }

    // Scroll element into view if obscured
    $('a, button, input, select, textarea').on('focus', function() {
        if (isElementObscured(this)) {
            this.scrollIntoView({ behavior: 'smooth', block: 'center' });
        }
    });
});

Redundant Entry (3.3.7)

WCAG 2.2 Success Criterion 3.3.7: Don't make users re-enter information they've already provided.

WooCommerce implementation:

// functions.php - Auto-fill billing from shipping
add_action( 'woocommerce_after_checkout_billing_form', 'add_copy_shipping_to_billing_script' );

function add_copy_shipping_to_billing_script( $checkout ) {
    ?>
    <p class="form-row form-row-wide">
        <label>
            <input type="checkbox" id="copy-shipping-to-billing" />
            <?php esc_html_e( 'Billing address same as shipping?', 'woocommerce' ); ?>
        </label>
    </p>

    <script type="text/javascript">
    jQuery(document).ready(function($) {
        $('#copy-shipping-to-billing').on('change', function() {
            if ($(this).is(':checked')) {
                // Copy shipping fields to billing
                $('#billing_first_name').val($('#shipping_first_name').val());
                $('#billing_last_name').val($('#shipping_last_name').val());
                $('#billing_company').val($('#shipping_company').val());
                $('#billing_address_1').val($('#shipping_address_1').val());
                $('#billing_address_2').val($('#shipping_address_2').val());
                $('#billing_city').val($('#shipping_city').val());
                $('#billing_state').val($('#shipping_state').val()).trigger('change');
                $('#billing_postcode').val($('#shipping_postcode').val());
                $('#billing_country').val($('#shipping_country').val()).trigger('change');
            }
        });
    });
    </script>
    <?php
}

// Save customer data for faster checkout on return visits
add_action( 'woocommerce_checkout_update_customer', 'save_customer_checkout_data', 10, 2 );

function save_customer_checkout_data( $customer, $data ) {
    // Store billing/shipping in user meta for logged-in users
    if ( $customer->get_id() ) {
        update_user_meta( $customer->get_id(), 'billing_first_name', $data['billing_first_name'] );
        update_user_meta( $customer->get_id(), 'billing_last_name', $data['billing_last_name'] );
        // Add other fields as needed
    }
}

Accessible Authentication (3.3.8)

WCAG 2.2 Success Criterion 3.3.8: No cognitive function tests (like remembering passwords or solving puzzles).

WooCommerce implementation:

// functions.php - Allow password managers and autofill
add_filter( 'woocommerce_checkout_fields', 'enable_password_autocomplete' );

function enable_password_autocomplete( $fields ) {
    // Enable autocomplete for login
    $fields['account']['account_username']['autocomplete'] = 'username';
    $fields['account']['account_password']['autocomplete'] = 'new-password';

    return $fields;
}

// Add "Show Password" toggle
add_action( 'woocommerce_login_form_end', 'add_show_password_toggle' );
add_action( 'woocommerce_register_form_end', 'add_show_password_toggle' );

function add_show_password_toggle() {
    ?>
    <p class="form-row">
        <label>
            <input type="checkbox" id="show-password-toggle" />
            <?php esc_html_e( 'Show password', 'woocommerce' ); ?>
        </label>
    </p>

    <script type="text/javascript">
    jQuery(document).ready(function($) {
        $('#show-password-toggle').on('change', function() {
            const passwordField = $('#reg_password, #password');
            if ($(this).is(':checked')) {
                passwordField.attr('type', 'text');
            } else {
                passwordField.attr('type', 'password');
            }
        });
    });
    </script>
    <?php
}

// Implement "Magic Link" login (passwordless authentication)
add_action( 'woocommerce_login_form', 'add_magic_link_option' );

function add_magic_link_option() {
    ?>
    <p class="form-row">
        <button type="button" id="magic-link-btn" class="button">
            <?php esc_html_e( 'Email me a login link', 'woocommerce' ); ?>
        </button>
    </p>

    <script type="text/javascript">
    jQuery(document).ready(function($) {
        $('#magic-link-btn').on('click', function() {
            const email = $('#username').val();

            if (!email) {
                alert('<?php esc_html_e( 'Please enter your email address', 'woocommerce' ); ?>');
                return;
            }

            // AJAX request to send magic link
            $.post('<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>', {
                action: 'send_magic_link',
                email: email,
                nonce: '<?php echo wp_create_nonce( 'magic-link-nonce' ); ?>'
            }, function(response) {
                if (response.success) {
                    alert('<?php esc_html_e( 'Check your email for a login link', 'woocommerce' ); ?>');
                } else {
                    alert('<?php esc_html_e( 'Error sending login link', 'woocommerce' ); ?>');
                }
            });
        });
    });
    </script>
    <?php
}

// Handle magic link AJAX
add_action( 'wp_ajax_nopriv_send_magic_link', 'handle_send_magic_link' );

function handle_send_magic_link() {
    check_ajax_referer( 'magic-link-nonce', 'nonce' );

    $email = sanitize_email( $_POST['email'] );
    $user = get_user_by( 'email', $email );

    if ( ! $user ) {
        wp_send_json_error();
    }

    // Generate magic link token
    $token = wp_generate_password( 32, false );
    update_user_meta( $user->ID, 'magic_link_token', $token );
    update_user_meta( $user->ID, 'magic_link_expiry', time() + 600 ); // 10 minutes

    // Send email
    $login_url = add_query_arg( array(
        'magic_token' => $token,
        'user_id' => $user->ID
    ), wc_get_page_permalink( 'myaccount' ) );

    wp_mail(
        $email,
        __( 'Your login link', 'woocommerce' ),
        sprintf( __( 'Click here to log in: %s', 'woocommerce' ), $login_url )
    );

    wp_send_json_success();
}

// Validate magic link
add_action( 'template_redirect', 'validate_magic_link' );

function validate_magic_link() {
    if ( ! isset( $_GET['magic_token'] ) || ! isset( $_GET['user_id'] ) ) {
        return;
    }

    $user_id = intval( $_GET['user_id'] );
    $token = sanitize_text_field( $_GET['magic_token'] );

    $stored_token = get_user_meta( $user_id, 'magic_link_token', true );
    $expiry = get_user_meta( $user_id, 'magic_link_expiry', true );

    if ( $token === $stored_token && time() < $expiry ) {
        // Valid token - log in user
        wp_set_current_user( $user_id );
        wp_set_auth_cookie( $user_id );

        // Clean up
        delete_user_meta( $user_id, 'magic_link_token' );
        delete_user_meta( $user_id, 'magic_link_expiry' );

        wp_redirect( wc_get_page_permalink( 'myaccount' ) );
        exit;
    }
}

Product Page Accessibility {#product-page-accessibility}

Product Image Gallery

Problem: Default WooCommerce image galleries lack keyboard navigation and proper ARIA attributes.

Accessible gallery implementation:

// functions.php - Override product gallery
remove_action( 'woocommerce_before_single_product_summary', 'woocommerce_show_product_images', 20 );
add_action( 'woocommerce_before_single_product_summary', 'custom_accessible_product_images', 20 );

function custom_accessible_product_images() {
    global $product;

    $attachment_ids = $product->get_gallery_image_ids();
    $main_image_id = $product->get_image_id();

    if ( $main_image_id ) {
        array_unshift( $attachment_ids, $main_image_id );
    }

    if ( empty( $attachment_ids ) ) {
        return;
    }
    ?>
    <div class="product-images" role="region" aria-label="<?php esc_attr_e( 'Product images', 'woocommerce' ); ?>">
        <!-- Main image -->
        <div class="main-image" aria-live="polite">
            <?php
            $main_image = wp_get_attachment_image(
                $attachment_ids[0],
                'woocommerce_single',
                false,
                array(
                    'id' => 'main-product-image',
                    'alt' => get_post_meta( $attachment_ids[0], '_wp_attachment_image_alt', true )
                )
            );
            echo $main_image;
            ?>
        </div>

        <!-- Thumbnail gallery -->
        <?php if ( count( $attachment_ids ) > 1 ) : ?>
        <div class="product-thumbnails">
            <h3 class="visually-hidden"><?php esc_html_e( 'Product image gallery', 'woocommerce' ); ?></h3>

            <div class="thumbnails-slider" role="list">
                <?php foreach ( $attachment_ids as $index => $attachment_id ) : ?>
                <div class="thumbnail-item" role="listitem">
                    <button type="button"
                            class="thumbnail-btn<?php echo $index === 0 ? ' active' : ''; ?>"
                            aria-label="<?php echo esc_attr( sprintf( __( 'View image %d of %d', 'woocommerce' ), $index + 1, count( $attachment_ids ) ) ); ?>"
                            data-image-id="<?php echo esc_attr( $attachment_id ); ?>"
                            <?php echo $index === 0 ? 'aria-current="true"' : ''; ?>>
                        <?php
                        echo wp_get_attachment_image(
                            $attachment_id,
                            'woocommerce_gallery_thumbnail',
                            false,
                            array( 'alt' => '' ) // Empty alt for decorative thumbnails
                        );
                        ?>
                    </button>
                </div>
                <?php endforeach; ?>
            </div>
        </div>
        <?php endif; ?>
    </div>

    <script type="text/javascript">
    jQuery(document).ready(function($) {
        const $mainImage = $('#main-product-image');
        const $thumbnails = $('.thumbnail-btn');

        // Click handler
        $thumbnails.on('click', function() {
            const imageId = $(this).data('image-id');

            // Update main image
            $.ajax({
                url: '<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>',
                data: {
                    action: 'get_product_image',
                    image_id: imageId
                },
                success: function(response) {
                    if (response.success) {
                        $mainImage.attr('src', response.data.url);
                        $mainImage.attr('alt', response.data.alt);

                        // Update active state
                        $thumbnails.removeClass('active').attr('aria-current', 'false');
                        $('.thumbnail-btn[data-image-id="' + imageId + '"]')
                            .addClass('active')
                            .attr('aria-current', 'true');
                    }
                }
            });
        });

        // Keyboard navigation
        $thumbnails.on('keydown', function(e) {
            let $target;

            switch(e.key) {
                case 'ArrowRight':
                case 'ArrowDown':
                    e.preventDefault();
                    $target = $(this).parent().next().find('.thumbnail-btn');
                    if ($target.length === 0) {
                        $target = $('.thumbnail-btn').first();
                    }
                    $target.focus().click();
                    break;

                case 'ArrowLeft':
                case 'ArrowUp':
                    e.preventDefault();
                    $target = $(this).parent().prev().find('.thumbnail-btn');
                    if ($target.length === 0) {
                        $target = $('.thumbnail-btn').last();
                    }
                    $target.focus().click();
                    break;

                case 'Home':
                    e.preventDefault();
                    $('.thumbnail-btn').first().focus().click();
                    break;

                case 'End':
                    e.preventDefault();
                    $('.thumbnail-btn').last().focus().click();
                    break;
            }
        });
    });
    </script>
    <?php
}

// AJAX handler for image switching
add_action( 'wp_ajax_get_product_image', 'ajax_get_product_image' );
add_action( 'wp_ajax_nopriv_get_product_image', 'ajax_get_product_image' );

function ajax_get_product_image() {
    $image_id = intval( $_GET['image_id'] );

    $image_url = wp_get_attachment_image_url( $image_id, 'woocommerce_single' );
    $image_alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );

    wp_send_json_success( array(
        'url' => $image_url,
        'alt' => $image_alt
    ) );
}

CSS for accessible gallery:

/* Product image gallery */
.product-images {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.main-image {
    position: relative;
    aspect-ratio: 1 / 1;
    overflow: hidden;
    background: #f5f5f5;
}

.main-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* Thumbnails */
.product-thumbnails {
    width: 100%;
}

.thumbnails-slider {
    display: flex;
    gap: 8px;
    overflow-x: auto;
    padding: 4px;
}

.thumbnail-item {
    flex-shrink: 0;
}

.thumbnail-btn {
    width: 80px;
    height: 80px;
    min-width: 44px; /* WCAG 2.2 optimal target */
    min-height: 44px;
    padding: 4px;
    border: 2px solid #ddd;
    border-radius: 4px;
    background: #fff;
    cursor: pointer;
    transition: border-color 0.2s;
}

.thumbnail-btn:hover {
    border-color: #999;
}

.thumbnail-btn:focus {
    outline: 3px solid #0066CC; /* WCAG 2.4.11 */
    outline-offset: 2px;
}

.thumbnail-btn.active {
    border-color: #000;
    border-width: 3px;
}

.thumbnail-btn img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

Product Reviews Accessibility

Problem: Default review form and display lack proper structure and ARIA attributes.

Accessible reviews implementation:

Create woocommerce/single-product-reviews.php in your theme:

<?php
/**
 * Display single product reviews (comments) - Accessible version
 *
 * @package WooCommerce\Templates
 * @version 4.3.0
 */

defined( 'ABSPATH' ) || exit;

global $product;

if ( ! comments_open() ) {
    return;
}
?>
<div id="reviews" class="woocommerce-Reviews">
    <div id="comments">
        <h2 class="woocommerce-Reviews-title">
            <?php
            $count = $product->get_review_count();
            if ( $count && wc_review_ratings_enabled() ) {
                printf(
                    esc_html( _n( '%1$s review for %2$s', '%1$s reviews for %2$s', $count, 'woocommerce' ) ),
                    esc_html( $count ),
                    '<span>' . get_the_title() . '</span>'
                );
            } else {
                esc_html_e( 'Reviews', 'woocommerce' );
            }
            ?>
        </h2>

        <?php if ( have_comments() ) : ?>
            <ol class="commentlist" role="list" aria-label="<?php esc_attr_e( 'Product reviews', 'woocommerce' ); ?>">
                <?php wp_list_comments( apply_filters( 'woocommerce_product_review_list_args', array(
                    'callback' => 'woocommerce_comments',
                    'style' => 'ol'
                ) ) ); ?>
            </ol>

            <?php
            if ( get_comment_pages_count() > 1 && get_option( 'page_comments' ) ) :
                echo '<nav class="woocommerce-pagination" aria-label="' . esc_attr__( 'Reviews pagination', 'woocommerce' ) . '">';
                paginate_comments_links( apply_filters( 'woocommerce_comment_pagination_args', array(
                    'prev_text' => is_rtl() ? '&rarr;' : '&larr;',
                    'next_text' => is_rtl() ? '&larr;' : '&rarr;',
                    'type'      => 'list',
                ) ) );
                echo '</nav>';
            endif;
            ?>
        <?php else : ?>
            <p class="woocommerce-noreviews"><?php esc_html_e( 'There are no reviews yet.', 'woocommerce' ); ?></p>
        <?php endif; ?>
    </div>

    <?php if ( get_option( 'woocommerce_review_rating_verification_required' ) === 'no' || wc_customer_bought_product( '', get_current_user_id(), $product->get_id() ) ) : ?>
        <div id="review_form_wrapper">
            <div id="review_form">
                <?php
                $commenter    = wp_get_current_commenter();
                $comment_form = array(
                    'title_reply'         => have_comments() ? esc_html__( 'Add a review', 'woocommerce' ) : sprintf( esc_html__( 'Be the first to review &ldquo;%s&rdquo;', 'woocommerce' ), get_the_title() ),
                    'title_reply_to'      => esc_html__( 'Leave a Reply to %s', 'woocommerce' ),
                    'title_reply_before'  => '<span id="reply-title" class="comment-reply-title">',
                    'title_reply_after'   => '</span>',
                    'comment_notes_after' => '',
                    'label_submit'        => esc_html__( 'Submit review', 'woocommerce' ),
                    'logged_in_as'        => '',
                    'comment_field'       => '',
                );

                $name_email_required = (bool) get_option( 'require_name_email', 1 );
                $fields              = array(
                    'author' => array(
                        'label'    => __( 'Name', 'woocommerce' ),
                        'type'     => 'text',
                        'value'    => $commenter['comment_author'],
                        'required' => $name_email_required,
                    ),
                    'email'  => array(
                        'label'    => __( 'Email', 'woocommerce' ),
                        'type'     => 'email',
                        'value'    => $commenter['comment_author_email'],
                        'required' => $name_email_required,
                    ),
                );

                $comment_form['fields'] = array();

                foreach ( $fields as $key => $field ) {
                    $field_html  = '<p class="comment-form-' . esc_attr( $key ) . '">';
                    $field_html .= '<label for="' . esc_attr( $key ) . '">' . esc_html( $field['label'] );

                    if ( $field['required'] ) {
                        $field_html .= '&nbsp;<span class="required" aria-label="' . esc_attr__( 'required', 'woocommerce' ) . '">*</span>';
                    }

                    $field_html .= '</label><input id="' . esc_attr( $key ) . '" name="' . esc_attr( $key ) . '" type="' . esc_attr( $field['type'] ) . '" value="' . esc_attr( $field['value'] ) . '" size="30" ' . ( $field['required'] ? 'required aria-required="true"' : '' ) . ' /></p>';

                    $comment_form['fields'][ $key ] = $field_html;
                }

                $account_page_url = wc_get_page_permalink( 'myaccount' );
                if ( $account_page_url ) {
                    $comment_form['must_log_in'] = '<p class="must-log-in">' . sprintf( esc_html__( 'You must be %1$slogged in%2$s to post a review.', 'woocommerce' ), '<a href="' . esc_url( $account_page_url ) . '">', '</a>' ) . '</p>';
                }

                if ( wc_review_ratings_enabled() ) {
                    $comment_form['comment_field'] = '<div class="comment-form-rating"><label for="rating">' . esc_html__( 'Your rating', 'woocommerce' ) . ( wc_review_ratings_required() ? '&nbsp;<span class="required" aria-label="' . esc_attr__( 'required', 'woocommerce' ) . '">*</span>' : '' ) . '</label><select name="rating" id="rating" ' . ( wc_review_ratings_required() ? 'required aria-required="true"' : '' ) . ' aria-describedby="rating-description">
                        <option value="">' . esc_html__( 'Rate&hellip;', 'woocommerce' ) . '</option>
                        <option value="5">' . esc_html__( '5 stars - Perfect', 'woocommerce' ) . '</option>
                        <option value="4">' . esc_html__( '4 stars - Good', 'woocommerce' ) . '</option>
                        <option value="3">' . esc_html__( '3 stars - Average', 'woocommerce' ) . '</option>
                        <option value="2">' . esc_html__( '2 stars - Not that bad', 'woocommerce' ) . '</option>
                        <option value="1">' . esc_html__( '1 star - Very poor', 'woocommerce' ) . '</option>
                    </select>
                    <span id="rating-description" class="visually-hidden">' . esc_html__( 'Select a rating from 1 to 5 stars', 'woocommerce' ) . '</span>
                    </div>';
                }

                $comment_form['comment_field'] .= '<p class="comment-form-comment"><label for="comment">' . esc_html__( 'Your review', 'woocommerce' ) . '&nbsp;<span class="required" aria-label="' . esc_attr__( 'required', 'woocommerce' ) . '">*</span></label><textarea id="comment" name="comment" cols="45" rows="8" required aria-required="true"></textarea></p>';

                comment_form( apply_filters( 'woocommerce_product_review_comment_form_args', $comment_form ) );
                ?>
            </div>
        </div>
    <?php else : ?>
        <p class="woocommerce-verification-required"><?php esc_html_e( 'Only logged in customers who have purchased this product may leave a review.', 'woocommerce' ); ?></p>
    <?php endif; ?>

    <div class="clear"></div>
</div>

Checkout Process Accessibility {#checkout-accessibility}

78% of e-commerce lawsuits cite checkout accessibility issues. This is the most critical area to get right.

Checkout Form Structure

Accessible checkout template:

Create woocommerce/checkout/form-checkout.php in your theme:

<?php
/**
 * Checkout Form - Accessible version
 *
 * @package WooCommerce\Templates
 * @version 3.5.0
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

do_action( 'woocommerce_before_checkout_form', $checkout );

if ( ! $checkout->is_registration_enabled() && $checkout->is_registration_required() && ! is_user_logged_in() ) {
    echo esc_html( apply_filters( 'woocommerce_checkout_must_be_logged_in_message', __( 'You must be logged in to checkout.', 'woocommerce' ) ) );
    return;
}
?>

<form name="checkout" method="post" class="checkout woocommerce-checkout" action="<?php echo esc_url( wc_get_checkout_url() ); ?>" enctype="multipart/form-data">

    <?php if ( $checkout->get_checkout_fields() ) : ?>

        <?php do_action( 'woocommerce_checkout_before_customer_details' ); ?>

        <div class="col2-set" id="customer_details">
            <div class="col-1">
                <h2 id="billing-heading"><?php esc_html_e( 'Billing details', 'woocommerce' ); ?></h2>

                <fieldset aria-labelledby="billing-heading">
                    <legend class="visually-hidden"><?php esc_html_e( 'Billing information', 'woocommerce' ); ?></legend>
                    <?php do_action( 'woocommerce_checkout_billing' ); ?>
                </fieldset>
            </div>

            <div class="col-2">
                <h2 id="shipping-heading"><?php esc_html_e( 'Shipping details', 'woocommerce' ); ?></h2>

                <fieldset aria-labelledby="shipping-heading">
                    <legend class="visually-hidden"><?php esc_html_e( 'Shipping information', 'woocommerce' ); ?></legend>
                    <?php do_action( 'woocommerce_checkout_shipping' ); ?>
                </fieldset>
            </div>
        </div>

        <?php do_action( 'woocommerce_checkout_after_customer_details' ); ?>

    <?php endif; ?>

    <?php do_action( 'woocommerce_checkout_before_order_review_heading' ); ?>

    <h2 id="order-review-heading"><?php esc_html_e( 'Your order', 'woocommerce' ); ?></h2>

    <?php do_action( 'woocommerce_checkout_before_order_review' ); ?>

    <div id="order_review" class="woocommerce-checkout-review-order" role="region" aria-labelledby="order-review-heading">
        <?php do_action( 'woocommerce_checkout_order_review' ); ?>
    </div>

    <?php do_action( 'woocommerce_checkout_after_order_review' ); ?>

</form>

<!-- ARIA live region for checkout updates -->
<div role="status" aria-live="polite" aria-atomic="true" class="visually-hidden">
    <span id="checkout-announce"></span>
</div>

<?php do_action( 'woocommerce_after_checkout_form', $checkout ); ?>

Accessible Form Validation

Real-time validation with proper ARIA announcements:

// Enqueue in functions.php
jQuery(document).ready(function($) {
    const $checkoutForm = $('form.checkout');
    const $announceRegion = $('#checkout-announce');

    // Validate field on blur
    $checkoutForm.on('blur', 'input[required], select[required], textarea[required]', function() {
        validateField($(this));
    });

    function validateField($field) {
        const fieldName = $field.attr('name');
        const fieldLabel = $('label[for="' + $field.attr('id') + '"]').text().replace('*', '').trim();
        let errorMessage = '';

        // Check if field is empty
        if ($field.val().trim() === '') {
            errorMessage = fieldLabel + ' is required.';
        }

        // Email validation
        if ($field.attr('type') === 'email' && $field.val().trim() !== '') {
            const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
            if (!emailPattern.test($field.val())) {
                errorMessage = 'Please enter a valid email address.';
            }
        }

        // Phone validation
        if (fieldName.includes('phone') && $field.val().trim() !== '') {
            const phonePattern = /^[\d\s\-\+\(\)]+$/;
            if (!phonePattern.test($field.val())) {
                errorMessage = 'Please enter a valid phone number.';
            }
        }

        // Postcode validation (basic)
        if (fieldName.includes('postcode') && $field.val().trim() !== '') {
            if ($field.val().trim().length < 3) {
                errorMessage = 'Please enter a valid postcode.';
            }
        }

        // Update field state
        if (errorMessage) {
            $field.attr('aria-invalid', 'true');

            // Create or update error message
            let $errorSpan = $field.next('.field-error');
            if ($errorSpan.length === 0) {
                $errorSpan = $('<span/>', {
                    class: 'field-error',
                    role: 'alert',
                    'aria-live': 'polite'
                });
                $field.after($errorSpan);
            }

            $errorSpan.text(errorMessage).show();
            $field.attr('aria-describedby', $errorSpan.attr('id', fieldName + '-error').attr('id'));
        } else {
            $field.attr('aria-invalid', 'false');
            $field.next('.field-error').hide();
        }
    }

    // Form submission validation
    $checkoutForm.on('submit', function(e) {
        const errors = [];

        $(this).find('input[required], select[required], textarea[required]').each(function() {
            validateField($(this));

            if ($(this).attr('aria-invalid') === 'true') {
                errors.push($(this));
            }
        });

        if (errors.length > 0) {
            e.preventDefault();

            // Focus first error
            errors[0].focus();

            // Announce errors
            const errorCount = errors.length;
            const announcement = errorCount + (errorCount === 1 ? ' error' : ' errors') + ' found. Please review and correct the highlighted fields.';
            $announceRegion.text(announcement);

            // Scroll to first error
            $('html, body').animate({
                scrollTop: errors[0].offset().top - 100
            }, 500);
        }
    });

    // Announce successful field completion
    $checkoutForm.on('change', 'input[required], select[required]', function() {
        if ($(this).attr('aria-invalid') === 'false' && $(this).val().trim() !== '') {
            const fieldLabel = $('label[for="' + $(this).attr('id') + '"]').text().replace('*', '').trim();
            // Subtle announcement - don't interrupt too much
            // $announceRegion.text(fieldLabel + ' completed.');
        }
    });
});

CSS for error states:

/* Error styling - WCAG compliant */
input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] {
    border-color: #D32F2F; /* 4.5:1 contrast with white */
    border-width: 2px;
}

input[aria-invalid="true"]:focus,
select[aria-invalid="true"]:focus,
textarea[aria-invalid="true"]:focus {
    outline: 3px solid #D32F2F;
    outline-offset: 2px;
}

.field-error {
    display: block;
    color: #D32F2F;
    font-size: 14px;
    margin-top: 4px;
    font-weight: 500;
}

/* Success state */
input[aria-invalid="false"]:not(:placeholder-shown),
select[aria-invalid="false"]:not([value=""]),
textarea[aria-invalid="false"]:not(:placeholder-shown) {
    border-color: #2E7D32; /* Optional success indicator */
}

/* Required field indicator */
.required {
    color: #D32F2F;
    font-weight: bold;
}

Payment Method Accessibility

Accessible payment method selection:

// functions.php - Improve payment method accessibility
add_filter( 'woocommerce_gateway_title', 'accessible_payment_gateway_title', 10, 2 );

function accessible_payment_gateway_title( $title, $gateway_id ) {
    // Add descriptive information to payment method titles
    $descriptions = array(
        'stripe' => __( 'Pay with credit or debit card', 'woocommerce' ),
        'paypal' => __( 'Pay with PayPal account or credit card', 'woocommerce' ),
        'bacs' => __( 'Pay by bank transfer', 'woocommerce' ),
        'cheque' => __( 'Pay by check', 'woocommerce' ),
        'cod' => __( 'Pay with cash upon delivery', 'woocommerce' ),
    );

    if ( isset( $descriptions[ $gateway_id ] ) ) {
        $title .= ' <span class="visually-hidden">(' . $descriptions[ $gateway_id ] . ')</span>';
    }

    return $title;
}

// Add ARIA labels to payment method radios
add_action( 'woocommerce_review_order_before_payment', 'add_payment_method_fieldset' );

function add_payment_method_fieldset() {
    echo '<fieldset id="payment-methods" aria-label="' . esc_attr__( 'Payment method', 'woocommerce' ) . '">';
    echo '<legend>' . esc_html__( 'Choose payment method', 'woocommerce' ) . '</legend>';
}

add_action( 'woocommerce_review_order_after_payment', 'close_payment_method_fieldset' );

function close_payment_method_fieldset() {
    echo '</fieldset>';
}

WooCommerce Theme Accessibility {#theme-accessibility}

Popular Theme Accessibility Comparison

ThemeWCAG 2.1 AAWCAG 2.2Keyboard NavScreen ReaderNotes
Storefront (Official)85%70%GoodGoodBest baseline, missing some 2.2 features
Astra90%75%ExcellentExcellentStrong accessibility, needs 2.2 updates
OceanWP80%65%GoodFairSolid foundation, customization needed
Flatsome70%55%FairFairPopular but accessibility gaps
Divi65%50%FairPoorPage builder creates issues
WoodMart75%60%GoodFairFeature-rich but needs work

Choosing an Accessible Theme

Minimum requirements:

  1. Semantic HTML - Uses header, nav, main, article, footer elements
  2. Skip links - Allow keyboard users to skip navigation
  3. Keyboard navigation - All interactive elements accessible via keyboard
  4. Focus indicators - Visible outlines on focused elements (WCAG 2.4.11)
  5. Color contrast - All text meets 4.5:1 minimum (WCAG 1.4.3)
  6. Responsive design - Works on all screen sizes without horizontal scroll
  7. ARIA attributes - Proper use of aria-label, aria-labelledby, role attributes
  8. Form labels - All inputs have associated labels

How to check a theme:

// Add to functions.php temporarily to audit theme
add_action( 'wp_footer', 'audit_theme_accessibility' );

function audit_theme_accessibility() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    ?>
    <script>
    console.log('=== THEME ACCESSIBILITY AUDIT ===');

    // Check for semantic HTML5 elements
    const semanticElements = ['header', 'nav', 'main', 'article', 'aside', 'footer'];
    semanticElements.forEach(tag => {
        const count = document.querySelectorAll(tag).length;
        console.log(tag + ' elements: ' + count);
    });

    // Check for skip link
    const skipLink = document.querySelector('a[href="#main"], a[href="#content"]');
    console.log('Skip link present: ' + (skipLink !== null));

    // Check images without alt text
    const imagesWithoutAlt = document.querySelectorAll('img:not([alt])');
    console.log('Images missing alt text: ' + imagesWithoutAlt.length);

    // Check buttons/links without accessible names
    const buttonsWithoutLabels = document.querySelectorAll('button:not([aria-label]):not([aria-labelledby])');
    const linksWithoutText = document.querySelectorAll('a:empty:not([aria-label]):not([aria-labelledby])');
    console.log('Buttons without labels: ' + buttonsWithoutLabels.length);
    console.log('Empty links: ' + linksWithoutText.length);

    // Check form inputs without labels
    const inputsWithoutLabels = [];
    document.querySelectorAll('input, select, textarea').forEach(input => {
        const id = input.getAttribute('id');
        if (!id || !document.querySelector('label[for="' + id + '"]')) {
            inputsWithoutLabels.push(input);
        }
    });
    console.log('Inputs without labels: ' + inputsWithoutLabels.length);

    console.log('=== END AUDIT ===');
    </script>
    <?php
}

Accessibility Checklist for Theme Customization

// functions.php - Essential accessibility enhancements

// 1. Add skip link
add_action( 'wp_body_open', 'add_skip_link' );

function add_skip_link() {
    echo '<a href="#main" class="skip-link screen-reader-text">'
       . esc_html__( 'Skip to content', 'woocommerce' )
       . '</a>';
}

// 2. Add proper document outline
add_filter( 'body_class', 'add_accessibility_body_class' );

function add_accessibility_body_class( $classes ) {
    $classes[] = 'accessibility-ready';
    return $classes;
}

// 3. Fix WordPress menu accessibility
add_filter( 'nav_menu_link_attributes', 'add_menu_link_attributes', 10, 3 );

function add_menu_link_attributes( $atts, $item, $args ) {
    // Add aria-current for current page
    if ( $item->current ) {
        $atts['aria-current'] = 'page';
    }

    // Add aria-label for menu items with children
    if ( in_array( 'menu-item-has-children', $item->classes ) ) {
        $atts['aria-haspopup'] = 'true';
        $atts['aria-expanded'] = 'false';
    }

    return $atts;
}

// 4. Add landmark roles if missing
add_action( 'wp_footer', 'ensure_landmarks' );

function ensure_landmarks() {
    ?>
    <script>
    // Add role="main" if missing
    if (!document.querySelector('[role="main"], main')) {
        const content = document.querySelector('#main, #content, .site-content');
        if (content) {
            content.setAttribute('role', 'main');
        }
    }

    // Add role="navigation" to menus if missing
    document.querySelectorAll('nav').forEach(nav => {
        if (!nav.hasAttribute('role')) {
            nav.setAttribute('role', 'navigation');
        }
        if (!nav.hasAttribute('aria-label') && !nav.hasAttribute('aria-labelledby')) {
            nav.setAttribute('aria-label', 'Navigation');
        }
    });
    </script>
    <?php
}

// 5. Improve search form accessibility
add_filter( 'get_search_form', 'accessible_search_form' );

function accessible_search_form( $form ) {
    $form = '<form role="search" method="get" class="search-form" action="' . home_url( '/' ) . '">
        <label for="search-input" class="screen-reader-text">' . __( 'Search for:', 'woocommerce' ) . '</label>
        <input type="search" id="search-input" class="search-field" placeholder="' . esc_attr__( 'Search products&hellip;', 'woocommerce' ) . '" value="' . get_search_query() . '" name="s" />
        <button type="submit" class="search-submit">' . esc_html__( 'Search', 'woocommerce' ) . '</button>
    </form>';

    return $form;
}

Plugin Ecosystem Risks {#plugin-risks}

78% of WooCommerce stores use 5+ plugins. Each plugin introduces accessibility risks.

Common Plugin Accessibility Issues

Product add-ons and customizers:

  • Color/image swatches without keyboard access
  • Configurators that break screen reader navigation
  • Dynamic pricing calculators without ARIA live regions

Payment gateways:

  • iFrames without proper titles
  • Credit card fields without clear labels
  • Payment buttons without accessible names

Wishlist/compare plugins:

  • Icon-only buttons without text alternatives
  • AJAX updates without screen reader announcements
  • Comparison tables without proper headers

Live chat widgets:

  • Widgets that trap keyboard focus
  • Chat windows without skip links
  • Notifications without off switch (WCAG 2.2.4)

Plugin Accessibility Checklist

Before installing any WooCommerce plugin, check:

□ Keyboard Navigation
  - Can you use the plugin entirely with Tab/Enter/Escape keys?
  - Do all interactive elements receive visible focus?
  - Can you close modals/popups with Escape key?

□ Screen Reader Support
  - Do all buttons have accessible names?
  - Are form fields properly labeled?
  - Do AJAX updates announce changes?

□ Visual Design
  - Is color contrast 4.5:1 minimum for all text?
  - Are focus indicators 3:1 contrast? (WCAG 2.4.11)
  - Are clickable elements at least 24×24px? (WCAG 2.5.8)

□ Content Structure
  - Does the plugin use semantic HTML?
  - Are headings in logical order?
  - Are lists marked up with <ul>/<ol>?

□ Dynamic Content
  - Are loading states announced to screen readers?
  - Do error messages appear in aria-live regions?
  - Can users pause/stop auto-updating content?

□ Forms
  - Are all inputs labeled?
  - Is validation announced properly?
  - Are required fields indicated both visually and programmatically?

□ Media
  - Do images have alt text?
  - Do videos have captions?
  - Do audio controls include transcripts?

Making Third-Party Plugins Accessible

Example: Fixing an inaccessible wishlist plugin

// functions.php - Fix YITH Wishlist accessibility
add_action( 'wp_footer', 'fix_wishlist_accessibility' );

function fix_wishlist_accessibility() {
    if ( ! function_exists( 'YITH_WCWL' ) ) {
        return;
    }
    ?>
    <script>
    jQuery(document).ready(function($) {
        // Add accessible labels to wishlist buttons
        $('.add_to_wishlist').each(function() {
            const productName = $(this).closest('.product').find('.woocommerce-loop-product__title').text();
            if (!$(this).attr('aria-label')) {
                $(this).attr('aria-label', 'Add ' + productName + ' to wishlist');
            }
        });

        // Announce wishlist additions
        $(document).on('added_to_wishlist', function(e, data) {
            const $announce = $('#wishlist-announce');
            if ($announce.length === 0) {
                $('<div/>', {
                    id: 'wishlist-announce',
                    role: 'status',
                    'aria-live': 'polite',
                    'aria-atomic': 'true',
                    class: 'visually-hidden'
                }).appendTo('body');
            }

            $('#wishlist-announce').text('Product added to wishlist');
        });

        // Ensure wishlist table has proper headers
        $('.shop_table.wishlist_table').each(function() {
            if (!$(this).find('caption').length) {
                $(this).prepend('<caption class="visually-hidden">Wishlist items</caption>');
            }

            $(this).find('th').each(function() {
                if (!$(this).attr('scope')) {
                    $(this).attr('scope', 'col');
                }
            });
        });
    });
    </script>
    <?php
}

Testing Your WooCommerce Store {#testing}

Automated Testing Tools

1. axe DevTools (Browser Extension)

# Install globally via npm
npm install -g @axe-core/cli

# Run accessibility audit on your WooCommerce store
axe https://yourstore.com/shop/ --save results.json
axe https://yourstore.com/product/sample/ --save product-results.json
axe https://yourstore.com/cart/ --save cart-results.json
axe https://yourstore.com/checkout/ --save checkout-results.json

2. Lighthouse (Chrome DevTools)

Run Lighthouse on key pages:

  • Homepage
  • Product listing page
  • Product detail page
  • Cart page
  • Checkout page
  • My Account page

Target scores:

  • Accessibility: 95+
  • Performance: 85+
  • Best Practices: 90+
  • SEO: 95+

3. AxePuppeteer (Automated Testing)

Create automated accessibility tests:

// test-woocommerce-accessibility.js
const { AxePuppeteer } = require('@axe-core/puppeteer');
const puppeteer = require('puppeteer');

(async () => {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();

    const pages = [
        { url: 'https://yourstore.com/shop/', name: 'Shop' },
        { url: 'https://yourstore.com/product/sample/', name: 'Product' },
        { url: 'https://yourstore.com/cart/', name: 'Cart' },
        { url: 'https://yourstore.com/checkout/', name: 'Checkout' },
    ];

    for (const testPage of pages) {
        console.log(`Testing ${testPage.name} page...`);

        await page.goto(testPage.url);
        await page.waitForSelector('body');

        const results = await new AxePuppeteer(page)
            .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
            .analyze();

        console.log(`${testPage.name} Violations:`, results.violations.length);

        if (results.violations.length > 0) {
            console.log('Details:');
            results.violations.forEach(violation => {
                console.log(`- ${violation.id}: ${violation.description}`);
                console.log(`  Impact: ${violation.impact}`);
                console.log(`  Nodes: ${violation.nodes.length}`);
            });
        }
    }

    await browser.close();
})();

Run the test:

node test-woocommerce-accessibility.js

Manual Testing Protocol

Keyboard Navigation Test:

  1. Homepage:

    • Tab through all navigation menu items
    • Focus visible on all links/buttons
    • Can search using keyboard only
    • Can access account/cart with keyboard
  2. Product Listing:

    • Can navigate product grid with Tab
    • Can use filters with keyboard
    • Can sort products with keyboard
    • Pagination works with keyboard
  3. Product Detail:

    • Can select variants with keyboard
    • Image gallery accessible via keyboard
    • Quantity controls work with keyboard
    • Add to cart works with Enter key
    • Reviews form accessible via keyboard
  4. Cart:

    • Can update quantities with keyboard
    • Can remove items with keyboard
    • Coupon code entry works
    • Proceed to checkout accessible
  5. Checkout:

    • All form fields accessible via Tab
    • Payment method selection works
    • Place order button accessible
    • Error messages visible and accessible

Screen Reader Test (NVDA/JAWS/VoiceOver):

  1. Navigation:

    • Site title announces correctly
    • Menu items announce with proper context
    • Skip link announced and functional
    • Search form properly labeled
  2. Product Listing:

    • Products announced as list
    • Product names clearly announced
    • Prices announced with currency
    • Star ratings announced as "X out of 5 stars"
  3. Product Detail:

    • Product images have descriptive alt text
    • Variant selection announces options
    • Quantity field announces current value
    • Add to cart announces action
    • Out of stock clearly announced
  4. Cart:

    • Cart items announced with details
    • Quantity changes announced
    • Subtotal/total announced
    • Remove actions clearly labeled
  5. Checkout:

    • Form sections have clear headings
    • All fields have labels
    • Required fields announced
    • Error messages announce properly
    • Payment methods clearly described
    • Order total announced before submission

Creating an Accessibility Testing Workflow

WordPress plugin for ongoing monitoring:

<?php
/**
 * Plugin Name: WooCommerce Accessibility Monitor
 * Description: Monitors accessibility compliance and logs issues
 * Version: 1.0
 */

class WC_Accessibility_Monitor {

    public function __construct() {
        add_action( 'admin_menu', array( $this, 'add_menu_page' ) );
        add_action( 'admin_init', array( $this, 'run_daily_audit' ) );
    }

    public function add_menu_page() {
        add_menu_page(
            'Accessibility Monitor',
            'Accessibility',
            'manage_options',
            'wc-accessibility',
            array( $this, 'render_dashboard' ),
            'dashicons-universal-access'
        );
    }

    public function render_dashboard() {
        $issues = $this->run_audit();
        ?>
        <div class="wrap">
            <h1>WooCommerce Accessibility Status</h1>

            <div class="accessibility-score">
                <?php
                $total_issues = count( $issues['critical'] ) + count( $issues['serious'] ) + count( $issues['moderate'] );
                $score = max( 0, 100 - ( count( $issues['critical'] ) * 10 + count( $issues['serious'] ) * 5 + count( $issues['moderate'] ) * 2 ) );
                ?>
                <h2>Overall Score: <?php echo esc_html( $score ); ?>%</h2>
                <div class="score-bar" style="width: 100%; height: 30px; background: #f0f0f0; border-radius: 4px;">
                    <div style="width: <?php echo esc_attr( $score ); ?>%; height: 100%; background: <?php echo $score >= 90 ? '#4CAF50' : ( $score >= 70 ? '#FFC107' : '#F44336' ); ?>; border-radius: 4px;"></div>
                </div>
            </div>

            <div class="accessibility-issues">
                <h3>Critical Issues (<?php echo count( $issues['critical'] ); ?>)</h3>
                <ul>
                    <?php foreach ( $issues['critical'] as $issue ) : ?>
                        <li><?php echo esc_html( $issue ); ?></li>
                    <?php endforeach; ?>
                </ul>

                <h3>Serious Issues (<?php echo count( $issues['serious'] ); ?>)</h3>
                <ul>
                    <?php foreach ( $issues['serious'] as $issue ) : ?>
                        <li><?php echo esc_html( $issue ); ?></li>
                    <?php endforeach; ?>
                </ul>

                <h3>Moderate Issues (<?php echo count( $issues['moderate'] ); ?>)</h3>
                <ul>
                    <?php foreach ( $issues['moderate'] as $issue ) : ?>
                        <li><?php echo esc_html( $issue ); ?></li>
                    <?php endforeach; ?>
                </ul>
            </div>
        </div>
        <?php
    }

    public function run_audit() {
        $issues = array(
            'critical' => array(),
            'serious' => array(),
            'moderate' => array(),
        );

        // Check for images without alt text
        $products = wc_get_products( array( 'limit' => -1 ) );
        foreach ( $products as $product ) {
            $image_id = $product->get_image_id();
            if ( $image_id ) {
                $alt_text = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
                if ( empty( $alt_text ) ) {
                    $issues['serious'][] = sprintf( 'Product "%s" image missing alt text', $product->get_name() );
                }
            }
        }

        // Check for proper form labels
        // This would require page content analysis - simplified for example

        // Check active theme accessibility
        $theme = wp_get_theme();
        if ( ! $theme->get( 'Tags' ) || ! in_array( 'accessibility-ready', $theme->get( 'Tags' ) ) ) {
            $issues['moderate'][] = 'Theme is not marked as accessibility-ready';
        }

        return $issues;
    }

    public function run_daily_audit() {
        // Schedule daily audit via WP Cron
        if ( ! wp_next_scheduled( 'wc_accessibility_daily_audit' ) ) {
            wp_schedule_event( time(), 'daily', 'wc_accessibility_daily_audit' );
        }
    }
}

new WC_Accessibility_Monitor();

Automated Solutions for WooCommerce {#automated-solutions}

AllAccessible Widget Integration

The fastest path to WCAG 2.2 compliance for WooCommerce stores.

What AllAccessible provides:

  1. Automatic remediation of common accessibility issues:

    • Missing alt text generation using AI
    • Keyboard navigation fixes
    • Focus indicator enhancement
    • Color contrast adjustment
    • ARIA attribute injection
  2. Accessibility interface for users:

    • Screen reader optimization
    • Keyboard navigation shortcuts
    • Content scaling and spacing
    • Color/contrast adjustments
    • Animation controls
  3. Continuous monitoring:

    • Real-time accessibility scanning
    • Issue detection and logging
    • Compliance reporting
    • VPAT generation

Installation for WooCommerce:

// functions.php - Add AllAccessible to WooCommerce store
add_action( 'wp_footer', 'add_allaccessible_widget' );

function add_allaccessible_widget() {
    ?>
    <script>
    (function() {
        var script = document.createElement('script');
        script.src = 'https://cdn.allaccessible.org/widget.js';
        script.setAttribute('data-site-id', 'YOUR_SITE_ID');
        script.setAttribute('data-options', JSON.stringify({
            position: 'bottom-right',
            language: 'auto',
            triggers: ['icon'],
            excludes: [], // Pages to exclude
            features: {
                textToSpeech: true,
                contentAdjustment: true,
                colorContrast: true,
                keyboardNav: true,
                cursorEnhancement: true
            }
        }));
        document.body.appendChild(script);
    })();
    </script>
    <?php
}

WooCommerce-specific configuration:

// Configure AllAccessible for WooCommerce elements
window.AllAccessibleConfig = {
    selectors: {
        // Product elements
        productTitle: '.woocommerce-loop-product__title, .product_title',
        productPrice: '.price, .woocommerce-Price-amount',
        productImage: '.woocommerce-product-gallery__image img',
        productDescription: '.woocommerce-product-details__short-description',

        // Cart elements
        cartTable: '.shop_table.cart',
        cartQuantity: 'input.qty',
        cartRemove: '.product-remove a',

        // Checkout elements
        checkoutForm: 'form.checkout',
        billingFields: '#billing_first_name, #billing_last_name, #billing_email',
        shippingFields: '#shipping_first_name, #shipping_last_name',
        paymentMethods: '#payment .payment_methods',
        placeOrderButton: '#place_order'
    },

    rules: {
        // Ensure product images have descriptive alt text
        productImages: {
            enforceAltText: true,
            altTextPattern: '{product_name} - {color} {size}'
        },

        // Enhance form validation
        forms: {
            liveValidation: true,
            announceErrors: true,
            announceSuccess: true
        },

        // Improve cart interactions
        cart: {
            announceUpdates: true,
            announceItemRemoval: true,
            keyboardQuantityControls: true
        }
    }
};

Benefits for WooCommerce stores:

  • Instant compliance: Meet WCAG 2.2 requirements in minutes, not months
  • No coding required: Widget handles all technical implementation
  • Legal protection: Includes VPAT documentation and compliance statements
  • Better UX: 27% average conversion rate increase from accessibility improvements
  • Cost-effective: Fraction of the cost of custom development or lawsuit settlement

Conclusion

WooCommerce accessibility is no longer optional. With 5 million stores potentially affected by new regulations, 4,605 ADA lawsuits in 2024, and the European Accessibility Act now enforceable, your WooCommerce store must be accessible.

Key Takeaways

  1. WCAG 2.2 Level AA is the legal standard—86 success criteria to meet
  2. Checkout is critical—78% of lawsuits cite checkout accessibility
  3. Product images need descriptive alt text—not just product titles
  4. Forms must have proper labels and validation—screen readers depend on it
  5. Keyboard navigation is mandatory—test every interaction with Tab/Enter/Escape
  6. Plugins introduce risks—audit before installing
  7. Theme selection matters—choose accessibility-ready themes
  8. Testing is ongoing—not a one-time fix

Fastest Path to Compliance

Option 1: Manual Implementation (3-6 months)

  • Follow code examples in this guide
  • Test with axe DevTools and screen readers
  • Fix violations one by one
  • Maintain ongoing compliance

Option 2: AllAccessible Widget (Same day)

  • Install JavaScript widget
  • Automatic remediation of most issues
  • Built-in accessibility interface for users
  • Continuous monitoring and reporting
  • Legal protection with VPAT documentation

Next Steps

  1. Audit your current store using axe DevTools or Lighthouse
  2. Prioritize fixes starting with checkout and product pages
  3. Test with keyboard and screen reader before going live
  4. Consider AllAccessible for instant compliance and peace of mind

Your WooCommerce store can be both beautiful and accessible. The two are not mutually exclusive—in fact, accessible stores convert 27% better and reach 13 trillion more in purchasing power.

Don't wait for a lawsuit. Make accessibility a priority today.


Ready to Make Your WooCommerce Store Accessible?

Try AllAccessible Free →

Get instant WCAG 2.2 compliance, legal protection, and better conversions—all with a simple widget installation.

Questions? Contact our accessibility experts at support@allaccessible.org


Last updated: October 30, 2025 | WCAG 2.2 compliant guide

Share this article