A conversion booster for product pages
Once the customer scrolls past the original add-to-cart button, a lean sticky add to cart bar takes over at the bottom of the screen. With x-intersect instead of a custom scroll listener, this pattern can be implemented performantly and without layout thrashing, in sync with variant selection and quantity in the main form.
Table of contents
- 1. Why a sticky add to cart bar improves conversion
- 2. Foundation: detecting visibility with x-intersect
- 3. Visibility logic: when the bar should appear
- 4. Showing and hiding with a transition from below
- 5. Syncing quantity and variants between bar and main form
- 6. Submitting: proxy submit on the main form
- 7. Performance: x-intersect instead of a custom scroll listener
- 8. Accessibility: focus order and aria-hidden
- 9. Sticky bar approaches compared
- 10. Summary
- 11. FAQ
1. Why a sticky add to cart bar improves conversion
On long product pages with a detailed description, reviews and cross-selling elements, customers often scroll far past the original add-to-cart button. Without a sticky add to cart bar, they then have to scroll back up to complete the purchase, which creates friction and in some cases leads to cart abandonment. A sticky add to cart bar fixed to the bottom of the screen keeps the purchase option reachable at any scroll position.
The decisive difference from a simple CSS position: fixed bar that is visible from the start lies in correct timing: the sticky add to cart bar should only appear once the original button has actually disappeared from the visible area. If it appears too early, it feels redundant and distracts from the product image. With Alpine.js and the x-intersect directive from the official intersect plugin, this timing can be controlled precisely without a manual scroll listener.
The following sections build a complete sticky add to cart bar that detects visibility via x-intersect, stays in sync with the quantity field and variant selection of the main form, and submits exactly the same form as the original button on click.
2. Foundation: detecting visibility with x-intersect
The core of the component is an invisible marker placed exactly at the position of the original add-to-cart button in the DOM. This marker is observed with x-intersect, which internally uses an IntersectionObserver instead of running calculations on every scroll event. As soon as the marker leaves the visible area, a global Alpine store is updated, which the sticky add to cart bar observes.
This decoupling through a store matters because the marker lives in the main content area of the page, while the sticky add to cart bar is typically rendered right at the end of the body element, to avoid stacking context issues with other fixed elements.
document.addEventListener('alpine:init', () => {
Alpine.store('stickyAddToCart', {
originalButtonVisible: true,
selectedQty: 1,
selectedOptions: {},
setOriginalButtonVisible(isVisible) {
this.originalButtonVisible = isVisible;
}
});
});
The marker itself is an empty <div> right next to the original button, with no visual effect but serving as a reliable reference point for x-intersect. This keeps the sticky add to cart bar logic independent of the actual height or size of the original button.
3. Visibility logic: when the bar should appear
The x-intersect:leave directive fires as soon as the observed element leaves the viewport, and x-intersect:enter as soon as it enters again. For a sticky add to cart bar this exact combination is ideal: on leave, the bar is shown, and on re-entering, for example when the customer scrolls back up, it is hidden again.
A common mistake is listening only to x-intersect without a direction, which fires on every crossing of the threshold regardless of direction and causes flicker when the customer scrolls back and forth exactly at the boundary. The directional variants :enter and :leave reliably avoid this problem.
<!-- Invisible marker right next to the original add-to-cart button -->
<div
x-data
x-intersect:enter="$store.stickyAddToCart.setOriginalButtonVisible(true)"
x-intersect:leave="$store.stickyAddToCart.setOriginalButtonVisible(false)"
class="h-px w-full"
aria-hidden="true"
></div>
<button type="submit" form="product_addtocart_form" class="btn-primary">
Add to Cart
</button>
The marker is placed directly after the original button, not before it, so the sticky add to cart bar only appears once the button has truly disappeared entirely from the viewport, not already when it is merely partially covered. This positioning is a small detail with a large effect on the perceived timing of the component.
4. Showing and hiding with a transition from below
The sticky add to cart bar itself is slid into view from below using x-show and an x-transition, instead of appearing abruptly. This gentle movement signals to the customer that the bar is a reaction to their scroll, not a randomly appearing element.
fixed bottom-0 combined with a high z-index matters, so the bar sits above other fixed elements like a cookie banner or a header bar without visually covering them. On mobile devices, the bar should additionally respect the safe area at the bottom of the screen, for example with padding-bottom: env(safe-area-inset-bottom).
<div
x-show="!$store.stickyAddToCart.originalButtonVisible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="translate-y-full opacity-0"
x-transition:enter-end="translate-y-0 opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="translate-y-full opacity-0"
class="fixed bottom-0 inset-x-0 z-40 bg-white border-t border-slate-200 shadow-lg"
style="padding-bottom: env(safe-area-inset-bottom);"
x-cloak
>
<div class="flex items-center gap-4 px-4 py-3 max-w-5xl mx-auto">
<img :src="productThumbnail" alt="" class="w-10 h-10 rounded-lg object-cover hidden sm:block">
<p class="font-semibold text-sm text-slate-800 flex-1 truncate" x-text="productName"></p>
<p class="font-bold text-slate-900" x-text="currentPrice"></p>
<button type="button" @click="submitMainForm()" class="btn-primary whitespace-nowrap">
Add to Cart
</button>
</div>
</div>
The duration of 300 milliseconds on show and 200 milliseconds on hide is deliberately asymmetric: appearing is allowed to take a bit longer so it does not feel hectic, while disappearing may happen more quickly, since at that moment the customer has already shifted their attention upward.
5. Syncing quantity and variants between bar and main form
For configurable products with size or color options, the sticky add to cart bar must reflect the same selection state as the main form. Instead of offering a second, independent selection in the bar, which can lead to inconsistencies, the bar reads the state directly from the main form via Alpine.store() and only displays it, without offering its own input option.
For products where quantity should also be changeable from the bar, an x-model on a hidden field in the main form synchronizes both inputs in both directions. This prevents the customer and the form from ending up with different quantity values.
// Main form component keeps the store updated on every relevant change
function mainProductForm() {
return {
qty: 1,
selectedSize: null,
init() {
this.$watch('qty', (value) => { Alpine.store('stickyAddToCart').selectedQty = value; });
this.$watch('selectedSize', (value) => {
Alpine.store('stickyAddToCart').selectedOptions.size = value;
});
}
};
}
// Sticky bar reads qty two-way through the same store property
function stickyBarQtyInput() {
return {
get qty() { return Alpine.store('stickyAddToCart').selectedQty; },
set qty(value) {
Alpine.store('stickyAddToCart').selectedQty = value;
document.querySelector('#product_addtocart_form [name="qty"]').value = value;
}
};
}
This bidirectional getter/setter approach ensures a quantity change in the sticky add to cart bar is immediately reflected in the main form, and vice versa, without an event having to be manually passed through. Alpine handles the reactivity automatically once both components read and write the same store.
6. Submitting: proxy submit on the main form
The sticky add to cart bar should never send its own, second form to the server. Instead, the button in the bar programmatically triggers the submit of the already existing main form, so that all hidden fields, CSRF tokens and variant selectors are sent correctly, without having to be duplicated in the bar.
The HTML form attribute on a button outside the actual <form> element is the cleanest native way to do this, because the browser correctly attributes the submit without extra JavaScript. Only when additional preprocessing is needed, for example validation before submitting, is an explicit submitMainForm() call used.
function submitMainForm() {
const form = document.getElementById('product_addtocart_form');
if (!form) return;
// Trigger native form validation before submitting
if (!form.reportValidity()) {
return;
}
// requestSubmit() respects the form's submit event listeners,
// unlike form.submit() which bypasses them entirely
form.requestSubmit();
}
The difference between form.submit() and form.requestSubmit() is crucial: submit() bypasses every event listener and native validation, while requestSubmit() treats the form exactly as if the customer had clicked the original button. For a sticky add to cart bar that needs to reliably trigger the same logic as the main button, requestSubmit() is therefore the right choice.
7. Performance: x-intersect instead of a custom scroll listener
A naive approach for a sticky add to cart bar listens to the window's scroll event and calculates the position of the original button via getBoundingClientRect() on every call. This forces a layout reflow on every scroll event and can noticeably stutter on weaker devices, especially if additional animations or lazy-loaded images run on the same page.
IntersectionObserver, on which x-intersect is based, works asynchronously and off the main thread for the actual visibility calculation. The browser reports visibility changes instead of the page having to actively query on every pixel of scroll. For a sticky add to cart bar that means noticeably less CPU load while scrolling, which directly affects how smooth the page feels.
// AVOID: scroll listener recalculates layout on every single scroll tick
window.addEventListener('scroll', () => {
const rect = document.getElementById('add-to-cart-marker').getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
Alpine.store('stickyAddToCart').setOriginalButtonVisible(isVisible);
}); // no throttling here forces a reflow on every frame
// PREFER: IntersectionObserver via x-intersect, async and off the scroll thread
// <div x-intersect:leave="..." x-intersect:enter="..."></div>
If a manual scroll listener is nonetheless required in a project for compatibility reasons, throttling with requestAnimationFrame must be added to limit the number of calculations per second. For the vast majority of Hyvä projects, however, x-intersect is the simpler and simultaneously more performant solution for a sticky add to cart bar.
8. Accessibility: focus order and aria-hidden
An invisible sticky add to cart bar must not remain keyboard focusable regardless. As long as the bar is hidden with x-show, Alpine correctly removes the element from the accessibility tree, provided display: none is actually set, which x-show does by default. In addition, aria-hidden="true" should be dynamically bound to the visibility state, so screen readers never announce the bar's content while it is invisible.
Once the bar becomes visible, it should fit seamlessly into the tab order, without pulling the customer out of their current reading flow. Overly aggressive focus management that automatically jumps focus to the bar would be disruptive here rather than helpful, unlike, for example, a modal dialog.
<div
x-show="!$store.stickyAddToCart.originalButtonVisible"
:aria-hidden="$store.stickyAddToCart.originalButtonVisible"
role="region"
aria-label="Quick add to cart"
class="fixed bottom-0 inset-x-0 z-40"
>
<!-- content from section 4 -->
</div>
The combination of role="region" and a descriptive aria-label gives screen reader users a clear context for what this additional area is, without them accidentally mistaking it for the page's main content. These small additions make the sticky add to cart bar a fully accessible part of the page instead of a purely visual add-on.
9. Sticky bar approaches compared
Different technical implementations of a sticky add to cart bar differ significantly in performance, maintainability and user experience.
| Aspect | Unclean approach | Recommended sticky bar pattern | Benefit |
|---|---|---|---|
| Detecting visibility | scroll event + getBoundingClientRect | x-intersect / IntersectionObserver |
No forced reflow on every scroll |
| Submitting the form | Second, own form in the bar | form.requestSubmit() on the main form |
No duplicated logic, same validation |
| Displaying variants | Own, independent selection in the bar | Store-based read-only display | No inconsistency between bar and form |
| Screen reader behavior | Bar stays in the accessibility tree when invisible | Dynamic aria-hidden |
No announcement of invisible content |
In practice, the combination of x-intersect, proxy submit via requestSubmit() and store-based synchronization is the most robust way to implement a sticky add to cart bar that fits seamlessly into existing Hyvä product forms, without duplicating their logic.
Mironsoft
Hyvä theme development and conversion optimization for Magento
A sticky add to cart bar that actually converts?
We build sticky bars, mini cart flyouts and other conversion elements as performant Alpine.js components with IntersectionObserver instead of scroll listeners and full form synchronization.
Sticky bars & flyouts
Performant visibility logic with x-intersect instead of scroll events
Form synchronization
Consistent state between bar and main form via Alpine.store()
Accessibility
Dynamic aria-hidden and correct focus order
10. Summary
A well built sticky add to cart bar rests on four principles: visibility detection via x-intersect instead of a custom scroll listener, a gentle show and hide with x-transition, consistent synchronization of quantity and variants through a shared Alpine store, and a proxy submit with requestSubmit() on the already existing main form instead of a second, redundant form.
Accessibility is not an optional extra here: dynamic aria-hidden, a meaningful role="region" and calm, non-intrusive focus handling make the sticky add to cart bar equally usable for every customer. Whoever cleanly separates these building blocks ends up with a component that measurably contributes to conversion without burdening the product page's performance.
Sticky Add to Cart Bar with Alpine.js — The essentials at a glance
Visibility detection
x-intersect:enter / :leave on a marker next to the original button, no scroll listener needed.
Form synchronization
Shared Alpine.store() for quantity and variants between bar and main form.
Submitting
form.requestSubmit() on the existing main form, no duplicated submit logic.
Accessibility
Dynamic aria-hidden, role="region" and calm focus behavior without autofocus.