# Add to cart buttons that change a badge and say nothing

Source: https://easeweb.dev/learn/action-confirmations
Topics: Live updates > Actions like add to cart or copy link are confirmed out loud (WCAG 4.1.3)
The fix: Write the result in words into a status region that is on the page before the button is pressed. Either show a short line with role="status" beside the button, or keep a visually hidden status region in the page layout and leave the design as it is, or, as the smallest change, make the cart count a status region with words around the number. Don't move focus to the cart.
Test with: screen reader, keyboard, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://www.w3.org/WAI/WCAG22/Techniques/failures/F103 https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A homeware shop's product page shows an enamel mug, its price and an Add to cart button. In the header, a bag icon carries a small round badge with the number of items in the cart. The design keeps the page calm: no pop-up, no drawer sliding in. Press Add to cart and, a second or so later, once the shop's server has answered, the badge goes from 0 to 1. That is the whole confirmation, and in the second before it nothing shows that the press was taken. A sighted user sees the number change out of the corner of their eye. A screen reader user presses the button and hears nothing, during the wait or after it. Focus stays on the button, its name is still "Add to cart", and the badge is at the other end of the page. They don't know whether the mug went in, so they press again and now have two, or they leave the page to open the cart, check, and come back to find their place.

## Why it fails

"1 item in your cart" is a status message: it reports the result of something the person did, and it does not take focus. Success Criterion 4.1.3 asks that such messages can be presented to assistive technology through role or properties, so that they are announced without moving focus. This is one of the W3C's own examples for the criterion: after an Add to Shopping Cart button is pressed, the text near the cart icon changes to "5 items", and a screen reader announces it. Here the badge is a plain `span` inside the cart link. Its number changes on screen, but no role or property tells assistive technology to read the change, which is failure F103. The button can't make up for it: it keeps focus and keeps its name, so there is nothing new on it for a screen reader to say. And a bare "1" would mean little even if it were read, which is why the W3C suggests adding words such as "in shopping cart" around the number.

## Who is affected

The badge is placed for someone who sees the whole page at once. On a long product page, the header has often scrolled out of view by the time someone reaches the button, so even sighted users can miss the change.

## What automation and AI miss

A scan sees a button with a name and a link with a name, and passes both. It doesn't press Add to cart, since on a live shop that would fill a real cart, so it never sees the badge change, and nothing in the markup says the badge reports a result. EaseWeb's audit reports a page that has Add to cart or Copy link buttons and no live region at all as a lead to test by hand. Some AI assistants asked to make the cart accessible rename the cart link with an `aria-label` such as "Cart, 1 item". That improves the link's name when someone reaches it, but a new name on an element that doesn't have focus is not announced. Others move focus to the cart link, which takes people away from the product they were looking at. Only pressing the button with a screen reader running shows what is said.

## Before: a badge that changes in silence

```html
<header>
  <a href="/" class="logo">Larkfield</a>
  <a href="/cart" class="cart">
    <svg aria-hidden="true">…</svg>
    <span class="visually-hidden">Cart</span>
    <span id="cart-count" class="badge">0</span>
  </a>
</header>
<main>
  <h1>Enamel mug</h1>
  <p class="price">$18</p>
  <button type="button" id="add">Add to cart</button>
</main>
```

```js
add.addEventListener('click', async () => {
  const cart = await addToCart('enamel-mug');
  cartCount.textContent = cart.count;
});
```

The number is right once the server answers, but the `span` is not a live region and nothing about the focused button changes, so a screen reader has nothing to announce, during the wait or after it. A second press while the first is on its way adds a second mug. If adding fails, nothing changes at all.

## After: a confirmation line beside the button

```html
<header>
  <a href="/" class="logo">Larkfield</a>
  <a href="/cart" class="cart">
    <svg aria-hidden="true">…</svg>
    <span class="visually-hidden">Cart</span>
    <span id="cart-count" class="badge">0</span>
  </a>
</header>
<main>
  <h1>Enamel mug</h1>
  <p class="price">$18</p>
  <button type="button" id="add">Add to cart</button>
  <p id="add-status" role="status"></p>
</main>
```

