A cart flyout without page reload
A mini cart preview shows the customer instantly what is in the cart, without leaving the product page. With Alpine.js this flyout can be built as a lean x-data component that catches section data events, changes quantities live and does all of this without jQuery or extra libraries.
Table of contents
- 1. Why a mini cart preview makes the difference
- 2. Foundation: x-data state for the cart
- 3. Loading cart data: combining section data and GraphQL
- 4. Catching the add-to-cart event and updating the icon
- 5. Flyout positioning and transitions
- 6. Rendering mini cart items with x-for
- 7. Changing quantity and removing items in the flyout
- 8. Focus management and ARIA live region
- 9. Mini cart patterns compared
- 10. Summary
- 11. FAQ
1. Why a mini cart preview makes the difference
A mini cart preview is the small flyout that appears when the customer clicks or hovers over the cart icon, showing the recently added items, the subtotal and a checkout link. Without this preview, the customer either has to switch to the full cart page after every add-to-cart click, or keep shopping blindly without knowing whether the item really landed in the cart. Especially in Magento shops running the Hyvä theme, where heavy JavaScript frameworks are deliberately avoided, a lightweight, reactive mini cart is a direct lever for conversion and perceived speed.
Classic Luma themes solved this problem through Knockout.js and Magento's customer data section mechanism, which reloads several sections on every page view regardless of whether they are actually needed. In Hyvä, Alpine.js takes over this job in a much leaner way: the mini cart preview is defined as a standalone x-data component, reacts to a single custom event fired after the add-to-cart request, and only loads the cart data that is actually required. The result is a flyout that feels like a single page application, without needing a full SPA architecture.
The following sections build a complete mini cart component step by step: from state design through loading cart data to changing quantities directly in the flyout and accessible focus control. Every code block is written so it can be dropped straight into a Hyvä layout handle like default.xml or a dedicated minicart.phtml.
2. Foundation: x-data state for the cart
The starting point of every mini cart preview is a cleanly structured state. Instead of loose individual variables, items, quantity, subtotal and loading state are bundled into one object registered through x-data. It is important that this object is defined as a named Alpine component so it can be addressed from several places in the DOM at once, for example the header icon and the flyout panel simultaneously.
For state shared globally across several components, Alpine.store() is the right choice. The cart icon in the header and the flyout panel in the header area need to know the same cart state, without data being tediously passed between components. A global store turns the mini cart into a single source of truth that any component on the page can read.
// Global Alpine store for the mini cart state
document.addEventListener('alpine:init', () => {
Alpine.store('miniCart', {
items: [],
itemCount: 0,
subtotal: '$0.00',
isOpen: false,
isLoading: false,
// Toggle the flyout panel open/closed
toggle() {
this.isOpen = !this.isOpen;
if (this.isOpen) {
this.refresh();
}
},
close() {
this.isOpen = false;
},
// Replace the whole cart state after a fetch
setData(data) {
this.items = data.items;
this.itemCount = data.itemCount;
this.subtotal = data.subtotal;
this.isLoading = false;
}
});
});
This structure deliberately separates presentation from state. The mini cart component in the markup only calls $store.miniCart.toggle() or reads $store.miniCart.items, without containing any fetch logic or calculations itself. That makes testing easier and keeps the store swappable, in case additional channels such as a sticky add-to-cart bar later need to read the same state.
3. Loading cart data: combining section data and GraphQL
Magento keeps cart data on the server side in sections retrieved through the customer/section/load endpoint, or alternatively through a GraphQL query against the cart type. For a performant mini cart preview in Hyvä, GraphQL is usually the cleaner choice, because the query can be restricted exactly to the fields the flyout actually needs: item name, image, quantity, row total and grand total.
The trick is not to run the refresh call on every page view, but specifically when the cart has actually changed. That reduces unnecessary requests while still keeping the mini cart up to date the moment the customer opens it or an add-to-cart event arrives.
Alpine.store('miniCart').refresh = async function () {
this.isLoading = true;
const query = `
query MiniCart($cartId: String!) {
cart(cart_id: $cartId) {
total_quantity
items { id quantity product { name thumbnail { url } } prices { row_total { value } } }
prices { grand_total { value currency } }
}
}
`;
const cartId = window.localStorage.getItem('mage-cart-id');
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { cartId } })
});
const { data } = await response.json();
this.setData({
items: data.cart.items,
itemCount: data.cart.total_quantity,
subtotal: new Intl.NumberFormat('en-US', { style: 'currency', currency: data.cart.prices.grand_total.currency })
.format(data.cart.prices.grand_total.value)
});
};
Anyone already using Hyvä GraphQL endpoints for checkout can reuse the same query structure and does not need to maintain a second REST interface. The mini cart preview benefits from the fact that GraphQL returns exactly the requested fields, saving server load especially with many concurrent customers.
4. Catching the add-to-cart event and updating the icon
For the mini cart to update automatically after an add-to-cart click, the add-to-cart form must fire a custom event as soon as the server response arrives. In Hyvä this typically happens through window.dispatchEvent() directly inside the form handler once the fetch request has succeeded. The icon in the header listens for exactly this event and calls the store refresh.
This decoupling through events is a central Alpine.js pattern: the form component does not know the mini cart directly, it only communicates through an event that any number of other components can react to, for example a sticky add-to-cart bar or a success notification.
// Add-to-cart form component
function addToCartForm() {
return {
isSubmitting: false,
async submit(event) {
event.preventDefault();
this.isSubmitting = true;
const formData = new FormData(event.target);
const response = await fetch(event.target.action, { method: 'POST', body: formData });
this.isSubmitting = false;
if (response.ok) {
// Notify every component listening for cart changes
window.dispatchEvent(new CustomEvent('cart-updated', {
detail: { quantity: formData.get('qty') }
}));
}
}
};
}
// Header icon listens globally, independent of where the form lives
document.addEventListener('cart-updated', () => {
Alpine.store('miniCart').refresh();
Alpine.store('miniCart').isOpen = true;
});
A common mistake here: developers call refresh directly from the form component instead of going through a global event. That works at first, but breaks as soon as a second form exists on the same page, for example in a product listing with several add-to-cart buttons. The event pattern keeps the mini cart preview independent of the number and position of forms on the page.
5. Flyout positioning and transitions
The visual part of the mini cart is an absolutely positioned panel shown and hidden with x-show and x-transition. It is important not to let the panel appear abruptly, but with a short transition that signals to the customer that something just happened, without feeling intrusive.
x-anchor from the official Alpine anchor plugin is an elegant solution for automatically positioning the flyout panel relative to the cart icon, even when the icon position shifts across responsive breakpoints. Without this plugin, simple absolute right-0 on a relative container is usually enough in most layouts.
<div x-data class="relative">
<button
@click="$store.miniCart.toggle()"
class="relative p-2"
aria-haspopup="true"
:aria-expanded="$store.miniCart.isOpen"
>
<span class="sr-only">Open cart</span>
<svg class="w-6 h-6" aria-hidden="true"><!-- cart icon --></svg>
<span
x-show="$store.miniCart.itemCount > 0"
x-text="$store.miniCart.itemCount"
class="absolute -top-1 -right-1 bg-teal-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
></span>
</button>
<div
x-show="$store.miniCart.isOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-end="opacity-0"
@click.outside="$store.miniCart.close()"
@keydown.escape.window="$store.miniCart.close()"
class="absolute right-0 mt-2 w-96 bg-white rounded-2xl shadow-xl border border-slate-200 z-40"
x-cloak
>
<!-- items partial from section 6 -->
</div>
</div>
The @click.outside modifier and @keydown.escape.window are not optional for a good mini cart. Without them the flyout stays open when the customer clicks elsewhere or presses escape, which over time is perceived as annoying. x-cloak additionally prevents the brief flash of the panel during initial page load, before Alpine has evaluated the directives.
6. Rendering mini cart items with x-for
The actual item list in the flyout is rendered with x-for over the store's items array. Every item needs a stable :key, ideally the cart item ID, so Alpine reuses existing DOM nodes when updating the list instead of recreating them entirely.
For a calm mini cart preview it is worth defining an explicit empty state shown when itemCount is zero. Without this state the customer only sees an empty panel and has to guess whether the cart is really empty or a loading error just occurred.
<div class="p-4 max-h-96 overflow-y-auto">
<template x-if="$store.miniCart.isLoading">
<p class="text-sm text-slate-500 py-8 text-center">Updating cart …</p>
</template>
<template x-if="!$store.miniCart.isLoading && $store.miniCart.itemCount === 0">
<p class="text-sm text-slate-500 py-8 text-center">Your cart is still empty.</p>
</template>
<template x-for="item in $store.miniCart.items" :key="item.id">
<div class="flex gap-3 py-3 border-b border-slate-100 last:border-0">
<img :src="item.product.thumbnail.url" :alt="item.product.name" class="w-14 h-14 rounded-lg object-cover flex-shrink-0" loading="lazy">
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 truncate" x-text="item.product.name"></p>
<p class="text-xs text-slate-500" x-text="'Qty: ' + item.quantity"></p>
</div>
<p class="text-sm font-semibold text-slate-800" x-text="'$' + item.prices.row_total.value"></p>
</div>
</template>
</div>
Using loading="lazy" on the product images pays off once more than three or four items are shown in the flyout. The mini cart then only loads images that are actually visible, which noticeably shortens the flyout's loading time especially on mobile connections.
7. Changing quantity and removing items in the flyout
A truly useful mini cart panel lets the customer change the quantity directly in the flyout, without having to switch to the full cart page. For this a updateQuantity method is added to the store per row, using a debounce so a request is not fired on every single click of the stepper.
For removing an item, a simple REST or GraphQL mutation call followed by another refresh() is enough. A short visual transition matters here too, for example fading out the row with x-transition, so the removal does not feel abrupt and the customer consciously notices the change.
Alpine.store('miniCart').pendingUpdate = null;
Alpine.store('miniCart').updateQuantity = function (itemId, quantity) {
clearTimeout(this.pendingUpdate);
this.pendingUpdate = setTimeout(async () => {
await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `mutation UpdateCartItem($cartId: String!, $itemId: Int!, $qty: Float!) {
updateCartItems(input: { cart_id: $cartId, cart_items: [{ cart_item_id: $itemId, quantity: $qty }] }) {
cart { total_quantity }
}
}`,
variables: { cartId: window.localStorage.getItem('mage-cart-id'), itemId, qty: quantity }
})
});
this.refresh();
}, 400);
};
Alpine.store('miniCart').removeItem = async function (itemId) {
await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `mutation RemoveCartItem($cartId: String!, $itemId: Int!) {
removeItemFromCart(input: { cart_id: $cartId, cart_item_id: $itemId }) { cart { total_quantity } }
}`,
variables: { cartId: window.localStorage.getItem('mage-cart-id'), itemId }
})
});
this.refresh();
};
A debounce of 400 milliseconds has proven to be a good compromise in practice: short enough that the mini cart does not feel sluggish, but long enough to bundle several fast clicks on the stepper into a single request. Without a debounce, every click produces a separate request, and these can overtake each other in an unfavorable order, leading to an incorrectly displayed quantity.
8. Focus management and ARIA live region
A mini cart preview that only works visually excludes customers using a screen reader. Two additions make the flyout accessible: first, focus must jump to the first interactive element when the panel opens, and back to the triggering button when it closes. Second, the count update on the icon needs an aria-live region so screen readers announce the change automatically, without the customer having to manually navigate to the icon.
The combination of aria-haspopup, aria-expanded and a correctly set role="status" region is the difference between a mini cart that only convinces visually, and one that is also reliably usable with keyboard and screen reader.
<div
x-data
x-effect="if ($store.miniCart.isOpen) { $refs.firstFocusable?.focus() }"
>
<!-- visually hidden live region, announces cart count changes -->
<div class="sr-only" role="status" aria-live="polite" x-text="'Cart: ' + $store.miniCart.itemCount + ' items'"></div>
<div x-show="$store.miniCart.isOpen" role="dialog" aria-label="Cart preview">
<a x-ref="firstFocusable" href="/checkout/cart" class="block p-2">Go to cart</a>
</div>
</div>
This x-effect statement reacts automatically as soon as isOpen changes and sets the focus without an extra event listener. For Hyvä projects there is one more rule: after every inline <script> block, $hyvaCsp->registerInlineScript() must appear in the .phtml template, so the content security policy does not block the Alpine code. Without this call the mini cart works locally in development mode but fails in production once CSP is enforced.
9. Mini cart patterns compared
There are several technical ways to implement a mini cart in a Magento shop. The choice directly affects load time, server load and how natural the flyout feels.
| Task | Drawback | Recommended mini cart pattern | Benefit |
|---|---|---|---|
| Refreshing cart data | Full page reload | Alpine.store() + GraphQL refresh() | No page reload, targeted refresh timing |
| Propagating cart changes | Direct method call between components | Custom event cart-updated |
Decoupled, works with any number of forms |
| Quantity change | Request on every click | Debounce with setTimeout |
Fewer requests, correct ordering |
| Panel positioning | Fixed pixel coordinates | relative/absolute or x-anchor |
Stays correctly positioned across breakpoints |
| Screen reader announcement | No announcement on counter update | role="status" aria-live="polite" |
Automatic announcement without manual navigation |
In practice, the combination of a global Alpine.store(), targeted GraphQL refresh and event-based communication performs best. It keeps the mini cart preview lightweight while not giving up comfort features like quantity changes or live announcements, and stays entirely within what Hyvä already brings along in tooling.
Mironsoft
Hyvä theme development and Alpine.js components for Magento
A mini cart that actually helps customers?
We build cart flyouts, sticky add-to-cart bars and other Hyvä UI patterns as clean, maintainable Alpine.js components, with GraphQL integration and accessible focus management.
Mini cart & flyouts
Cart preview, quantity changes and live updates without page reload
Accessibility
Focus management, ARIA live regions and keyboard support to WCAG
GraphQL integration
Lean queries instead of heavy section data reloads
10. Summary
A successful mini cart preview in Hyvä consists of a few clearly separated building blocks: a global Alpine.store() for the cart state, a custom event that decouples forms and panel, targeted GraphQL loading instead of heavy section data reloads, and clean quantity changes with debounce directly in the flyout. None of these building blocks require an additional JavaScript library, everything runs with what Alpine.js and Hyvä already bring along.
The often underestimated part is accessibility: focus management on open and close, plus an aria-live region for the item count, cost little extra code but make the mini cart truly usable for screen reader users. Whoever thinks about these points from the start saves later rework and builds a cart flyout that is both fast and accessible to every customer.
Mini Cart Preview with Alpine.js — The essentials at a glance
State design
Alpine.store('miniCart') bundles items, count and loading state as one shared source of truth.
Data binding
Targeted GraphQL query instead of a section data reload, triggered through a global cart-updated event.
Interaction
Quantity change with debounce, smooth transitions, closing on outside click or escape.
Accessibility
Focus management with x-effect, aria-live region and correct ARIA attributes on the trigger.