Back to Blog
Icon-Only Button aria-label Fix for Shopify Themes (WCAG 4.1.2 / 2.5.3) — featured image

Icon-Only Button aria-label Fix for Shopify Themes (WCAG 4.1.2 / 2.5.3)

Vijaygopal Balasa
6 min read

Icon-only buttons are everywhere on a Shopify storefront — cart, search, account, wishlist, social-share, language-selector. Most themes ship them with an SVG inside an <a> or <button> and no accessible name. Screen readers announce them as "button" with nothing else. WCAG 4.1.2 (Name, Role, Value, Level A) requires every control to have an accessible name. This guide gives you the exact ARIA patch for every common icon-only button pattern.

The two patterns to use

Pattern A: aria-label (concise, invisible)

<a href="/cart" aria-label="Cart">
  {% render 'icon-cart' %}
</a>

Best when: the button has no visible label and the design wants a clean icon. Screen readers announce "Cart, link" — clean and minimal.

Pattern B: visually-hidden text (preferred for SEO + voice control)

<a href="/cart">
  {% render 'icon-cart' %}
  <span class="visually-hidden">Cart</span>
</a>

Best when: you want the label indexed by search engines and want voice-control users to be able to say "Click Cart" naturally. Visually-hidden text counts as a real label per WCAG 2.5.3 (Label in Name) — voice-control software can target it.

For high-traffic Shopify storefronts, Pattern B is recommended for the primary header icons (Cart, Search, Account). Pattern A is fine for footer social icons and other peripheral chrome.

Per-icon labels

Use these labels — they are tested across major screen readers and voice-control systems:

IconLabelNotes
CartCart, X itemsInclude the live count if available; update on cart change.
SearchSearchOr Search the store for clarity.
Account / loginAccount (logged in) / Log in (logged out)State-aware.
Wishlist heart (empty)Add to wishlistaria-pressed="false"
Wishlist heart (filled)Remove from wishlistaria-pressed="true"
Hamburger menuOpen menu (closed) / Close menu (open)aria-expanded toggles.
Close XCloseOr specific: Close cart drawer.
Currency selectorChange currency, current: USDInclude current value.
Language selectorChange language, current: EnglishInclude current value.
Quick viewQuick view: ${product.title}Per-product.
Add to cart on cardAdd ${product.title} to cartPer-product.
Sort dropdownSort byCombined with select element.
Filter openOpen filtersaria-expanded toggles.

The aria-hidden="true" rule on inner SVGs

Every icon-only button should have aria-hidden="true" on the inner SVG so the screen reader announces only the parent button label, not the SVG path data. Two ways to apply it:

In Liquid snippets

{% comment %} snippets/icon-cart.liquid {% endcomment %}
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="24" height="24">
  <path d="..." />
</svg>

aria-hidden="true" removes the SVG from the accessibility tree. focusable="false" prevents IE / older browsers from accidentally putting it in the Tab order. Setting both on every icon snippet propagates the fix to every place the icon is used.

