product-pagevariantskeyboardscreen-readershopifywcagdeveloper

Shopify Variant Picker Accessibility: Swatches, Dropdowns and Sold-Out States

Make the Shopify variant picker accessible: swatches as labelled radio buttons, dropdowns with labels, sold-out values that are announced, and price changes screen readers hear.

By Radoslaw Fedorczuk10 min read

Shopify variant picker accessibility decides whether a shopper can choose a size or a colour at all. The usual failures are colour swatches built from clickable <div> elements that a keyboard cannot reach, dropdowns with no label, sold-out sizes that are greyed out and never announced, and a price that changes on screen while a screen reader says nothing. Any one of them blocks the step before Add to cart.

The fixes below follow Dawn's variant picker (main branch, commit 258f00f from 10 August 2026), which uses native radio buttons and a native <select>. I ran the broken and fixed markup through a headless Chromium test page and through our scanner's rule set, and the results are quoted where they matter.

Who runs into this

  • Keyboard users. A swatch that only reacts to a mouse click cannot be selected at all.
  • Screen reader users. A colour circle has no text, so without a name the shopper hears "clickable" or nothing, and a sold-out size that is only greyed out sounds the same as one in stock.
  • People with low vision or colour vision deficiencies. A selected state shown only by a thin coloured ring, or a colour name shown only as a colour, does not reach them.
  • Developers who replaced Dawn's picker with a swatch app or a custom section. The app's markup replaces the theme's, and it needs the same test.

A 90-second test

  1. Press Tab until you reach the colour swatches. Focus should land on the selected colour, with a visible focus indicator.
  2. Press the Right and Left arrow keys. The selection should move between colours, and the visible label ("Colour: Navy") should follow.
  3. Press Tab once more. Focus should move to the next option group, not through every swatch one by one. That is how a radio group behaves.
  4. Pick a size you know is sold out. You should see and, with a screen reader on, hear that it is sold out. Add to cart should say "Sold out".
  5. Listen to the price with a screen reader (VoiceOver on a Mac with Cmd+F5, NVDA on Windows) while you switch to a variant with a different price. You should hear the new price without moving focus.
  6. Change the value of a dropdown with the arrow keys. The page should not reload or jump to a new URL while you are still choosing.

Colour swatches as radio buttons

A swatch is a choice of one value from a set, which is what a radio group is. Native radio buttons give you, for free, the keyboard behaviour in the W3C radio group pattern, a checked state that assistive technology reads, and a group name from the <legend>. The criteria involved are 4.1.2 Name, Role, Value, 2.1.1 Keyboard and 1.4.1 Use of Color, because the colour itself is not a name.

The broken pattern:

<p>{{ option.name }}</p>
<div class="swatches">
  {%- for value in option.values -%}
    <div class="swatch{% if value.selected %} is-selected{% endif %}"
         style="background-color: rgb({{ value.swatch.color.rgb }})"
         onclick="selectOption('{{ value | escape }}')"></div>
  {%- endfor -%}
</div>

These <div> elements are not in the Tab order, have no role, no name and no selected state.

The fixed pattern, close to Dawn's snippets/product-variant-options.liquid and snippets/swatch-input.liquid:

<fieldset class="product-form__input product-form__input--swatch">
  <legend class="form__label">
    {{ option.name }}: <span data-selected-value>{{ option.selected_value }}</span>
  </legend>
  {%- for value in option.values -%}
    {%- capture input_id -%}
      {{ section.id }}-{{ option.position }}-{{ forloop.index0 }}
    {%- endcapture -%}
    <input type="radio" id="{{ input_id }}"
           name="{{ option.name | escape }}-{{ option.position }}"
           value="{{ value | escape }}" form="product-form-{{ section.id }}"
           class="swatch-input__input
             {%- unless value.available %} visually-disabled{% endunless %}"
           {% if value.selected %}checked{% endif %}>
    <label for="{{ input_id }}" class="swatch-input__label">
      {% render 'swatch', swatch: value.swatch %}
      <span class="visually-hidden">{{ value | escape }}</span>
      {%- unless value.available -%}
        <span class="visually-hidden">
          {{- 'products.product.variant_sold_out_or_unavailable' | t -}}
        </span>
      {%- endunless -%}
    </label>
  {%- endfor -%}
