Price, variant, stock and cart updates must be announced

When a customer picks a size and the price or availability changes, or adds a product to the cart, sighted customers see the update. Screen reader users hear nothing, so they do not know the product was added or that the chosen size is sold out.

Rule id
vs-live-updates
Source
Validiscan check
Impact
Serious
Fix tier
AUTO
on Shopify, Shopware, WooCommerce and Magento
Status
runs

What the check does

Detects price, variant, stock and cart changes on product and cart pages that happen without a live region, and aria-live regions that wrap the whole page (4.1.3).

The check runs only on product and cart pages. Each aria-live="polite" or "assertive" on html, body, main or an element containing main is reported.

It changes one control outside header, navigation, footer and dialogs, blocking form submission. Product page: the first variant select (named for size, color or similar, or in the product area), else a variant radio group, else swatch buttons with one selected. Cart page: the first quantity field, changed by one.

Before and after, it reads texts of up to 120 characters whose itemprop, class or id suggests a price, stock, cart count or total, and the add-to-cart button; the page gets up to 2 seconds to settle. A changed visible text is reported unless inside or containing a live region (aria-live="polite" or "assertive", role="status", "alert", "log", output) or another live region announced a change (for prices and stock, one containing the new value). A change that does not revert when the control is reset goes to needs review, which does not become a finding today.

How to fix it

Add a small, permanent live region (role="status" or aria-live="polite") for the price, stock and cart count, and update its text when the variant or the cart changes. Do not put aria-live on large containers or the whole page, which makes screen readers read everything again.

WCAG success criteria

EN 301 549 clauses

The EN 301 549 clause of each criterion, by version of the standard
WCAG criterion Version 3.2.1 Version 4.1.1
4.1.3 9.4.1.3 9.4.1.3

Where a recipe fixes it

Shopify: recipe live-region-cart-variant

Show the before and after (1)
Before and after Example data

sections/main-cart.liquid Changes to sections/main-cart.liquid: 1 added, 1 removed

Changes to sections/main-cart.liquid: 1 added, 1 removed
Line before Line after Change Code
37 37
38 38 <div class="cart__footer">
39 39 <h2 class="cart__summary-title">Order summary</h2>
40 Removed <p class="totals">
40 Added <p class="totals" role="status">
41 41 <span class="totals__label">{{ 'sections.cart.subtotal' | t }}</span>
42 42 <span class="totals__value" data-cart-subtotal>{{ cart.total_price | money_with_currency }}</span>
43 43 </p>

sections/main-product.liquid Changes to sections/main-product.liquid: 1 added, 1 removed

Changes to sections/main-product.liquid: 1 added, 1 removed
Line before Line after Change Code
71 71 </div>
72 72 {%- endif %}
73 73
74 Removed <div class="product__price" data-price-container>
74 Added <div class="product__price" data-price-container role="status">
75 75 {% render 'price', product: product, use_variant: true %}
76 76 <p class="product__inventory" data-stock>{% if current_variant.available %}{{ 'products.product.in_stock' | t }}{% else %}{{ 'products.product.out_of_stock' | t }}{% endif %}</p>
77 77 <p class="product__sku">{{ 'products.product.sku' | t }}: <span data-sku>{{ current_variant.sku }}</span></p>

Shopware: recipe live-region-cart-variant

Show the before and after (1)
makes the variant price and the cart totals polite live regions; the updates are announced Example data

Resources/views/storefront/page/checkout/cart/index.html.twig Changes to Resources/views/storefront/page/checkout/cart/index.html.twig: 2 added, 2 removed

