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
- 4.1.3 Status Messages, level AA
EN 301 549 clauses
| 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)
sections/main-cart.liquid 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
| 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)
Resources/views/storefront/page/checkout/cart/index.html.twig 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
| 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)
assets/js/theme.js 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
| 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> |
woocommerce/single-product/add-to-cart/variable.php 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)
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
| 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
| 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 checkOr 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