</fieldset>

The input can be visually hidden with CSS, as long as it stays focusable, and the label draws the swatch. On the test page Chromium exposed this markup as a group named "Colour: Navy" with a radio "Navy" (checked) and a radio "Burgundy Variant sold out or unavailable".

Two visual checks go with it. The selected swatch needs an indicator that does not rely on colour alone, such as a thicker ring or a check mark, and that indicator needs a contrast of at least 3:1 against what surrounds it (1.4.11 Non-text Contrast). The focus indicator needs to be visible too (2.4.7 Focus Visible). Our article on colour contrast in Shopify themes covers the thresholds.

If you have to keep a custom element instead of a native radio, it needs all of this by hand: role="radiogroup" with a name, role="radio" and aria-checked on each swatch, a text name for each, one swatch in the Tab order at a time, and arrow key handling. That is a lot of code to get right, and every piece of it is something the native input already does.

Size is often a dropdown. A <select> needs a programmatic label (1.3.1 Info and Relationships, 3.3.2 Labels or Instructions), and choosing a value must not take the shopper to another page while they are still choosing (3.2.2 On Input).

The broken pattern fails both:

<select name="options[{{ option.name | escape }}]"
        onchange="window.location = this.value">
  {%- for value in option.values -%}
    <option value="{{ value.variant.url }}">{{ value }}</option>
  {%- endfor -%}
</select>

In Chromium on Windows, each press of the Down arrow on a closed <select> changes the value and fires change. On the test page, two presses fired two events. With the handler above, a keyboard user who presses Down twice to reach "L" gets sent to the "M" page first.

The fixed pattern, as in Dawn's snippets/product-variant-picker.liquid, inside the loop over product.options_with_values:

{%- assign select_id = 'Option-' | append: section.id -%}
{%- assign select_id = select_id | append: '-' | append: forloop.index0 -%}
<label class="form__label" for="{{ select_id }}">{{ option.name }}</label>
<select id="{{ select_id }}" name="options[{{ option.name | escape }}]"
        form="product-form-{{ section.id }}">
  {%- for value in option.values -%}
    <option value="{{ value | escape }}"{% if value.selected %} selected{% endif %}>
      {%- if value.available -%}
        {{ value }}
      {%- else -%}
        {{ 'products.product.value_unavailable' | t: option_value: value }}
      {%- endif -%}
    </option>
  {%- endfor -%}
</select>

Dawn updates the product in place when the value changes and writes the variant into the address bar with history.replaceState, so there is no navigation.

A custom dropdown built from a button and a list (the kind many swatch and filter apps ship) is a listbox or combobox widget. The W3C listbox pattern shows how much keyboard handling it needs. For a variant picker, a native <select> styled with CSS is the shorter road.

Sold-out states that are announced

A sold-out value has to tell every shopper that it is sold out, in text as well as in grey (1.4.1 Use of Color). Dawn handles it in three places:

  • The radio stays enabled, gets a visually-disabled class for the grey look, and its label carries the hidden text "Variant sold out or unavailable".
  • In a dropdown, the option text becomes "M - Unavailable" (the value_unavailable string).
  • When the selected combination is sold out, the Add to cart button is disabled and its text changes to "Sold out".

Why not add disabled to the sold-out radio? Because a disabled radio leaves the group. On the test page, with Burgundy marked disabled, the Right arrow key skipped it and stayed on Navy. With Dawn's approach, the arrow moved to Burgundy and the shopper heard that it was sold out. A shopper who never reaches a size never learns it exists or that it may come back.

Price and stock changes that screen readers hear

When a shopper picks another variant, the price, the stock message and sometimes the SKU change on screen. For a screen reader user these are status messages (4.1.3 Status Messages). Dawn wraps the price block in <div id="price-{{ section.id }}" role="status"> and the inventory line in another role="status" element, and on each change it copies the new content into those existing elements.

That detail is where custom code often breaks:

// Broken: the old element is thrown away and a new one takes its place.
// The new one also has role="status", but it was not on the page before.
document.getElementById(`price-${sectionId}`)
  .replaceWith(newDocument.getElementById(`price-${sectionId}`));
