6 min read#shopify#polaris#web-components#app-bridge

Polaris Web Components Gotchas Nobody Documents (2026)

Four Polaris Web Components traps that cost us real debugging time in a Shopify embedded app: dead events inside s-modal, a Save Bar that never saves, inline stacks that wrap form fields, and container queries that silently fall back.

Key Takeaways

  • React onClick/onChange handlers on s-* elements inside an s-modal do not fire reliably, because the modal renders outside your React tree. Use shopify.picker() for custom selection lists.
  • The App Bridge Save Bar recognizes its Save button by variant="primary", not by a slot="save-action". Get it wrong and clicking Save does nothing, with no network request at all.
  • s-stack direction="inline" will not place two form fields side by side; each field is full-width and wraps. Use s-grid with explicit columns.
  • Responsive @container syntax on s-grid/s-stack silently falls back to the mobile value unless an s-query-container ancestor exists.

Polaris Web Components (the s-* elements) are the right way to build Shopify Admin UI, but a handful of their behaviors are surprising, undocumented in the obvious places, and fail silently. Each of the four below cost us real debugging time in the Flicky admin app, usually because the symptom was "nothing happens" rather than an error. Here they are with the symptom, the reason, and the fix, so they cost you minutes instead of an afternoon.

1. React Events Don't Fire Inside s-modal

Symptom: you build a custom selection list inside an <s-modal>, with onClick on each row or onChange on checkboxes. Clicking does nothing. State never updates. Hitting Confirm submits an empty selection.

Why: s-modal teleports its slotted content out of your React root to render it on the Shopify overlay layer. React relies on event delegation from its root, so synthetic events from elements that now live outside that root never reach the listeners. Your handlers are wired to nodes React can no longer hear.

Fix: for custom selection (customers, segments, markets, tags), do not build a clickable list inside s-modal. Use the native App Bridge picker instead:

javascript
const picker = await shopify.picker({
  heading: 'Select customer segments',
  multiple: true,
  items: segments.map((s) => ({ id: s.id, heading: s.name, selected: chosen.has(s.id) })),
});
const selectedIds = await picker.selected;   // string[] | undefined (undefined = cancelled)

For products and collections, use shopify.resourcePicker(). Both run outside your React tree by design and return a plain promise, so there are no cross-boundary events to lose. This is the same root cause as the next gotcha: anything Shopify renders outside your iframe or React root cannot rely on your React event wiring.

2. The Save Bar That Never Saves

Symptom: you add an App Bridge <SaveBar>, wire your Save button, and clicking Save does nothing. No toast, no error, and crucially no network request: the backend log shows only GETs, never the PUT you expected.

Why: the App Bridge <SaveBar> (v4) renders a <ui-save-bar> and identifies its buttons by variant="primary" for Save and a plain <button> for Discard. If you instead use slot="save-action", the save bar renders its own default Save button that is not connected to your React handler. Your click hits a button that calls nothing.

Fix: mark the Save button with variant="primary", not a slot:

jsx
// WRONG — ui-save-bar renders its own Save, not wired to your handler.
<SaveBar id="settings">
  <button slot="save-action" onClick={handleSave}>Save</button>
</SaveBar>

// RIGHT — variant="primary" is how the Save button is recognized.
<SaveBar id="settings">
  <button variant="primary" onClick={handleSave}>Save</button>
  <button onClick={handleDiscard}>Discard</button>
</SaveBar>

How to spot it fast: the tell is the empty network tab. If clicking Save produces no request at all (not a failed one, none), the handler is not wired, which points at the button markup, not the mutation. We found all three affected pages by grepping the backend log for the missing PUT.

3. s-stack direction="inline" Wraps Form Fields

Symptom: you put two s-text-fields (or color/number/select fields) in an s-stack direction="inline" expecting them side by side, and each field drops onto its own line instead.

Why: form fields render full inline-size. An inline s-stack is flexbox that lays out children by their intrinsic width, and a full-width child immediately wraps. So each field claims the whole row.

Fix: use s-grid with explicit columns, one per field:

jsx
// WRONG — each field is full-width, so they stack vertically.
<s-stack direction="inline" gap="base">
  <s-text-field label="Width" name="w" />
  <s-text-field label="Height" name="h" />
</s-stack>