Via CSS-targeting (when you can't edit snippets)

<a href="/cart" aria-label="Cart">
  <svg class="icon" aria-hidden="true" focusable="false">...</svg>
</a>

If your theme uses inline SVGs and you can't cleanly edit every snippet, add aria-hidden="true" directly on each occurrence. AccessComply's deterministic agent does this in bulk via theme-source rewrite.

Cart count: the dynamic-label case

The cart icon is special — its label should reflect the current cart state:

<a href="/cart" class="header__icon-cart" aria-label="Cart, {{ cart.item_count }} {% if cart.item_count == 1 %}item{% else %}items{% endif %}">
  {% render 'icon-cart' %}
  <span class="cart-count" aria-hidden="true">{{ cart.item_count }}</span>
</a>

When JavaScript updates the cart count, it should also update the aria-label:

function updateCartCount(count) {
  const link = document.querySelector('.header__icon-cart');
  if (!link) return;
  link.setAttribute('aria-label', `Cart, ${count} ${count === 1 ? 'item' : 'items'}`);
  const visualCount = link.querySelector('.cart-count');
  if (visualCount) visualCount.textContent = count;
}

The visual count badge is aria-hidden="true" because the parent's aria-label already announces the same number — without aria-hidden="true" on the badge, the screen reader would announce "Cart, 3 items, 3" (the badge gets read again).

State-aware labels: account icon

The account icon should announce different things when the user is logged in vs logged out:

{% if customer %}
  <a href="/account" aria-label="Account, {{ customer.first_name | escape }}">
    {% render 'icon-user' %}
  </a>
{% else %}
  <a href="/account/login" aria-label="Log in">
    {% render 'icon-user' %}
  </a>
{% endif %}

Toggle buttons: hamburger + wishlist heart

Toggle buttons should communicate state via aria-expanded (for disclosures) or aria-pressed (for two-state toggles):

<button
  type="button"
  class="header__menu-toggle"
  aria-expanded="false"
  aria-controls="header-menu-panel"
  aria-label="Open menu"
>
  {% render 'icon-hamburger' %}
</button>
function toggleMenu() {
  const btn = document.querySelector('.header__menu-toggle');
  const panel = document.getElementById('header-menu-panel');
  const open = btn.getAttribute('aria-expanded') === 'true';
  btn.setAttribute('aria-expanded', open ? 'false' : 'true');
  btn.setAttribute('aria-label', open ? 'Open menu' : 'Close menu');
  panel.hidden = open;
}

Wishlist heart pattern with aria-pressed:

<button
  type="button"
  class="wishlist-toggle"
  aria-pressed="{{ in_wishlist | default: false }}"
  aria-label="{% if in_wishlist %}Remove from wishlist{% else %}Add to wishlist{% endif %}"
>
  {% render 'icon-heart' %}
</button>

Common mistakes to avoid

title attribute as a substitute for aria-label

<!-- WRONG — title is mouse-over only, not announced by screen readers -->
<a href="/cart" title="Cart">
  <svg>...</svg>
</a>

title shows on mouse hover but screen readers do not consistently announce it. Use aria-label instead.

Missing aria-hidden="true" on the SVG

<!-- WRONG — screen reader may double-announce SVG path data -->
<a href="/cart" aria-label="Cart">
  <svg viewBox="0 0 24 24"><path d="..." /></svg>
</a>

Add aria-hidden="true" to the SVG.

Generic placeholders like "Click here"

<!-- WRONG — screen reader announces "Click here, link" with no destination -->
<a href="/cart" aria-label="Click here">
  <svg>...</svg>
</a>

The label should describe the function (Cart, Search, Account), not the action verb.

Same aria-label on multiple buttons

Two icon buttons with aria-label="Search" are indistinguishable to a screen-reader user navigating by button list. Disambiguate with context: aria-label="Search header" and aria-label="Search products".

Why this is deterministic

Every icon-only button is detectable in the rendered DOM: a <button> or <a> whose only descendants are non-text-bearing (SVG, image, icon-font character) and which has no aria-label, aria-labelledby, or visually-hidden text. AccessComply's deterministic agent runs this detection across every page, then writes the appropriate aria-label based on the icon class, surrounding context, and product/cart data. The fix is theme-source code via the Theme GraphQL themeFilesUpsert mutation — auditable, version-controlled, survives theme updates.

Further reading

Free to install

Scan your store free, fix violations at the source

AccessComply scans your Shopify store for ADA + EAA / WCAG 2.1 + 2.2 AA violations and applies real source-code fixes — no overlays, no widgets.

Vijaygopal Balasa, Founder, AccessComply
Written by

Vijaygopal Balasa

Founder, AccessComply

Founder of AccessComply. Builds AI agents that fix Shopify accessibility violations at the source-code level — not via overlays. Focused on real WCAG 2.2 AA outcomes for merchants.

More on EAA

See all →