6 min read#css#stacking-context#z-index#shopify

The Sticky-Column Trap: When position:fixed Renders Behind Everything

A position:fixed modal opened from inside a sticky two-column layout can render behind the other column. Here is why position:sticky creates the stacking context that traps it, and the two ways out.

Key Takeaways

  • position: sticky creates a stacking context. A position: fixed overlay opened from inside a sticky column is stacked within that column, not above the whole page.
  • When a later sibling column also forms a stacking context (because it too is sticky), it can paint over your overlay, so the modal appears behind the neighboring column.
  • No amount of z-index on the overlay fixes it, because the overlay and the other column live in different stacking contexts.
  • The fix is to render the overlay outside the column's DOM subtree: a Shopify s-modal (renders on the admin overlay layer) for simple content, or a React portal to document.body when the overlay has inputs.

You build a two-column detail page: a setup form on the left, a live preview on the right. To keep the preview visible while the form scrolls, you make the preview column position: sticky. Then you add a "Preview email" button inside a card in the left column that opens a position: fixed modal. It works in isolation. But on the real page, the modal renders behind the right-hand preview column, half-hidden, and bumping its z-index to 9999 changes nothing.

This is the sticky-column trap. It is a pure CSS stacking-context problem that z-index cannot solve, and it has bitten us more than once on our detail pages. This guide explains exactly why it happens and the two reliable ways out, with the decision rule for which to use.

Why position:fixed Gets Trapped

The surprising fact at the center of this is simple: position: sticky creates a new stacking context. So do transform, filter, will-change, contain, and opacity less than 1. This is easy to forget because we reach for sticky as a layout tool, not a stacking tool.

Here is the chain of events:

  1. Your preview column is position: sticky, so it forms its own stacking context.
  2. The card in your setup column that opens the modal, or the setup column itself, forms a stacking context too (often also sticky, or transformed).
  3. Your position: fixed modal is a descendant of that setup-side stacking context, so it is painted as part of that context, not at the page root.
  4. Two sibling stacking contexts with z-index: auto paint in DOM order. The preview column comes after the setup column in the DOM, so it paints last, on top.
  5. Result: the preview column covers your "fixed" modal, and z-index inside the setup context cannot lift the modal above a different sibling context.
css
/* The layout that sets the trap (simplified from our detail pages) */
.cd-detail-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;   /* setup : preview */
  align-items: start;               /* leave slack for sticky to travel */
}

@media (min-width: 769px) {
  .cd-preview-col {
    position: sticky;               /* <-- creates a stacking context */
    top: 12px;
    align-self: start;
    max-height: calc(100dvh - 24px);
    overflow-y: auto;
  }
}

The key realization: a position: fixed element escapes its containing block for positioning (it sizes and places relative to the viewport), but it does not escape its ancestor's stacking context for painting. Positioning and painting are two different questions, and sticky only interferes with the second one.

Why z-index Is a Dead End

The instinct is to crank the overlay's z-index. It fails because z-index only orders elements within the same stacking context. Your overlay is inside the setup column's context; the preview column is a separate sibling context. Comparing their z-index values is meaningless, the browser decides their order at the parent level, by DOM order, before your overlay's z-index is ever considered.

css
/* This does NOT work. */
.my-fixed-modal {
  position: fixed;
  z-index: 999999;   /* still trapped inside the setup column's stacking context */
}

The only real fix is to get the overlay out of the column's DOM subtree so it is no longer a descendant of any column's stacking context.

Fix 1: Render on the Shopify Overlay Layer with s-modal

In this step you'll move the overlay out of the trap by using a Shopify s-modal, which renders on the admin's overlay layer, outside your app's iframe DOM.

For an embedded Shopify app, s-modal is the cleanest escape. It renders on Shopify Admin's own overlay surface, completely outside your iframe's DOM, so it is not a descendant of any of your stacking contexts. You control it with the Commands API or the modal API.

jsx
// Trigger anywhere inside the sticky setup column — the modal escapes it.
<s-button commandFor="email-preview" command="--show">Preview email</s-button>

<s-modal id="email-preview" heading="Email preview">
  <s-text>Rendered email preview goes here.</s-text>
  <s-button slot="primary-action" commandFor="email-preview" command="--hide">Close</s-button>
</s-modal>

// Or imperatively: window.shopify.modal.show('email-preview')