// RIGHT — n columns for n fields.
<s-grid gridTemplateColumns="1fr 1fr" gap="base">
  <s-text-field label="Width" name="w" />
  <s-text-field label="Height" name="h" />
</s-grid>

For three color pickers in a row, use gridTemplateColumns="1fr 1fr 1fr". This is the documented pattern for laying out fields; reach for s-grid, not s-stack, whenever you want inputs on one line.

4. Container Queries Silently Fall Back Without s-query-container

Symptom: you write a responsive s-grid with @container syntax so fields are three-across on wide screens and stacked on narrow ones. It always renders stacked, even on a wide screen. No error.

Why: the @container responsive syntax resolves only when there is an s-query-container ancestor to provide the containment context. Without one, the @container condition never matches and the value silently falls back to the part after the comma (the mobile default), so everything stacks.

Fix: wrap the responsive grid (or the whole tab/page) in s-query-container:

jsx
// WRONG — no containment context, so @container never matches → always 1 column.
<s-grid gridTemplateColumns="@container (inline-size > 480px) 1fr 1fr 1fr, 1fr" gap="base">
  {fields}
</s-grid>

// RIGHT — the query container makes @container resolve.
<s-query-container>
  <s-grid gridTemplateColumns="@container (inline-size > 480px) 1fr 1fr 1fr, 1fr" gap="base">
    {fields}
  </s-grid>
</s-query-container>

The subtle part: it is easy to assume s-grid is responsive on its own. It is not; the @container syntax needs a container to query. If you do not need the responsive behavior, use a static gridTemplateColumns="1fr 1fr 1fr" instead and skip the wrapper. Either is fine; the silent-stack bug comes from asking for @container without providing the container.

The Pattern Behind These Bugs

Three of these four share one root cause worth naming: Polaris and App Bridge render some things outside your React tree or outside your iframe. s-modal content and the ui-save-bar live outside the React root, so your event wiring does not reach them, which is why shopify.picker() and variant="primary" (Shopify-native mechanisms) work where React handlers fail. The container-query one is a different lesson: responsive s-* syntax has a prerequisite (s-query-container) that fails open to the fallback value.

The practical takeaway: when a Polaris interaction "does nothing," check first whether the element is one Shopify renders outside your control, and reach for the native mechanism rather than more React.

Verification

  • s-modal events: confirm a custom list inside s-modal does not update React state, then confirm shopify.picker() returns the selection reliably.
  • Save Bar: click Save and watch the network tab. A missing request (not a failed one) means the button is not wired; switch to variant="primary".
  • Inline fields: confirm two fields in an inline s-stack wrap, and that s-grid with 1fr 1fr places them side by side.
  • Container query: confirm a responsive @container grid stacks until you wrap it in s-query-container, then renders multi-column on a wide viewport.
  • Validate as you go: run the Shopify dev MCP component validator on s-*/App Bridge code before shipping, since props and values shift between API versions.

Frequently Asked Questions

Why don't my click handlers work inside s-modal?

s-modal renders its content on the Shopify overlay layer, outside your React root, so React's event delegation never receives the events. Use shopify.picker() for custom selection lists and shopify.resourcePicker() for products and collections, since both run natively outside your React tree and return promises.

My Save button does nothing and there's no network request. Why?

The App Bridge Save Bar recognizes the Save action by variant="primary", not by slot="save-action". With the slot, the save bar renders its own Save button that is not connected to your handler, so clicking it calls nothing. Switch the button to variant="primary". The absence of any network request (rather than a failed one) is the diagnostic tell.

How do I put two Polaris form fields on the same row?

Use s-grid with explicit columns (gridTemplateColumns="1fr 1fr"), not s-stack direction="inline". Form fields are full inline-size, so an inline stack wraps each one to its own line. Add one 1fr per field.

Why is my responsive s-grid always showing one column?

The @container responsive syntax only resolves inside an s-query-container. Without that ancestor the condition never matches and the grid falls back to the mobile value after the comma, so it stacks. Wrap the grid (or the page) in s-query-container, or use a static column template if you do not need responsiveness.


Written by Duke, based on the production implementation of the Flicky embedded Shopify app. Code samples are drawn from the shipping codebase and simplified for readability. See also the sticky-column stacking trap and the engineering notes hub.