Changes to Resources/views/storefront/page/checkout/cart/index.html.twig: 2 added, 2 removed
Line before Line after Change Code
59 59 <span class="line-item-unit-price-label">{{ 'checkout.cartHeaderUnitPrice'|trans }}:</span>
60 60 <span class="line-item-unit-price-value">{{ item.unitPrice|currency }}*</span>
61 61 </div>
62 Removed <div class="line-item-total-price">
62 Added <div class="line-item-total-price" aria-live="polite" aria-atomic="true">
63 63 <span class="line-item-total-price-label">{{ 'checkout.cartHeaderTotalPrice'|trans }}:</span>
64 64 <span class="line-item-total-price-value" data-line-total>{{ item.totalPrice|currency }}*</span>
65 65 </div>
88 88 <div class="col-lg-4 checkout-aside">
89 89 <div class="checkout-aside-summary">
90 90 <h2 class="checkout-aside-summary-header">{{ 'checkout.summaryHeader'|trans }}</h2>
91 Removed <div class="checkout-aside-summary-totals" data-shipping-threshold="{{ shop.shippingThreshold }}" data-shipping-costs="{{ shop.shippingCosts }}">
91 Added <div class="checkout-aside-summary-totals" data-shipping-threshold="{{ shop.shippingThreshold }}" data-shipping-costs="{{ shop.shippingCosts }}" aria-live="polite" aria-atomic="true">
92 92 <dl class="checkout-aside-summary-list">
93 93 <dt class="checkout-aside-summary-label">{{ 'checkout.summaryPositionPrice'|trans }}</dt>
94 94 <dd class="checkout-aside-summary-value" data-cart-subtotal>{{ cart.subtotal|currency }}*</dd>

Resources/views/storefront/page/product-detail/buy-widget.html.twig Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 1 added, 1 removed

Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 1 added, 1 removed
Line before Line after Change Code
17 17 {% endblock %}
18 18
19 19 {% block buy_widget_price %}
20 Removed <div class="product-detail-price-container">
20 Added <div class="product-detail-price-container" aria-live="polite" aria-atomic="true">
21 21 <meta itemprop="priceCurrency" content="{{ shop.currency.isoCode }}">
22 22 <meta itemprop="price" content="{{ product.price }}">
23 23 <p class="product-detail-price{% if product.listPrice %} with-list-price{% endif %}" data-price-display>{{ product.price|currency }}*</p>

WooCommerce: recipe live-region-cart-variant

Show the before and after (2)
adds a status region next to the header cart fragment and wires the theme script Example data

assets/js/theme.js Changes to assets/js/theme.js: 15 added, 0 removed

Changes to assets/js/theme.js: 15 added, 0 removed
Line before Line after Change Code
255 255 initCheckout();
256 256 initShowPassword();
257 257 })();
258 Added
259 Added /* validiscan:live-region-cart-variant */
260 Added // Announces cart changes in the status region next to the header cart (WCAG 4.1.3).
261 Added (function () {
262 Added const region = document.querySelector('[data-kb-cart-status]');
263 Added if (!region || !window.jQuery) return;
264 Added window.jQuery(document.body).on('added_to_cart removed_from_cart', (event) => {
265 Added const message = region.getAttribute(event.type === 'added_to_cart' ? 'data-added' : 'data-removed');
266 Added region.textContent = '';
267 Added window.setTimeout(() => {
268 Added region.textContent = message || '';
269 Added }, 100);
270 Added });
271 Added })();
272 Added /* /validiscan:live-region-cart-variant */

header.php Changes to header.php: 1 added, 0 removed

Changes to header.php: 1 added, 0 removed
Line before Line after Change Code
67 67 <?php kb_icon( 'bag' ); ?><span class="kb-cart-amount"><?php echo wp_kses_post( WC()->cart->get_cart_subtotal() ); ?></span>
68 68 <span class="count"><?php echo esc_html( sprintf( _n( '%d item', '%d items', WC()->cart->get_cart_contents_count(), 'storefront' ), WC()->cart->get_cart_contents_count() ) ); ?></span>
69 69 </a>
70 Added <span class="screen-reader-text" role="status" data-kb-cart-status data-added="<?php esc_attr_e( 'Product added to your cart.', 'kestrel-birch' ); ?>" data-removed="<?php esc_attr_e( 'Product removed from your cart.', 'kestrel-birch' ); ?>"></span>
70 71 </li>
71 72 </ul>
72 73 </div>
makes the variation price and stock of the fixture theme a polite live region Example data

woocommerce/single-product/add-to-cart/variable.php Changes to woocommerce/single-product/add-to-cart/variable.php: 1 added, 1 removed

