Shopify Builds>LUS Brands>Mobile sticky add-to-cart bar mirrored from the real product form

Mobile Sticky Add-to-Cart Bar Mirrored From the Real Product Form

A bottom bar portaled to <body> that mirrors quantity, button label, disabled and loading state from the product page's own form, forwards taps into it, and follows iOS Safari's visual viewport as the browser chrome collapses.

A Shopify theme gets one product form, and on a long product page it scrolls away on the first swipe. Rather than render a second form at the bottom of the viewport, we portaled a mirror of the real one to the document body: it copies quantity, label, disabled and loading state, forwards taps to the button it shadows, and tracks the visual viewport so iOS toolbars can't leave it floating.

Fact Strip

  • Client: LUS Brands
  • Surface: Product detail page
  • Templates served: 4
  • Complexity: High
  • Attribution: Deploi-authored. The bar, the mirroring logic and the viewport maths are ours; the product form it shadows, and the quantity element it reuses, are Dawn 15.4.1's.
  • Status: Live, verified 2026-09-06
  • Evidence: Three code artifacts plus four client working records
  • Platform primitives: 4

The Problem

Product pages on this storefront are long — ingredients, routine steps, bundles, reviews, video — so on a phone the add-to-cart control leaves the viewport within a screen or two, and everything a shopper reads after that point is reading with no way to buy. Shopify offers no help here in particular: a theme renders one product form, and where it sits in the document is where it stays.

The obvious remedy is the one that causes the damage. A second product form fixed to the bottom of the screen gives the page two quantity inputs, two variant states and two things that have to agree with the cart. They agree right up until a shopper changes a variant, edits a quantity, or taps twice while a request is in flight.

The Constraint

The bar has to paint above everything else on the page, and a fixed element rendered inside <main> loses that fight to any ancestor carrying a transform or an overflow rule — of which a block-composed product page has plenty. iOS Safari compounds it: the browser chrome collapses and expands as you scroll, so bottom: 0 is the wrong number for as long as the toolbar is animating.

Dawn re-renders the product form through the Section Rendering API on every variant change, so an element reference cached at load, or a listener bound to the form's markup, is invalidated repeatedly across a session. And the quantity rules — minimum, maximum, increment, and the quantity already sitting in the cart — are computed server-side and written onto the input as attributes. Recomputing them in JavaScript would put a second copy of a merchandising rule in the browser.

What We Built

sticky_bar.liquid is rendered from theme.liquid, after </main> and on product templates only. It emits two things: a quantity-input custom element and a plain type="button" submit proxy. Neither is a form. That distinction is the whole design.

sticky-bar.js picks the markup up and portals it to document.body, then resolves the live section id from <product-info data-section> so it can find the real controls by name. From there it keeps the two in lockstep in three directions.

Quantity. Two-way sync between the bar's input and the form's, guarded by a syncingQty re-entrancy flag so a change written into one input cannot bounce back out of the other. Before the sync, min, max, step, data-min, data-max and data-cart-quantity are copied across and quantity-input.validateQtyRules() is called on the bar's element — the same method Dawn calls on its own, so the rules are enforced by the theme's code against the theme's server-rendered attributes.

Button state. A MutationObserver on the main submit button watches disabled, aria-disabled and class, plus subtree changes, and mirrors the label, the disabled state, the .loading class and spinner visibility onto the proxy. The label is read from span:not(.sold-out-message), so sold-out and unavailable copy follows whatever the real button is currently saying.

The tap. The proxy's click handler refuses to act when the main button is disabled or loading. Otherwise it forwards a synthetic .click() to the real button and gets out of the way. Variant selection, quantity rules, the cart request, error handling and analytics all stay in one implementation.

Around that, the controller subscribes to PUB_SUB_EVENTS.variantChange and quantityUpdate, and re-caches its element references on product-info:loaded, because the markup it points at is periodically replaced beneath it.

Layout is handled with custom properties rather than magic numbers. A ResizeObserver writes the bar's measured height into --sticky-bar-height and adds a has-sticky-bar class to the body, so the page can reserve its own footroom. The bottom inset is recomputed from window.visualViewportinnerHeight - offsetTop - height — into --sticky-bar-bottom, on a requestAnimationFrame-throttled listener for resize, scroll and orientation change.

Why This Way

