Related Products and Cross-Sell Carousels in Hyva Theme
AI generated
Hyvä
phtml
Hyva Theme · Product Recommendations
Related Products and Cross-Sell Carousels in Hyva
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.

14 min read Swiper Alpine Carousel Lazy Loading GraphQL Recommendations Core Web Vitals

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
            }
          }
        }
      }
    }
  }
}

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.

11. FAQ: Cross-Sell Carousels in Hyva: Key Takeaways

1Does a classic JS carousel even fit the Hyva philosophy?
Only with caveats: Hyva stands for minimal JavaScript, so a carousel should be implemented as leanly as possible, with only the modules actually needed. For few products, a static grid is often the better alternative anyway.
2Why should I avoid bundling the full Swiper library?
Because the full bundle includes modules for effects, zoom, and video support that are practically never needed in a product context. A selective import of only the navigation and optionally pagination modules keeps the JS bundle noticeably leaner.
3At what number of recommendations does a carousel pay off over a grid?
As a rule of thumb, from around eight to ten recommendations onward, since a grid with that many products either gets very tall or has to render too small on mobile. Below that, a static, responsive grid is usually the technically cheaper solution.
4Should every carousel image use loading lazy?
No, the first one or two visible slides should load eager or without a loading attribute at all. Lazy only makes sense from the third slide onward, otherwise rendering of the initially visible images gets needlessly delayed.
5Where does the data for related products and cross-sell in the carousel come from?
Through the products GraphQL query with its nested related_products and crosssell_products fields. It matters to query only the fields actually needed for the carousel, to keep the query lean.
6How do I ensure keyboard operability in the carousel?
Through Swiper's own keyboard module, which needs explicit activation, plus real button elements with an aria-label for navigation instead of plain div clicks. Without these measures, the carousel remains effectively unusable for keyboard users.
7What happens to the carousel if JavaScript fails to load?
Without a deliberate fallback strategy, customers see an unstyled, horizontally strung out list with no structure. A grid as baseline markup, only turned into a carousel by Alpine and Swiper, reliably prevents this problem.
8Does Swiper's own lazy loading collide with native browser lazy loading?
It can, which is why the native loading lazy approach without an extra Swiper lazy module is usually easier to maintain in practice. Enabling both mechanisms at once adds unnecessary complexity with no real added benefit.
9Why shouldn't I always show the navigation arrows?
Because with fewer products than fit into the visible area, they read as broken, non-functional UI elements. Checking the product count against the configured slidesPerView setting automatically hides the arrows in that case.
10How do I avoid layout shifts while carousel images load?
Through explicit width and height attributes on every image, so the browser reserves the needed space before the image actually loads. Without these, the rest of the page content visibly shifts as images finish loading.