```js
const items = n => (n === 1 ? '1 item' : `${n} items`);
let adding = false;

add.addEventListener('click', async () => {
  // Ignore presses while one is on its way; don't disable the focused button.
  if (adding) return;
  adding = true;
  // Say it is adding only when the shop is slow to answer.
  const slow = setTimeout(() => (addStatus.textContent = 'Adding the enamel mug…'), 500);
  try {
    const cart = await addToCart('enamel-mug');
    cartCount.textContent = cart.count;
    addStatus.textContent = `Enamel mug added. ${items(cart.count)} in your cart.`;
  } catch {
    addStatus.textContent = 'The mug was not added. Please try again.';
  } finally {
    clearTimeout(slow);
    adding = false;
  }
});
```

The `p` with `role="status"` is in the HTML from the start and empty, so the browser is already watching it when its text changes. The screen reader finishes what it is saying, then reads "Enamel mug added. 1 item in your cart." If the shop takes more than half a second to answer, "Adding the enamel mug…" comes first, so the wait is not silent either; a quick answer skips it, so people aren't told twice. Focus stays on the button, which stays enabled: a press during the wait is ignored rather than adding a second mug. Each press changes the count in the sentence, so a second press is announced too. The line sits under the button, where a magnifier user is already looking and where a sighted user who has scrolled past the header can see it, and it can stay there until the page changes. A failed add gets its own words instead of silence.

## After: a hidden status region, when the design stays as it is

```html
<header>
  <a href="/" class="logo">Larkfield</a>
  <a href="/cart" class="cart">
    <svg aria-hidden="true">…</svg>
    <span class="visually-hidden">Cart</span>
    <span id="cart-count" class="badge">0</span>
  </a>
</header>
<main>
  <h1>Enamel mug</h1>
  <p class="price">$18</p>
  <button type="button" id="add">Add to cart</button>
</main>
<!-- Once, in the page layout, outside any component that is redrawn -->
<div id="cart-status" role="status" class="visually-hidden"></div>
```

```js
const items = n => (n === 1 ? '1 item' : `${n} items`);
let adding = false;

add.addEventListener('click', async () => {
  // Ignore presses while one is on its way; don't disable the focused button.
  if (adding) return;
  adding = true;
  // Say it is adding only when the shop is slow to answer.
  const slow = setTimeout(() => (cartStatus.textContent = 'Adding the enamel mug…'), 500);
  try {
    const cart = await addToCart('enamel-mug');
    cartCount.textContent = cart.count;
    cartStatus.textContent = `Enamel mug added. ${items(cart.count)} in your cart.`;
  } catch {
    cartStatus.textContent = 'The mug was not added. Please try again.';
  } finally {
    clearTimeout(slow);
    adding = false;
  }
});
```

The page looks exactly as it did: the badge is still the only thing a sighted user sees. The empty region lives in the layout, so writing into it is a change the browser announces, and a screen reader says the same sentences as in the fix above, "Adding" on a slow answer included. One region serves every Add to cart on the site, including quick-add buttons on a product grid, as long as each writes the product's name into its message. Keep the region out of `display: none`; the visually hidden class keeps it in the accessibility tree.

## After: the cart count as a status region, the smallest change

```html
<header>
  <a href="/" class="logo">Larkfield</a>
  <a href="/cart" class="cart">
    <svg aria-hidden="true">…</svg>
    <span role="status" aria-atomic="true">
      <span class="visually-hidden">Cart, items:</span>
      <span id="cart-count" class="badge">0</span>
    </span>
  </a>
</header>
<main>
  <h1>Enamel mug</h1>
  <p class="price">$18</p>
  <button type="button" id="add">Add to cart</button>
</main>
```

```js
add.addEventListener('click', async () => {
  const cart = await addToCart('enamel-mug');
  cartCount.textContent = cart.count;
});
```

This follows the W3C's example: the badge and its hidden words are wrapped in a `role="status"` region, and the script doesn't change. When the number changes, the whole region is read: "Cart, items: 1". `role="status"` already implies `aria-atomic="true"`, but technique ARIA22 notes that some environments don't apply it, and without it they read only the new number, so the attribute is written out. The hidden words give the number a meaning, and they also become the cart link's name. It works only while conditions outside this snippet hold. The region must be in the page at every width: a site that swaps in a separate phone header and hides the desktop one with `display: none` hides the region too, and a hidden region says nothing. The count must already be in the HTML when the page arrives: a shop that fetches the count after loading changes the region on every page, and can announce the cart each time. And it reports only a new number, so people hear how many items there are, not which one was added; nothing is said while the shop is answering, a press during the wait adds a second mug, and an add that fails changes nothing and says nothing.

## Which option to use

