Accessible color and size swatches in Shopify themes

How to build Shopify color and size swatches that pass an accessibility retest: radio groups, names, selected and sold-out states, focus and contrast.

Short answer

A color or size picker is a single-choice question, so build it as a group of native radio buttons inside a <fieldset> with a <legend>. Every option needs a text name, including color swatches, where the name can be visually hidden. The selected state has to be visible without relying on color, and sold-out options have to say so in text. Keyboard users should be able to Tab into the group and use the arrow keys. When the variant changes, the price, availability and main image alt text should change with it.

Variant pickers are where many Shopify accessibility findings collect, because they get customized: swatch apps, "pill" buttons, custom sections. This guide uses the fix from our demo store (fictional), where <div> swatches and sizes were rebuilt as radio groups, and Dawn's current picker as a reference for Liquid.

What auditors flag

What the tester findsWCAG 2.2 success criterion
Swatches are <div>s or <span>s: Tab skips them2.1.1 Keyboard (A)
Screen reader says "clickable" or nothing; no color name, no selected state4.1.2 Name, Role, Value (A)
The options aren't grouped under "Color" or "Size"1.3.1 Info and Relationships (A)
The selected swatch is shown only by a slightly different ring color1.4.1 Use of Color (A)
A white or pale swatch has no visible edge; the selection ring is faint1.4.11 Non-text Contrast (AA)
No focus indicator on the swatch2.4.7 Focus Visible (AA)
Swatches are 16–20 px circles packed together2.5.8 Target Size (Minimum) (AA)
Price or "sold out" changes on screen but isn't announced4.1.3 Status Messages (AA)

In the demo audit, the color swatches (KC-009) and the size selector (KC-018) were both rated Critical. Together they stopped a keyboard user from buying anything.

Why radio buttons, not buttons or a listbox

Picking one color out of three is exactly what radio buttons are for. A native radio group gives you, for free:

  • the role, name and checked state for screen readers ("Slate, radio button, selected, 2 of 3");
  • one Tab stop for the whole group, with arrow keys to move and select, which is the behavior the APG radio group pattern describes;
  • form submission without extra JavaScript.

Buttons with aria-pressed can work, but they make every option a separate Tab stop and don't convey "one of three". A role="listbox" adds keyboard handling you have to write and test yourself. If you have to keep <div>s for some reason, you'd need role="radiogroup", role="radio", aria-checked, roving tabindex and arrow-key handling, all reimplemented. Native inputs are less code and fewer bugs.

The markup

This is the color group from the demo store's product page after the fix:

html
<fieldset class="option">
  <legend class="option-label">Color:<span class="color-value" aria-hidden="true"> Forest</span></legend>
  <div class="swatches">
    <input type="radio" class="swatch-input visually-hidden" name="color" id="color-forest" value="Forest" checked>
    <label for="color-forest" class="swatch" style="background:#2f4a3a"><span class="visually-hidden">Forest</span></label>

    <input type="radio" class="swatch-input visually-hidden" name="color" id="color-slate" value="Slate">
    <label for="color-slate" class="swatch" style="background:#4a5563"><span class="visually-hidden">Slate</span></label>

    <input type="radio" class="swatch-input visually-hidden" name="color" id="color-ember" value="Ember">
    <label for="color-ember" class="swatch" style="background:#b4532a"><span class="visually-hidden">Ember</span></label>
  </div>
</fieldset>

Points worth copying:

  • The input is visually hidden, not display: none. A display: none input can't receive focus. The .visually-hidden class clips it to 1 px instead, so it stays in the tab order and the accessibility tree.
  • The color name is real text inside the label. That's the accessible name. A title attribute alone isn't reliable: many screen reader and browser combinations don't read it, and touch users never see it.
  • The legend shows the current value visually, and that part is aria-hidden, so a screen reader doesn't read "Color: Forest" and then "Forest, selected" back to back.

The size group follows the same structure, with one difference for sold-out sizes:

html
<input type="radio" class="size-input visually-hidden" name="size" id="size-xl" value="XL" disabled>
<label for="size-xl" class="size">XL<span class="visually-hidden"> (sold out)</span></label>

Shopify Liquid: the native swatch object

Since early 2024, Shopify themes can read swatch data from product option values. product_option_value.swatch returns a swatch with a color and an image, or nil when none is set. Dawn's current picker (v16) is a good reference. It uses a <fieldset>, a <legend>, radio inputs, and a visually hidden copy of each value's name inside the label. Here's a trimmed version of the same idea for a custom section:

liquid
{%- for option in product.options_with_values -%}
  <fieldset class="option">
    <legend class="option-label">
      {{ option.name }}:<span class="option-value" aria-hidden="true"> {{ option.selected_value }}</span>
    </legend>
    {%- for value in option.values -%}
      {%- assign input_id = section.id | append: '-' | append: option.position | append: '-' | append: forloop.index0 -%}
      {%- assign swatch_bg = null -%}
      {%- if value.swatch.image -%}
        {%- assign swatch_bg = value.swatch.image | image_url: width: 80 | prepend: 'url(' | append: ')' -%}
      {%- elsif value.swatch.color -%}
        {%- assign swatch_bg = 'rgb(' | append: value.swatch.color.rgb | append: ')' -%}
      {%- endif -%}
      <input type="radio" class="swatch-input visually-hidden"
        id="{{ input_id }}" name="{{ option.name | escape }}-{{ option.position }}"
        value="{{ value | escape }}" form="product-form-{{ section.id }}"
        {% if value.selected %}checked{% endif %}>
      <label for="{{ input_id }}" class="{% if swatch_bg %}swatch{% else %}option-pill{% endif %}{% unless value.available %} is-unavailable{% endunless %}"
        {% if swatch_bg %}style="--swatch-bg: {{ swatch_bg }};"{% endif %}>
        <span class="{% if swatch_bg %}visually-hidden{% endif %}">{{ value | escape }}</span>
        {%- unless value.available -%}<span class="visually-hidden">, sold out</span>{%- endunless -%}
      </label>
    {%- endfor -%}
  </fieldset>
{%- endfor -%}