// Fixed: keep the element that was on the page, change its content.
const current = document.getElementById(`price-${sectionId}`);
const next = newDocument.getElementById(`price-${sectionId}`);
current.innerHTML = next.innerHTML;

W3C technique ARIA19 states the rule behind this: the live container has to be in the page before the message for most screen readers to speak it. Technique ARIA22 tests the same thing for role="status".

Keep the price text itself complete. Dawn's snippets/price.liquid adds hidden labels such as "Regular price" and "Sale price" before each amount, so a struck-through old price is not read as the current one.

Keep focus on the option after the update

Dawn fetches the product section for the new variant and replaces the picker's markup. After the update it puts focus back on the input the shopper used, in renderProductInfo() in assets/product-info.js. Custom code that replaces the whole product block without that line drops focus to the top of the document, and a keyboard user has to tab through the header again after every choice. When you test step 2 above, watch where focus is after each arrow key.

What AccessifyAI detects

The scanner reads the HTML your store sends, without running JavaScript. How we test lists every rule. On a product page it reports:

  • "Clickable element not marked as clickable" (4.1.2) for a <div> or <span> with an onclick attribute and no role, the broken swatch above.
  • "Dropdown with no label" (1.3.1) for a <select> without a label, aria-label, aria-labelledby or title.
  • "Form field with no label" (1.3.1) for a radio input without a label, such as a swatch input whose <label for> points to the wrong id.
  • "Button with no text" (4.1.2) for icon-only buttons, and "Two elements share the same ID" (4.1.2) when two product forms on one page reuse input ids.
  • "Button too small to tap" (2.5.8), only when the size is written as inline pixel values on the element. Sizes set in the theme's CSS files are not measured.

On the broken test page it reported three findings: the two onclick swatches and the unlabelled <select>. The same page also contained a <span role="radio"> with no name, a custom listbox, a <select> that navigates on change and a price outside any live region. None of these produced a finding. Our rules do not check the name or state of custom ARIA widgets, do not evaluate what happens on change, and do not know which element shows the price.

What no automated tool can judge

  • Whether the arrow keys, Tab and Space do what the shopper expects in your picker.
  • Whether a sold-out value is announced and whether Add to cart explains why it is disabled.
  • Whether the price change is heard, heard once, and heard at the right moment.
  • Whether the selected state is visible without colour, and whether focus stays on the option after an update.
  • Whether a colour name makes sense ("Navy" helps, "Colour 3" does not). That is content, and a person decides it.

Check your own store

Run the free one-page scan with the address of one of your product pages to see what the rules find there. The app scans more pages per run, including your product templates, and for findings that need a theme edit it suggests which theme file to open: AccessifyAI on the Shopify App Store. It covers 15 of the 55 WCAG 2.2 A and AA criteria, so the 90-second test stays part of the job.

Related reading: the next step after the picker is the Shopify cart drawer, and how to test your Shopify store for accessibility covers the rest of the store.

Frequently asked questions

Should sold-out variants be disabled?

Keep them selectable and say they are sold out. A disabled radio drops out of the arrow key order, so keyboard and screen reader users never find out the size exists. Dawn keeps the input enabled, adds hidden "sold out or unavailable" text to its label and disables Add to cart for the sold-out combination.

Are colour swatches accessible without visible text?

They can be, if each swatch has a text name that assistive technology reads (Dawn puts the value in visually hidden text inside the label), if the selected state shows without relying on colour, and if the group has a visible name such as "Colour: Navy". Shoppers who cannot tell the colours apart still benefit from seeing the selected colour's name.

Should the price have aria-live?

The price container should be a live region that is on the page from the start. role="status", which Dawn uses, is a polite live region. Change its content on variant change; do not replace the element.

Do swatch apps need separate testing?

Yes. A swatch or variant app replaces the theme's picker markup with its own, so everything in the 90-second test applies to the app's output. Test it after installing and after each app update.

Sources

Share:

Get accessibility tips by email

One short email per month with new guides and Shopify accessibility updates. No spam, unsubscribe anytime.