Proxying a tap into a form that already exists is a smaller idea than building a second one, and it holds under the cases that break sticky bars: a variant swap mid-scroll, a quantity rule that depends on what's already in the cart, an add that's still in flight. There is one form, one set of rules, one place where a bug can live. Portaling to <body> was the price of admission — nothing rendered inside the product section can reliably outrank its own ancestors' stacking contexts — and the visualViewport arithmetic is the only way we've found to keep a bottom-anchored element flush while iOS animates its toolbars.

What we gave up is expressiveness. The bar is deliberately dumb: it holds no state, so anything a merchandiser wants it to say has to be true of the real form first. And leaving the section's DOM means leaving its styles behind, so every visual rule the bar needs is written for the bar rather than inherited from the page around it.

Why Not an App

Sticky add-to-cart is one of the most common line items in a conversion-optimization app's feature list, and those apps generally work the same way: inject a second form, then keep it in step with the theme's by reading selectors. That approach inherits the exact problem this build exists to avoid — a second source of truth for quantity and variant, syncing across a boundary the app doesn't control, on a theme whose form re-renders itself on every variant change. Ours is a view of the form, not a copy of it, so there is nothing to drift. The other reason is scope: the bar is a snippet and one module, mounted only on product templates and only under a mobile media query. Buying it would mean a script on every page of the storefront to get a control that appears on some of them.

Implementation Notes

  • Nothing initializes unless <product-info data-section> is present, so the script is inert on non-product pages even when it loads.
  • Everything sits behind matchMedia('(max-width: 749px)'). On desktop the custom properties are removed and the body class dropped, so no layout footroom is reserved for a bar that isn't there.
  • The bar reuses Dawn's quantity-input element rather than a plain number field, so the server-rendered quantity_rule attributes — the cart-aware data-cart-quantity among them — are honored by the theme's own validation.
  • The mirrored label reads span:not(.sold-out-message) from the main button, so sold-out and unavailable states carry across without a second copy of the copy.
  • tryInit re-arms itself on the next product-info:loaded when no controller could be constructed on the first pass, which covers a product section that arrives after the script.

Edge Cases

  • Sold-out and unavailable variants render their correct button copy server-side, and the proxy declines to submit whenever the main button is disabled.
  • Loading state and spinner are mirrored, so a second tap during an in-flight add does nothing rather than queueing a duplicate.
  • The re-entrancy flag stops the two quantity inputs from ping-ponging change events at each other.
  • ResizeObserver and visualViewport are both feature-detected. Without them the bar still renders — it simply doesn't reserve layout space or correct its inset.

Platform Primitives Used

  • Section Rendering API — Dawn re-renders the product form through it on every variant change, so the bar treats its own element references as temporary and re-caches them on the theme's own product-info:loaded event.
  • Cart AJAX API — reached only through the real form. The bar never issues a cart request of its own; it forwards a click and lets the theme's existing path do the work.
  • Custom elements — the bar's quantity control is Dawn's quantity-input element, so quantity validation runs in the theme's implementation, not a second one.
  • CSS custom properties--sticky-bar-height and --sticky-bar-bottom are written from measurement, so the page's footroom and the bar's inset are values the CSS reads rather than numbers hard-coded into it.

Where It Runs

Four product templates mount it: the standard product page, the 2026 design, the kids' catalog variant and the waiting-list template. All four render the same snippet from the layout rather than from the section, so a template that gains a new PDP layout inherits the bar without anyone remembering to add it. On desktop widths it is present in the document and switched off.

What This Demonstrates

Primary: AJAX cart drawer and line-item rules without an app — specifically the discipline of routing every cart interaction through one implementation, whatever surface triggered it. It is also a sticky add-to-cart and buy box build, and the buy-box half is where the mirroring pattern earns its keep.

How We Know

Three code artifacts: the Liquid snippet, its JavaScript module and the built asset the layout enqueues, running to roughly 340 lines. Four client working records cover the same decision from the other side — agreement that the mobile add-to-cart control should be a sticky bottom element, then a later, sharper boundary that the product image slider should keep scrolling on small screens and the add-to-cart button should be the only thing pinned. The code's choice to portal a mirror rather than build a second form is what keeps that boundary true.

Related Builds

The Buy-vs-Build Question

Sticky add-to-cart and slide-out cart usually arrive in the same app, which makes them a fair test of where the buy line falls. Our position: buy the thing that needs a backend, build the thing that needs to agree with your theme. A bar that mirrors a form is the second kind. See the slide cart and sticky add-to-cart decision.

Want a Buy Button That Follows the Shopper Down the Page?

If your product pages have grown and the add-to-cart control hasn't kept up, there's a version of this that fits your theme instead of fighting it. Contact us today and we'll look at your product page together.

More builds