Use translation keys ({{ 'products.product.sold_out' | t }}) instead of hard-coded English in a real theme.

Sold out: disabled, or focusable with text?

There are two defensible choices:

  1. Disable the input (what the demo does for XL). It can't be selected, and screen readers announce it as unavailable. The catch is that arrow keys skip disabled radios, so a keyboard user may not notice the size exists. The visible strike-through and the "(sold out)" text in the label make up for that.
  2. Keep it enabled and say "sold out" in the label (Dawn's approach: a visually disabled style plus a visually hidden "sold out or unavailable" message). Users can land on it, hear that it's sold out, and the Add to cart button changes to "Sold out".

Either passes if the state is conveyed in text and not only by a faded color. Choose based on how the store's Add to cart and back-in-stock flows work, and write the choice into the change log so the auditor knows what to expect.

The CSS: selected, focused and visible without color

css
.swatch {
  width: 40px; height: 40px; border-radius: 50%;
  background: var(--swatch-bg);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / .45);   /* edge for pale swatches */
  position: relative; cursor: pointer;
}
/* Selected: a two-ring halo plus a checkmark, so it doesn't rely on hue */
.swatch-input:checked + .swatch { box-shadow: inset 0 0 0 1px rgb(0 0 0 / .45), 0 0 0 2px #fff, 0 0 0 4px var(--ink); }
.swatch-input:checked + .swatch::after {
  content: ""; position: absolute; left: 50%; top: 45%;
  width: 7px; height: 13px; border: solid #fff; border-width: 0 3px 3px 0;
  transform: translate(-50%, -50%) rotate(45deg);
}
/* Focus: drawn on the label, because the real input is visually hidden */
.swatch-input:focus-visible + .swatch { outline: 3px solid var(--focus); outline-offset: 5px; }
.size-input:checked + .size { background: var(--ink); color: #fff; border-color: var(--ink); }
.size-input:disabled + .size { color: #767676; text-decoration: line-through; background: #f3f0ea; }

Three checks for this CSS:

  • The checkmark needs contrast against the swatch. A white check on a pale yellow swatch disappears. For light colors, switch the check to dark using a class you set from the swatch's lightness, or draw the check with a dark outline.
  • The halo needs 3:1 against the page background, per 1.4.11. A 2 px dark ring on a white page passes easily. A light-gray ring usually doesn't.
  • Size matters. The demo uses 40 × 40 px swatches with 12 px gaps. WCAG 2.2's 2.5.8 sets 24 × 24 CSS px as the minimum (with a spacing exception), and the Theme Store requires the same. Shopify's best-practice page recommends 44 × 44 px for primary controls, which is a sensible target for touch.

The JavaScript: listen for change, not click

The demo's original code listened for clicks on the <div>s. After the fix it listens for change on the inputs, which fires for mouse, touch, Space and arrow keys alike:

js
document.querySelectorAll('.swatch-input').forEach(function (radio) {
  radio.addEventListener('change', function () {
    state.color = radio.value;
    document.querySelector('.color-value').textContent = ' ' + state.color;
    setImage();   // also updates the main image's alt text
  });
});

When the variant changes, three things should follow:

  • The main image alt text. In the demo it's generated from product, color and view: "Ridgeline 40L Pack in Slate, side view". Otherwise a screen reader user hears the old color's description.
  • Price and availability. If they change, put them in a polite live region (aria-live="polite" or role="status") so the change is announced without moving focus. Shopify's theme best-practice page recommends the same for variant price and availability.
  • Focus after a re-render. Dawn and many themes fetch fresh section HTML for the new variant and swap it in. If the picker itself is replaced, focus falls to <body>. After the swap, find the newly checked input by its ID and call .focus() on it. Test this with arrow keys: a broken version jumps focus to the top of the page on every keypress.

Third-party swatch apps

Swatch apps inject their own markup, and some still render <div>s. Check the app's settings first: several offer a "radio" or "accessible" mode. If not, the options are a theme-side override where the app supports one, switching to Shopify's native swatches (which most current themes support), or a ticket to the vendor. We flag these at quote time, because "fix the swatches" means very different work depending on who owns the markup.

How to retest

  1. Tab to the product options. Focus should land on the checked color (or the first one if none is checked) with a clearly visible indicator.
  2. Press the right and left arrow keys. The selection should move, the main image and its alt text should update, and the visible value in the legend should change.
  3. With VoiceOver or NVDA running, the group should be announced with its name ("Color") and each option with its name and state, for example "Slate, selected, radio button, 2 of 3".
  4. Reach a sold-out size. Its state should be announced in text, not just shown faded.
  5. Change a variant that changes the price. The new price should be announced.
  6. Check the selected state in grayscale (most operating systems have a filter). You should still be able to tell which swatch is selected.
  7. Zoom to 400% and confirm the swatches wrap instead of overflowing.

In our demo store (fictional), steps 1–3 are part of the scripted keyboard task and the virtual screen reader transcript. Entries KC-009 and KC-018 in the change log list the files changed.

Sources


Swatches or size pickers in a client's audit? Send us one client's audit findings. We review the findings and the code, then quote a fixed price for an agreed list of fixes. Your team retests. If the agreed fixes fail your retest, you don't pay for them. Send findings.

Want us to fix this in your theme?

We fix accessibility issues like this in your theme code, on a duplicate theme, with a before/after report for every issue. Fixed prices from $450.

Related