Alpine-based carousels without heavy JS libraries, with lazy loading and GraphQL
A carousel for related products is quick to wire up with a heavy JS library and just as quick to become a performance problem. Staying true to Hyva means building the carousel with Alpine and a minimal Swiper integration, loading images consistently lazy, and honestly asking whether a static grid might not be the better choice after all.
Table of Contents
- 1. Why carousels deserve caution in a Hyva context
- 2. Integrating Swiper minimally instead of in full
- 3. The Alpine markup for a product carousel
- 4. Getting carousel image lazy loading right
- 5. A GraphQL query for product recommendations
- 6. Performance trade-off: carousel versus static grid
- 7. Multiple carousels on one page: watching the performance budget
- 8. Accessibility and keyboard operation in the carousel
- 9. Common mistakes with cross-sell carousels
- 10. Summary
- 11. FAQ
1. Why carousels deserve caution in a Hyva context
Hyva stands for a deliberately lean frontend without unnecessary JavaScript weight, and a classic carousel with autoplay, arrows, dots, and touch gestures sits in some tension with that from the start. Many carousel libraries ship several hundred kilobytes of JavaScript, often including their own CSS engine, which runs directly counter to the core idea of a performant theme.
Still, there are legitimate use cases: with many related products or cross-sell suggestions on narrow viewports, a horizontal scroll carousel is often the most space efficient presentation, clearly better than a cramped grid with tiny product cards. The key is implementing a carousel as leanly as possible instead of pulling in a full featured library with functionality that never gets used in a product context anyway.
2. Integrating Swiper minimally instead of in full
Swiper is comparatively lean and modular among carousel libraries, but it matters to import only the modules actually needed instead of loading the full bundle with effects, zoom, and video support. For a plain product carousel, basic navigation and optionally pagination are usually enough, everything else needlessly bloats the JS bundle.
Integration works best as a standalone module through the existing Hyva webpack or Vite build process, not as a global script pulled from an external CDN link, since the latter complicates the content security policy and loses control over the version actually shipped. Alpine is only responsible for showing and hiding the navigation and tracking the current state, while Swiper itself handles the actual scroll and touch logic.
// web/js/product-carousel.js
import Swiper from 'swiper';
import { Navigation } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/navigation';
export default function productCarousel(config) {
return {
swiper: null,
init() {
this.swiper = new Swiper(this.$refs.carousel, {
modules: [Navigation],
slidesPerView: 1.3,
spaceBetween: 16,
navigation: {
nextEl: this.$refs.next,
prevEl: this.$refs.prev,
},
breakpoints: {
640: { slidesPerView: 2.3 },
1024: { slidesPerView: 4 },
},
});
},
};
}
3. The Alpine markup for a product carousel
The phtml template itself stays deliberately close to the DOM structure Swiper expects, so no extra translation layer is needed between Magento product data and the library. The product cards inside the slides are identical to the ones in the regular product grid, ideally pulled in through the same partial, so the compare button, wishlist button, and price display do not need to be maintained twice.
The navigation arrows should only appear when there are actually more products than fit into the visible area, otherwise they read as broken, non-functional UI elements. This check can simply be expressed by comparing the number of loaded products against the configured slidesPerView setting in Alpine state.
<div
x-data="productCarousel()"
x-init="init()"
class="relative"
>
<div class="swiper" x-ref="carousel">
<div class="swiper-wrapper">
<template x-for="product in products" :key="product.uid">
<div class="swiper-slide">
<!-- Reused product card -->
<div class="product-item">
<img
:src="product.thumbnail"
:alt="product.name"
loading="lazy"
width="240"
height="240"
class="w-full h-auto"
>
<span x-text="product.name"></span>
</div>
</div>
</template>
</div>
</div>
<button x-ref="prev" x-show="products.length > 4" aria-label="Previous products">‹</button>
<button x-ref="next" x-show="products.length > 4" aria-label="More products">›</button>
</div>
4. Getting carousel image lazy loading right
A common mistake with carousel images is applying the native loading lazy attribute unconditionally to every slide, including the first one or two visible images. Browsers do load loading lazy images inside the initial viewport fairly quickly, but rendering is measurably delayed compared to an eagerly loaded image, which can hurt Largest Contentful Paint precisely for above the fold carousels.
The robust solution is loading the first one or two slides eager, or with no loading attribute at all, and only switching to lazy from the third slide onward, combined with explicit width and height attributes to avoid layout shifts on load. Swiper itself ships its own lazy loading mechanism, but it can collide with native browser lazy loading, which is why the native approach without an extra Swiper module is usually easier to maintain in practice.
5. A GraphQL query for product recommendations
For related products and cross-sell suggestions, the products query with its nested related_products and crosssell_products fields already provides everything needed, without requiring an extra REST call. It matters to query only the fields actually rendered in the carousel, meaning name, image, price, and URL, instead of accidentally pulling in the full product description or every attribute.
For very large categories with dynamically computed recommendations, for example through a third party recommendation module, it also pays off to cache the recommendation list per product server side, since the computation itself can be more resource intensive than a simple attribute lookup. A cache that expires too quickly otherwise forces every product page view to rerun the entire recommendation logic.
query CrossSellProducts($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
crosssell_products {
uid
name
url_key
small_image {
url
}
price_range {
minimum_price {
final_price {
value
currency
}
}
}
}
}
}
}
6. Performance trade-off: carousel versus static grid
A static grid with no JavaScript dependency wins on practically every metric compared to a carousel: no extra JS library, no layout calculation from a carousel engine, no touch event listeners staying active in the background. For up to four to six product recommendations, a responsive CSS grid that wraps on smaller viewports is therefore often the better technical decision, even if a carousel looks more modern visually.
Beyond about eight to ten recommendations, the trade-off tends to favor a carousel, since a grid with that many products either gets very tall or has to render uncomfortably small on mobile devices. The pragmatic rule of thumb is: check first whether a grid with a sensible column count still works across every breakpoint, and only reach for a carousel once that check genuinely fails.
7. Multiple carousels on one page: watching the performance budget
A single product page can quickly end up with two or three carousels at once: related products, cross-sell further down, and recently viewed products on top of that. Every one of these instances spins up its own Swiper instance with its own event listeners, which adds up fast if built without care and noticeably delays the page's interactivity shortly after load.
The pragmatic approach is initializing carousels only once they actually scroll into the visible area, instead of starting all of them immediately on page build. An IntersectionObserver, wired into Alpine through x-intersect or a lean custom directive, defers Swiper instantiation until it is genuinely needed, keeping the page's initial JavaScript execution time low even with several carousels present on the same page.
// Only initialize the carousel once it scrolls into the viewport
function lazyCarousel() {
return {
initialized: false,
observeAndInit() {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.initialized) {
this.initialized = true;
this.initSwiper();
observer.disconnect();
}
}, { rootMargin: '200px' });
observer.observe(this.$el);
},
};
}
8. Accessibility and keyboard operation in the carousel
A carousel operable only by mouse or touch effectively excludes keyboard users. Swiper supports keyboard navigation through its own keyboard module, which needs to be activated, and the navigation buttons should be real button elements with a meaningful aria-label instead of plain div clicks with no semantic meaning.
For screen reader users, an aria-live region that announces the current position on slide change, for example product three of eight, is also recommended, along with a role of region and an aria-label on the carousel container itself, so the structure is clearly recognizable to assistive technology instead of being interpreted as an arbitrary list with no context.
9. Common mistakes with cross-sell carousels
The most common mistake is pulling in a full carousel library with every module unchecked, even though a product context only needs basic navigation. That leads to an unnecessarily bloated JS bundle that directly hurts Core Web Vitals metrics, particularly Total Blocking Time on slower mobile devices.
A second, often overlooked issue is the lack of any fallback display when JavaScript fails to load for whatever reason, such as a CSP violation or a blocked script. Without a fallback, customers see an unstyled, horizontally strung out list with no structure at all, instead of at least getting a simple, functioning grid as a baseline experience.
| Criterion | Static Grid | Alpine + Swiper Carousel | Recommendation |
|---|---|---|---|
| JS bundle size | No extra load | Extra but lean module | Only when space is genuinely tight |
| Number of recommendations | Good up to 4-6 products | Sensible from 8-10 products | Rule of thumb by product count |
| Keyboard operation | Native via browser | Requires the keyboard module | Activate the module |
| Layout stability | No CLS risk | Risk without width/height | Always set explicit image dimensions |
| Fallback without JS | Always works | Requires a deliberate fallback strategy | Use a grid as baseline markup |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
Cross-Sell Carousels in Hyva: Key Takeaways
Keep it lean
Import only the Swiper modules actually needed instead of the full library with every effect.
Grid first, carousel second
Up to four to six recommendations, a static grid is usually the technically better choice.
Use lazy loading deliberately
Load the first one or two slides eager, only lazy from there on, to protect LCP.
Do not skip accessibility
Keyboard module, aria-label, and aria-live announcements belong in every production carousel.