Shopify app accessibility: the color-contrast trap
App Bridge and Polaris give you a safety net, but merchants are going to override it. Designing a contrast-safe theming layer that still respects the brand.
Every Shopify embedded app I’ve audited in the last year has failed the same two WCAG 2.1 AA checks. Not the exotic ones — the boring, common ones. Placeholder text contrast, and focus rings quietly removed on custom-styled inputs.
Both bite because the Polaris defaults are fine. The problem is what happens when merchants layer their brand on top.
#The two failures, specifically
#Placeholder text (SC 1.4.3)
A lot of designers assume placeholder contrast only needs to meet 3:1 because
placeholders are "decorative" or "a hint." WCAG 2.1 SC 1.4.3 disagrees:
visible text is visible text, and it needs 4.5:1 against its background.
Polaris ships placeholders around #6D7175 on a white background, which
clears the bar. A merchant brand override doesn’t always clear it.
#Focus rings (SC 2.4.7)
The other one is even more common. Someone customizes the input border-radius,
hits outline: none to clean up the default Chromium focus ring, and ships
without a replacement. Now the keyboard user has no idea where they are.
The fix is one line, and it’s non-negotiable:
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}If you remove the ring, you owe the user a visible replacement. That’s the whole rule.
#A theming layer worth shipping
I’ve built this two ways now. Once as an SCSS layer on the admin surface, once as a CSS-variable-only layer on the storefront. The SCSS one wins on guarantees. The variable one wins on runtime flexibility. You pick your trade-off.
// Given a background and a preferred foreground, return the preferred
// color if it clears 4.5:1, otherwise fall back to a high-contrast default.
@function safe-text($bg, $pref, $fallback: #141413) {
@if (contrast-ratio($bg, $pref) >= 4.5) {
@return $pref;
}
@return $fallback;
}
.banner {
background: var(--brand);
color: safe-text(var(--brand), #FAF9F5); // compile-time guarantee
}The line that matters is line 5 — the check happens at compile time, and the
build fails if the math is wrong. The variable version of this is softer:
the fallback only kicks in at runtime, and only if you remembered to wrap
every color assignment in a color-contrast() call.
#What the audit actually looks like
Roughly in this order, top of the checklist down:
- Visible focus indicator on every interactive element.
- 4.5:1 contrast on body text and placeholders. 3:1 on large text (18pt+ or 14pt bold).
<label>for every input, oraria-labelif visually hidden.- Skip link to main content, visible on focus.
- No motion that lasts longer than 5s without a control to pause.
prefers-reduced-motionhonored on every animation.
Getting to the top of that list gets you past most of the stuff a merchant will actually notice. Getting to the bottom gets you past an audit.
#Why I stopped fighting the defaults
For a long time I wanted to ship a theming layer that matched the brand exactly, and protected the user from themselves. I can tell you from experience: that system is impossible to maintain, and it only works as long as nobody looks at it.
What actually works is a small, loud rule: if a brand color drops below 4.5:1 on the surface it targets, render the fallback and log a warning. Merchants will notice the fallback. Good. That’s the point.
export function textOn(bg: string, pref: string, fallback = "#141413") {
const ratio = contrastRatio(bg, pref);
if (ratio < 4.5) {
console.warn(`[a11y] preferred text on ${bg} failed contrast (${ratio.toFixed(2)}:1), using fallback`);
return fallback;
}
return pref;
}The quiet observation after all of this: accessibility isn’t a separate discipline you bolt on. It’s what the design system is for. If the system only produces accessible output, you don’t need a compliance pass. You just need to stop undoing the system at the component layer.