All three announce the result without moving focus. Use the line beside the button: it is the same code as the hidden region, and it gives up nothing, because the message also appears where people are looking, and it can say when adding is slow or fails. The hidden region is the right choice when the design must stay as drawn, for example in a theme you can't change, and it covers every Add to cart on the site at once. Making the count a status region is the smallest change, one wrapper and no change to the script, but it is not enough on its own: use it only when the header is the same at every width and its count is in the HTML, and still give failures a message of their own.

## Implementation decisions

**Other actions that only change an icon.** The same failure, and the same fix, comes up wherever an action's only result is a picture:

- Copy link: an icon that turns into a tick for two seconds is not heard. Write "Link copied" into the line or the region, and say so when the browser refuses to write to the clipboard.
- Save to wishlist: a heart that fills is a toggle button, and its state belongs on the button itself with `aria-pressed`, which is read on the focused button. See [toggle buttons that don't say whether they are pressed](/learn/toggle-buttons). A status message is needed only for what the button can't say, such as "Saved. Sign in to keep your list."
- Quick add on a product grid: every card's button says "Add to cart", so the message must name the product.

**While it adds.** The wait between the press and the server's answer is part of the confirmation. The line and the hidden region handle it as [loading spinners that screen readers never announce](/learn/loading-states) does: a quick answer gets only the result, and a slow one gets "Adding…" first, from a timer that the answer cancels. Keep the button enabled and ignore presses while one is on its way. Setting `disabled` on the focused button looks tidy, but the browser can move focus off it: in Chrome, focus falls to the page itself and does not come back when the button is enabled again, so a screen reader user loses their place in the middle of buying. A spinner drawn on the button is fine for sighted users, as long as the button keeps its name; leave the words to the status region. [Disabled buttons that can't be found or say why](/learn/disabled-controls) covers disabled controls in general.

**The region must exist first.** In a component framework, render the region in the layout or in the product's own markup, never inside an `{#if}` that appears with the message, and never in a mini cart that mounts when something is added. A region that arrives with its message is usually not announced; [status messages that are shown but never announced](/learn/status-messages) shows that failure in full.

**A cart drawer is a different design.** Some shops open a mini cart panel after adding. If it moves focus into the panel and works as a dialog, that is a change of context, not a status message, and the panel's own heading tells people what happened: follow [modals that leave keyboard focus behind them](/learn/modal-focus-management). A panel that slides in without taking focus is as silent as the badge, and needs one of the fixes here.

**Politeness.** `role="status"` waits until the screen reader has finished speaking, which suits a confirmation. Don't use `role="alert"` or `aria-live="assertive"` for adding to a cart; it cuts into whatever the person was listening to.

**The same words twice.** Two failures in a row write the same sentence, and some screen readers may say nothing when a region's text does not change. Clear the region first and write the new words after a short delay, as [search and filter results that update without a word](/learn/search-results) does.

**Not fixes.** Changing the button's text to "Added" for two seconds is read by some screen readers on the focused button and not by others, and it says nothing about the cart. Moving focus to the cart link or to a message gets it read, but takes people away from the product, and it is not what 4.1.3 asks for. An `aria-label` on the cart link that changes with the count is not announced, because the link doesn't have focus.

## Verify the fix

1. Inspect the DOM before pressing: the status line or region is present, has `role="status"`, is not hidden with `display: none` or `aria-hidden`, and is not inside anything that is redrawn; where the count is the region, the HTML from the server already holds the number.
2. With a screen reader on, press Add to cart: the result is announced once, after any speech in progress, and focus stays on the button.
3. Slow the network down in developer tools (a throttling preset such as Slow 4G) and press twice quickly: focus stays on the button, and with the line or the hidden region, "Adding" is announced once before the result and the second press adds nothing. Then go offline and press once more: the failure is announced in words.
4. Reload the page and move to another page: nothing about the cart is announced as they load. Then narrow the window to a phone width and add again: it is still announced.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the header, the product template or the cart script changes.

## Limits

Live regions behave differently across screen readers and browsers, especially regions inside links, moved regions and regions inside dialogs. This pattern covers one button on one product page. Naming the cart's own buttons is covered in [icon-only buttons with no accessible name](/learn/icon-buttons), and the quantity next to Add to cart in [quantity steppers with unnamed buttons and no typing](/learn/quantity-steppers). A form that sends and shows its result, such as a newsletter sign-up, is a topic of its own. Test your real cart, with its drawer, quick-add buttons and phone header, with the screen readers your users use.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