Changes to woocommerce/single-product/add-to-cart/variable.php: 1 added, 1 removed
Line before Line after Change Code
65 65 </table>
66 66
67 67 <div class="single_variation_wrap">
68 Removed <div class="woocommerce-variation single_variation">
68 Added <div class="woocommerce-variation single_variation" aria-live="polite" aria-atomic="true">
69 69 <div class="woocommerce-variation-description"></div>
70 70 <div class="woocommerce-variation-price"><span class="price"><?php echo wp_kses_post( wc_price( $kb_selected['display_price'] ) ); ?></span></div>
71 71 <div class="woocommerce-variation-availability"><?php echo wp_kses_post( $kb_selected['availability_html'] ); ?></div>

Magento: recipe live-region-cart-variant

Show the before and after (1)
makes the price box and the stock message live regions and validates Example data

app/design/frontend/Validiscan/fixture/Magento_Catalog/templates/product/price/final_price.phtml Changes to app/design/frontend/Validiscan/fixture/Magento_Catalog/templates/product/price/final_price.phtml: 1 added, 1 removed

Changes to app/design/frontend/Validiscan/fixture/Magento_Catalog/templates/product/price/final_price.phtml: 1 added, 1 removed
Line before Line after Change Code
13 13 // Fixed variant: the product-page box carries aria-live="polite" aria-atomic="true" and the stock
14 14 // message (product/view/type/default.phtml) is role="status".
15 15 ?>
16 Removed <div class="price-box price-final_price" data-role="priceBox" data-product-id="<?= (int) $product->getId() ?>" data-price-box="product-id-<?= (int) $product->getId() ?>"<?php if ($isProductPage): ?> data-base-price="<?= (float) $amount ?>"<?php endif; ?>>
16 Added <div class="price-box price-final_price" data-role="priceBox" data-product-id="<?= (int) $product->getId() ?>" data-price-box="product-id-<?= (int) $product->getId() ?>"<?php if ($isProductPage): ?> data-base-price="<?= (float) $amount ?>"<?php endif; ?> aria-live="polite" aria-atomic="true">
17 17 <span class="normal-price"><span class="price-container price-final_price tax weee"><span id="product-price-<?= (int) $product->getId() ?>" data-price-amount="<?= (float) $amount ?>" data-price-type="finalPrice" class="price-wrapper"><span class="price"><?= $escaper->escapeHtml($block->renderAmount($block->getPriceType('final_price')->getAmount(), ['display_label' => false])) ?></span></span></span></span>
18 18 </div>

app/design/frontend/Validiscan/fixture/Magento_Catalog/templates/product/view/type/default.phtml Changes to app/design/frontend/Validiscan/fixture/Magento_Catalog/templates/product/view/type/default.phtml: 1 added, 1 removed

Changes to app/design/frontend/Validiscan/fixture/Magento_Catalog/templates/product/view/type/default.phtml: 1 added, 1 removed
Line before Line after Change Code
11 11 ?>
12 12 <div class="product-info-stock-sku">
13 13 <?php if ($block->displayProductStockStatus()): ?>
14 Removed <div class="stock available" title="<?= $escaper->escapeHtmlAttr(__('Availability')) ?>"><span><?= $escaper->escapeHtml(__('In stock')) ?></span></div>
14 Added <div class="stock available" title="<?= $escaper->escapeHtmlAttr(__('Availability')) ?>" role="status"><span><?= $escaper->escapeHtml(__('In stock')) ?></span></div>
15 15 <?php endif; ?>
16 16 <div class="product attribute sku"><strong class="type"><?= $escaper->escapeHtml(__('SKU')) ?></strong> <div class="value" itemprop="sku"><?= $escaper->escapeHtml($product->getSku()) ?></div></div>
17 17 </div>

Start with a free check of your shop.

Send your shop’s address. Validiscan scans it and replies with every failure the scan can prove, with the page and the code.

Request a free check

Or write to info@validiscan.com.

What the free check covers, and what it leaves out
You send
your shop’s address
Pages scanned
up to 15
You get
every failure the scan can prove, with the page and the code
On your shop
public pages only; no login, nothing bought, no forms sent
What it is not
a full audit

Write to Validiscan

For a free check, give the address of your shop and its platform.

Validiscan uses what you send to answer you. Privacy notice