7 min read#shopify#css#storefront#theme-app-extension

Make a Storefront Widget Survive Any Merchant's Theme CSS (2026)

A storefront widget runs inside someone else's theme, where generic class names and CSS variables collide and element resets destroy your markup. Here are the isolation rules we use: prefixed classes, namespaced tokens, scoped reset, and SVG for icons.

Key Takeaways

  • A storefront widget shares a page with the merchant's theme CSS, so generic class names, generic CSS variable names, and default element styles are all hostile territory.
  • Prefix every class with a namespace unlikely to collide, and never rely on a theme's reset leaving your bare divs and spans alone.
  • Define your own prefixed CSS variables for anything customizable. A generic token like --color-border can be clobbered by a theme token of the same name.
  • Scope a reset to your widget root, render icon-like visuals as inline SVG, and let fonts inherit so the widget feels native without being fragile.

Your widget works perfectly on a blank test theme. Then a merchant installs it on their heavily customized theme and the borders vanish, the radio circles turn into squares, and a key color is suddenly wrong. Nothing in your code changed. The theme did it, by defining a CSS variable with the same name as yours, or by applying a global element reset that flattened your markup.

A storefront widget does not get a clean room. It is injected into the merchant's DOM and inherits their cascade. This guide documents the isolation rules we use in the Flicky theme app extension so a widget renders correctly on any theme: prefixed classes, namespaced tokens, a scoped reset, and SVG for anything a theme could restyle. Each rule maps to a real failure we hit.

The Two Collisions That Break Widgets

Two specific failures account for most "it works on my theme" bugs.

Generic CSS-variable collision. You define --color-border: #d1d5db on your widget root and use it for your borders. The merchant's theme also defines --color-border on a closer or equally specific selector, or relies on it cascading. Now your border uses the theme's value, or nothing, and your careful design falls apart. CSS custom properties share one global namespace per element subtree, so a generic name is a landmine.

Element-reset collision. You render a bordered radio as a bare <div> or <span> with a border and a fixed width/height. The theme has a global reset like * { border: 0 } or restyles span, and your element loses its box entirely. Your markup and CSS are correct; the theme's broader-reaching rules simply win or strip the defaults you assumed.

Both come from the same root cause: you do not control the cascade your widget lands in. The rules below assume the theme is adversarial, because effectively it is.

Rule 1: Prefix Every Class

In this step you'll namespace every class so it cannot collide with a theme's class of the same purpose.

A theme almost certainly has a .row, a .badge, a .button, maybe a .radio. If you use those names, you inherit the theme's styling for them. Prefix everything with a short namespace tied to your app and feature:

html
<div class="flicky-vd-wrap">
  <div class="flicky-vd-row flicky-vd-row--selectable">
    <span class="flicky-vd-radio" aria-hidden="true"></span>
    <div class="flicky-vd-main">...</div>
  </div>
</div>

What just happened: flicky-vd-row will never match a theme's .row. The prefix (flicky- for the app, vd for the volume-discount feature) makes every selector specific to your widget. This is the cheapest and highest-leverage rule; do it for every class without exception.

Rule 2: Namespace Your Customizable Tokens

In this step you'll give every merchant-customizable value its own prefixed CSS variable, so a theme token cannot overwrite it.

Define customizable values as prefixed custom properties on your widget root, with a -val indirection so an inline style can override the default cleanly:

