Shopify · Guide · · Erick Nolasco
Pipeline by Groupthought is a good-looking theme, and a lot of fashion and lifestyle brands use it. It also has a handful of keyboard and screen reader problems that ship with the theme itself. That means a store can have careful product photos, clean copy and an accessibility overlay installed, and still have shoppers getting lost in the header or the cart.
I audit and fix Shopify stores for accessibility. After seeing the same Pipeline problems on two stores I audited, I went back and checked more Pipeline stores to separate theme problems from store problems. Here is what I found and how I’d fix each one.
What I tested
Four live Shopify stores running Pipeline: one on 8.1.0, two on 8.1.1, and one on the older 6.4.0 for comparison. I confirmed the theme and version from Shopify.theme in each page’s source. I checked the homepage, a product page, the cart drawer and, where the store had it, Quick View.
I only list something below as a Pipeline issue if it showed up on at least two of these stores, or if I could trace it to Pipeline’s own markup or code. App problems are covered separately at the end.
The issues
1. Header menus open on focus and Escape doesn’t close them
What happens: Tab onto a top-level menu item like “Shop” and its dropdown opens right away. Every link inside it joins the Tab order, so a keyboard user has to walk through the whole submenu to reach the next menu item. On one store that meant 66 Tab presses to get across the header. Pressing Escape on the top-level link does nothing. Pressing Escape from inside a submenu does close it, but focus drops to the page body, so the shopper’s place is gone.
This happened on all four stores, including 6.4.0. It comes from Pipeline’s HoverDisclosure code, which calls showDisclosure() on the link’s focus event.
Who it affects: Keyboard users, switch users, and anyone for whom every key press takes effort.
The fix: Give each dropdown a real toggle button, and stop focus alone from opening the menu. In the snippet that outputs .menu__item (search the theme code for data-hover-disclosure-toggle), add a button right after the top-level link:
<a href="{{ link.url }}" data-top-link class="navlink navlink--toplevel">
<span class="navtext">{{ link.title }}</span>
</a>
<button type="button" class="menu__toggle" data-menu-toggle
aria-expanded="false" aria-controls="{{ same value as data-hover-disclosure-toggle }}">
<span class="visually-hidden">{{ link.title }} menu</span>
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16"><path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2"/></svg>
</button>
Then add the behavior in a separate file, assets/a11y-fixes.js. Pipeline’s theme.js is minified and gets replaced when you update the theme, so keep your patches out of it. Load the file in layout/theme.liquid just before </body>:
<script src="{{ 'a11y-fixes.js' | asset_url }}" defer></script>
// Stop focus on the top-level link from opening the dropdown (hover still works)
document.addEventListener('focus', (e) => {
if (e.target.matches?.('.menu__item [data-top-link]')) e.stopImmediatePropagation();
}, true);
document.querySelectorAll('.menu__item[data-hover-disclosure-toggle]').forEach((item) => {
const panel = document.getElementById(item.getAttribute('data-hover-disclosure-toggle'));
const toggle = item.querySelector('[data-menu-toggle]');
const header = item.closest('[data-header-wrapper]');
if (!panel || !toggle) return;
const setOpen = (open) => {
item.classList.toggle('is-visible', open);
panel.classList.toggle('is-visible', open);
if (header && item.classList.contains('grandparent')) header.classList.toggle('meganav--visible', open);
toggle.setAttribute('aria-expanded', String(open));
};
toggle.addEventListener('click', () => setOpen(!panel.classList.contains('is-visible')));
item.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && panel.classList.contains('is-visible')) { setOpen(false); toggle.focus(); }
});
});
Give .menu__toggle at least a 24 by 24 pixel hit area so it meets the WCAG 2.2 target size rule. On the store that needed 66 Tab presses to cross the header, this patch brought it down to 18.
2. Menu headings are buttons wrapped around links
What happens: Each top-level item with a dropdown is a <div class="menu__item" role="button" aria-haspopup="true" aria-expanded="false"> with an <a class="navlink navlink--toplevel"> inside it. Screen readers get a “Shop” button and a “Shop” link stacked in one spot, and the open or closed state sits on the wrapper instead of anything the shopper can focus. axe flags this as nested-interactive on all four stores.
Who it affects: Screen reader and voice-control users.
The fix: Delete role="button" and aria-haspopup from the .menu__item div in Liquid. Pipeline’s JavaScript writes aria-expanded back onto that div when a menu opens on hover, so add this inside the loop from issue 1 to move the state onto the new button:
const strip = () => ['role', 'aria-haspopup', 'aria-expanded', 'aria-controls'].forEach((a) => item.removeAttribute(a));
strip();
new MutationObserver(() => {
toggle.setAttribute('aria-expanded', String(panel.classList.contains('is-visible')));
if (item.hasAttribute('aria-expanded')) strip();
}).observe(item, { attributes: true, attributeFilter: ['aria-expanded', 'class'] });
3. The cart drawer lets focus slip behind it
What happens: Opening the cart from the header doesn’t reliably move focus into the drawer. On two of the 8.1.1 stores focus stayed on the Cart link every time, and on the 8.1.0 store it happened on one of two runs. Pipeline only moves focus after a CSS transitionend event, which is fragile. Once you’re in, a couple of Tab presses take you out of the drawer and onto the page behind the overlay. Escape only works if focus is already inside. When the drawer closes, Pipeline focuses the first [data-drawer-toggle="drawer-cart"] on the page, which is the hidden mobile cart icon, so focus is lost. And hideDrawer() sets aria-expanded to true instead of false. I saw the leak, the lost focus and the stuck aria-expanded on all four stores.
Who it affects: Keyboard and screen reader users, at the point where they’re about to check out.
The fix: Add this to a11y-fixes.js:
const visible = (el) => el.checkVisibility
? el.checkVisibility({ checkVisibilityCSS: true, visibilityProperty: true })
: el.offsetParent !== null;
const focusables = (root) => [...root.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])'
)].filter(visible);
const cart = document.getElementById('drawer-cart');
if (cart) {
let opener = null;
const cartButton = () => [...document.querySelectorAll('[data-drawer-toggle="drawer-cart"]')].find(visible);
const isOpen = () => cart.classList.contains('drawer--visible');
new MutationObserver(() => {
const panel = cart.querySelector('.drawer__content');
if (isOpen()) {
panel?.setAttribute('role', 'dialog');
panel?.setAttribute('aria-modal', 'true');
panel?.setAttribute('aria-label', 'Cart');
opener = document.activeElement !== document.body ? document.activeElement : cartButton();
setTimeout(() => {
if (!cart.contains(document.activeElement)) (cart.querySelector('[data-first-focus]') || focusables(cart)[0])?.focus();
}, 450);
} else {
document.querySelectorAll('[data-drawer-toggle="drawer-cart"]').forEach((b) => b.setAttribute('aria-expanded', 'false'));
(opener && visible(opener) ? opener : cartButton())?.focus({ preventScroll: true });
opener = null;
}
}).observe(cart, { attributes: true, attributeFilter: ['class'] });
document.addEventListener('keydown', (e) => {
if (!isOpen()) return;
if (e.key === 'Escape') return cart.dispatchEvent(new CustomEvent('theme:drawer:close'));
if (e.key !== 'Tab') return;
const items = focusables(cart);
if (!items.length) return;
const first = items[0], last = items[items.length - 1];
if (!cart.contains(document.activeElement)) { e.preventDefault(); first.focus(); }
else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}, true);
}
The trap only counts controls you can actually see. That matters because Pipeline’s own trap uses the last focusable element in the markup, which is often hidden.
4. Product photo thumbnails can’t be reached by keyboard
What happens: Thumbnails are <div class="media__thumb" data-slideshow-thumbnail> with a click listener and nothing else. You can’t Tab to them or press Enter on them. On a product with 30 photos, axe also flags the scrolling strip (.media__thumb__wrapper) as scrollable-region-focusable. I confirmed this on three stores. The fourth hides the strip at desktop width.
Who it affects: Keyboard users who want a specific photo, like a fabric close-up or a fit shot.
The fix:
const thumbs = [...document.querySelectorAll('[data-product-thumbs] [data-slideshow-thumbnail]')];
thumbs.forEach((thumb, i) => {
thumb.setAttribute('role', 'button');
thumb.tabIndex = 0;
const base = thumb.getAttribute('aria-label') || 'Product image';
thumb.setAttribute('aria-label', `${base}, image ${i + 1} of ${thumbs.length}`);
thumb.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); thumb.click(); }
});
});
document.querySelectorAll('[data-product-thumbs]').forEach((strip) => {
strip.setAttribute('role', 'group');
strip.setAttribute('aria-label', 'Product photos');
});
5. Quick View’s close button is skipped by Tab
What happens: In sections/api-product-quickview.liquid, the close button is written as <button class="drawer__button drawer__close" data-first-focus data-micromodal-close tabindex="-1" aria-label="Close">. That tabindex="-1" takes it out of the Tab order. The markup was the same on three stores. On the one store with Quick View turned on, focus also went to the page body when the panel closed instead of going back to the “Quick view” button.
Who it affects: Keyboard users, who have to know to press Escape to get out.
The fix: Delete tabindex="-1" from that button in the section file. Then return focus on close (this uses the visible helper from issue 3, so keep both in the same file):
let quickViewOpener = null;
document.addEventListener('click', (e) => {
const btn = e.target.closest('button, a');
if (btn && !btn.closest('[data-quickview-modal]')) quickViewOpener = btn;
}, true);
new MutationObserver((records) => records.forEach(({ target }) => {
if (target.matches('[data-quickview-modal]') && target.getAttribute('aria-hidden') === 'true'
&& quickViewOpener && visible(quickViewOpener)) quickViewOpener.focus();
})).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['aria-hidden'] });
6. The dropdown size picker hides the selected size from screen readers
What happens: If you set variant options to show as a dropdown, snippets/product-variant-options.liquid outputs <button class="select-popout__toggle" aria-labelledby="...-select-size-label-1">. That points only at the “Size” label, so screen readers hear “Size, button” and never the selected size. After choosing a size, focus lands on the page body. Only one of the four stores used the dropdown style, but the markup is Pipeline’s.
Who it affects: Screen reader users checking their size before adding to cart.
The fix: Give the toggle an id and add it to aria-labelledby, so the name becomes “Size XS”:
<button type="button" class="select-popout__toggle"
id="{{ the aria-controls value }}-toggle"
aria-labelledby="{{ the label id }} {{ the aria-controls value }}-toggle" ...>
Then send focus back after a choice:
document.addEventListener('click', (e) => {
const toggle = e.target.closest('[data-popout-option]')?.closest('[data-popout]')?.querySelector('[data-popout-toggle]');
if (toggle) setTimeout(() => toggle.focus(), 0);
});
7. The focus ring takes on your accent color
What happens: Pipeline’s theme.css styles focus with .focus-enabled *:focus, setting outline-color: var(--secondary) and outline-offset: -2px, both marked !important. Your focus ring is whatever your Secondary color is, drawn inside the element. On one store that color was a pale mint at 1.68:1 against white, and on its mint buttons the ring disappeared. The other stores had darker accents, so it’s fine for them. Check yours.
Who it affects: Keyboard users, especially those with low vision.
The fix: In Theme settings, Custom CSS:
body.focus-enabled *:focus {
outline: 2px solid #1a1a1a !important;
outline-offset: 2px !important;
}
A quick self-check
- Tab from the top of your homepage. Does a dropdown open just because you landed on it?
- With a menu open, press Escape. Does it close, and do you still know where focus is?
- Open the cart with Enter. Is focus inside it? Keep pressing Tab. Do you ever end up behind the overlay?
- Close the cart with Escape. Are you back on the Cart link?
- On a product page, can you Tab to the photo thumbnails?
- Can you see the focus ring on every button, including colored ones?
How I tested
I used Playwright with Chromium at 1280 by 900. On each store I ran axe-core with WCAG 2.2 AA rules, walked the page with the Tab key, and opened and closed menus, the cart drawer and Quick View from the keyboard. I read Pipeline’s own theme.js, theme.css and section markup to trace each problem to its source. I then loaded the patches above on two of the stores and repeated the walk to confirm they work.
There were limits. I only tested empty carts, since I don’t add products to other people’s stores. I didn’t test with a real screen reader, so the screen reader descriptions come from the accessibility tree, not from listening. I tested desktop only. Pipeline updates can change any of this, so test again after you update.
Some problems I saw were not Pipeline’s fault. A size chart app (Kiwi Sizing) didn’t move focus into its pop-up, and an Instagram feed app added hidden Tab stops. Those need fixes from the app developer or in the app’s settings. One store also had color swatches that linked to different products but shared the same name. That one comes from how the store names its products.
WCAG 2.2 criteria involved
- 1.4.11 Non-text Contrast
- 1.4.13 Content on Hover or Focus
- 2.1.1 Keyboard
- 2.4.3 Focus Order
- 2.4.7 Focus Visible
- 2.4.11 Focus Not Obscured (Minimum)
- 2.5.8 Target Size (Minimum)
- 4.1.2 Name, Role, Value
If your store runs Pipeline and you’d like to know where it stands, get a free 5-point accessibility summary.
Want to know where your store stands?
I offer a free 5-point accessibility summary: the most important issues on your site, in plain language, with no obligation.
Get a free 5-point summary