What just happened: because s-modal renders outside the iframe entirely, there is no column stacking context between it and the viewport. It paints above everything, with no z-index juggling. This is what we switched our email-preview modal to, and the "behind the preview column" problem vanished.

Watch out: s-modal is the right tool only for content you display. It is not reliable for content you edit, which is the next fix.

Fix 2: Portal to document.body for Overlays with Inputs

In this step you'll handle the case s-modal can't: an overlay containing form inputs.

There is a known gotcha with s-modal: React onInput and onChange handlers on s-* children inside it do not fire reliably, because the modal renders in a separate shadow context from your React tree. So for an overlay with text fields, selects, or any controlled input, s-modal will leave you with dead inputs.

The fix that keeps both the stacking escape and working React events is a React portal to document.body:

jsx
import { createPortal } from 'react-dom';

function EmailMessageModal({ open, onClose, children }) {
  if (!open) return null;
  // Portaling to document.body makes the overlay a child of <body>, NOT of the
  // sticky column, so it escapes the stacking context AND keeps React events.
  return createPortal(
    <div className="cd-overlay" role="dialog" aria-modal="true">
      <div className="cd-overlay__backdrop" onClick={onClose} />
      <div className="cd-overlay__panel">{children}</div>
    </div>,
    document.body,
  );
}

What just happened: createPortal moves the overlay's DOM to document.body while keeping it in your React component tree. It is no longer a descendant of the sticky column, so it escapes the trap, and because it is still rendered by React, onInput/onChange on its inputs fire normally. You own the backdrop and panel CSS, but you get full control of the form.

The Decision Rule

Choose by whether the overlay contains inputs:

Overlay content Use Why
Display only (preview, confirmation, info) s-modal Escapes stacking contexts for free; native admin styling; no custom CSS
Contains inputs (forms, editable fields) React portal to document.body Escapes the trap and keeps React onInput/onChange working

Both work because both get the overlay out of the column's DOM subtree. The only question is whether you can tolerate s-modal's input-event limitation.

Verification

  • Reproduce it: put a position: fixed modal inside a column that is position: sticky (or has a sticky sibling), with the sticky column later in the DOM. Open it; it renders behind the sticky sibling.
  • Confirm z-index is a dead end: set the modal's z-index to a huge number; it still renders behind. This proves the problem is stacking contexts, not stacking order.
  • Confirm the fix: switch a display-only overlay to s-modal and confirm it paints above both columns. Switch an input overlay to a document.body portal and confirm both the stacking is correct and typing in its fields updates state.
  • DOM check: with the portal open, inspect the DOM and confirm the overlay node is a direct child of <body>, not nested inside the column.

Key Takeaways

1. position: sticky is a stacking-context trigger, not just a layout tool. The moment a column is sticky, anything positioned inside it is stacked within it, not at the page root.

2. Positioning and painting are separate. position: fixed escapes its containing block for layout but stays inside its ancestor's stacking context for paint. That split is the whole bug.

3. z-index cannot cross stacking contexts. If the overlay and the thing covering it are in different contexts, no z-index value will reorder them. Stop reaching for it.

4. Escape the subtree, don't fight the layers. Render the overlay outside the column: s-modal for display, a document.body portal for inputs. Both remove the overlay from the trap entirely.

Frequently Asked Questions

Why does position:sticky create a stacking context at all?

The CSS specification lists position: sticky (and fixed) as triggers that establish a new stacking context, alongside transform, filter, opacity < 1, will-change, and contain. It is easy to miss because we add sticky for scroll behavior and do not expect it to change paint order, but it does.

Can't I just raise the modal's z-index?

No. z-index only orders siblings within the same stacking context. A fixed overlay trapped in one column's context and the sibling column that covers it are in different contexts, so their z-index values are never compared directly. The browser orders the two contexts by DOM order at their shared parent first.

When should I use s-modal versus a portal?

Use s-modal for display-only overlays; it escapes stacking contexts for free and matches Shopify Admin styling. Use a React portal to document.body when the overlay contains inputs, because React onInput/onChange handlers on s-* children inside s-modal do not fire reliably.

Does a portal break accessibility or focus?

Not inherently. createPortal keeps the element in the React tree, so React event bubbling and context still work. You do need to manage focus and aria-modal yourself, the same as any custom dialog, but the portal itself does not harm accessibility.


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 engineering notes hub.