css
.flicky-vd-wrap {
  /* Customizable tokens — prefixed so a theme's --color-* can't clobber them.
     The `-val` suffix is the inline-style override hook; the base var supplies a default. */
  --flicky-vd-row-bg:        var(--flicky-vd-row-bg-val, #ffffff);
  --flicky-vd-row-border:    var(--flicky-vd-row-border-val, #e5e7eb);
  --flicky-vd-active-color:  var(--flicky-vd-active-color-val, #0f6e56);
  --flicky-vd-active-bg:     var(--flicky-vd-active-bg-val, #e1f5ee);
}

The merchant's settings are applied as the -val variables via inline style, which keeps all styling in CSS with no inline property soup:

html
<div class="flicky-vd-wrap"
     style="--flicky-vd-active-color-val:#b4126b; --flicky-vd-active-bg-val:#fdeef6;">

What just happened: every value a merchant can customize lives behind a --flicky-vd-* name that a theme is not going to define. The -val indirection gives you a clean default (used when no inline override is present) and a single override hook (the inline style). Reserve generic names like --color-* for internal, non-critical values only, and know that even those can be influenced by the theme's cascade.

Watch out: a CSS variable you read but never define on your own scope will fall through to whatever the theme set. If a value matters to your design, define it on .flicky-vd-wrap with a prefixed name.

Rule 3: Scope a Reset and Never Trust Defaults

In this step you'll protect your bare elements from theme resets by scoping your own reset and declaring every property you depend on.

Add a reset scoped to your widget root so a theme cannot change your box model out from under you, and never leave a visually important property to the default:

css
/* Scoped reset — stops theme CSS from redefining the box model of bare elements. */
.flicky-vd-wrap,
.flicky-vd-wrap *,
.flicky-vd-wrap *::before,
.flicky-vd-wrap *::after {
  box-sizing: border-box;
}

/* Declare EVERY property the element depends on — don't assume a default survives. */
.flicky-vd-radio {
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 2px solid var(--flicky-vd-row-border);
  background: #fff;
  position: relative;
}
.flicky-vd-row.active .flicky-vd-radio::after {
  content: "";
  position: absolute;
  inset: 3px;
  border-radius: 50%;
  background: var(--flicky-vd-active-color);
}

What just happened: the radio is a bare <span>, but it declares its own width, height, border, border-radius, and background explicitly, so a theme reset that zeroes borders or restyles spans cannot erase it. The scoped reset fixes the box model inside your widget without touching the rest of the page (never reset globally from a widget, you would break the merchant's store).

Rule 4: Render Icons as Inline SVG

In this step you'll make icon-like visuals immune to theme CSS by drawing them as SVG geometry instead of styled elements.

A chevron or checkmark built from borders or pseudo-elements is at the mercy of theme rules. The same shape as inline SVG is geometry the theme cannot restyle away:

javascript
// Chevron and check rendered as inline SVG — immune to theme element resets.
const chevron = `<svg class="flicky-vd-select__chevron" viewBox="0 0 24 24" fill="none"
  stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>`;
const check = `<svg class="flicky-vd-select__check" viewBox="0 0 24 24" fill="none"
  stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;

What just happened: the chevron and check are defined by SVG path geometry, so there is no border or background for a theme reset to strip. They inherit color via currentColor, so they still respect your widget's color tokens. Use SVG for any crisp iconography; reserve styled-element shapes for cases where a scoped reset plus explicit declarations (Rule 3) is enough.

Rule 5: Inherit the Font, Share the Markup

Two smaller rules round out the isolation.

Let the font inherit. Default your widget's font to the theme's so it feels native, and only override when the merchant explicitly picks a font in your settings:

css
.flicky-vd-wrap {
  --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-family: var(--font-sans);
  max-width: 100%;   /* never overflow the merchant's container */
}

Share one stylesheet between storefront and admin preview. The same flicky-vd-* classes and tokens render both the live storefront widget and the in-app preview (StorefrontWidgetPreview.jsx). One source of truth means the merchant's preview matches what shoppers see, and you never maintain two drifting copies.

Verification

  • Hostile-theme test: install the widget on a theme with an aggressive global reset (* { border: 0; margin: 0 }) and confirm borders, the radio circle, and spacing survive.
  • Token-collision test: add --color-border: red to the theme and confirm your widget's borders are unaffected because they use --flicky-vd-* tokens.
  • Icon test: confirm the chevron and check render as crisp SVG and take your widget's color, not the theme's.
  • Overflow test: place the widget in a narrow container and confirm max-width: 100% keeps it inside.
  • Preview parity: confirm the in-app preview and the storefront render identically, since they share the stylesheet.
  • After CSS changes: npm run build in the theme extension, then shopify app deploy.

Key Takeaways

1. Treat the theme as adversarial. You do not control the cascade your widget lands in, so assume generic names collide and generic resets strip your defaults.

2. Prefix classes and tokens. A namespaced class cannot match a theme's class, and a namespaced CSS variable cannot be clobbered by a theme variable of the same name.

3. Declare everything; trust nothing. Scope a reset to your root and set every property a visually important element depends on, so no theme reset can erase it.

4. Use SVG for icons. Geometry survives where styled-element shapes do not, and currentColor keeps SVG on-brand with your tokens.

Frequently Asked Questions

Why not use Shadow DOM to isolate the widget completely?

Shadow DOM does isolate styles strongly, but it also cuts the widget off from inheriting the merchant's fonts and makes it harder to let the widget feel native to each theme. Our approach keeps the widget in the light DOM (so it inherits typography and spacing cues) while defending the specific things that break: class names, tokens, element defaults, and icons.

My CSS variable works on some themes but not others. Why?

You are almost certainly reading a generically named variable (like --color-border) that you do not define on your own scope, so it falls through to whatever the theme set. Define it on your widget root with a prefixed name (--flicky-vd-row-border) and the theme can no longer influence it.

Do I really need to prefix every single class?

Yes. It is the cheapest rule and the one that prevents the widest class of bugs. A single unprefixed .row or .badge is enough to inherit a theme's styling for it. Make the prefix a habit, not a judgment call.

How do I keep the in-app preview matching the real widget?

Share one stylesheet and one set of class names between the storefront widget and the admin preview component. When both render from the same flicky-vd-* CSS, the preview a merchant configures is exactly what shoppers see, with no second copy to drift.


Written by Duke, based on the production implementation of the Flicky Shopify theme app extension. Code samples are drawn from the shipping codebase and simplified for readability. See also rendering widgets without layout shift and the engineering notes hub.