How to Build Custom Components in Hyvä Using Alpine.js and Tailwind

CodeAtoZ Engineer, CodeAtoZ
10 min read · 1944 words
Share: 𝕏 in
HYVÄ · ALPINE.JS · TAILWIND · SERIES PART 3 OF 4

How We Build Custom Components in Hyvä Using Alpine.js and Tailwind CSS

Real component code from production Hyvä stores — gallery, accordion, cart button, sticky header, and event dispatch.

5components built
Alpinev3.x syntax
Tailwindv3 classes
Prodtested code

Building Hyvä custom components is the part of Hyvä development that clicks everything into place. Once you understand how Alpine.js and Tailwind CSS work together, you can build any Hyvä custom component fast, clean, and with far less code than the KnockoutJS equivalent would have required.

Every Hyvä custom component in this guide is real code from production stores. No toy examples. The patterns here are what we actually ship.

Hyvä custom components with Alpine.js and Tailwind CSS — CodeAtoZ guide
5 production-ready Hyvä custom components — Alpine.js patterns used on real stores

Alpine.js in Hyvä — Core Concepts for Custom Components

Every Hyvä custom component uses Alpine.js — Hyvä replaces Magento’s KnockoutJS and RequireJS entirely with this lightweight reactive framework (about 15KB). You write Hyvä custom components directly in HTML attributes with no separate JS files needed.

The key Alpine directives you’ll use constantly:

  • x-data — defines a component’s reactive data scope
  • x-show / x-if — conditionally show or render elements
  • @click / @keydown — event listeners
  • :class / :style — dynamic bindings
  • x-text / x-html — output data into elements
  • x-transition — CSS transition helpers
  • $dispatch — fire custom events to other components
  • $store — access global Alpine stores (Hyvä uses these extensively)

Thumbnail switcher with active state

A common product page requirement — clicking a thumbnail updates the main image with an active border on the selected thumb.

<!-- In your product/view/media.phtml -->
<div x-data="{
    activeIndex: 0,
    images: = /* @noEscape */ $block->getGalleryImagesJson() ?>,
    selectImage(index) {
        this.activeIndex = index;
    }
}" class="flex flex-col gap-4">

    <!-- Main image -->
    <div class="aspect-square overflow-hidden rounded-lg">
        <img
            :src="images[activeIndex].full"
            :alt="images[activeIndex].caption"
            class="w-full h-full object-cover transition-opacity duration-200"
        />
    </div>

    <!-- Thumbnails -->
    <div class="flex gap-2 overflow-x-auto">
        <template x-for="(image, index) in images" :key="index">
            <button
                @click="selectImage(index)"
                class="flex-shrink-0 w-16 h-16 rounded border-2 overflow-hidden transition-colors duration-150"
                :class="activeIndex === index
                    ? 'border-green-600'
                    : 'border-gray-200 hover:border-gray-400'"
            >
                <img :src="image.thumb" :alt="image.caption" class="w-full h-full object-cover"/>
            </button>
        </template>
    </div>
</div>

The x-data attribute defines the component scope. activeIndex is the reactive state — when it changes, Alpine automatically updates every binding that references it. No manual DOM manipulation needed.

Component 2 — Accordion / FAQ

Animated accordion with x-show and x-transition

Clean accordion that closes other items when one is opened. Works for FAQs, product description tabs, category filters.

<div x-data="{ openItem: null }" class="divide-y divide-gray-200">

    <?php foreach ($items as $index => $item): ?>
    <div>
        <button
            @click="openItem = openItem === <?= $index ?> ? null : <?= $index ?>"
            class="flex w-full items-center justify-between py-4 text-left"
        >
            <span class="font-medium text-gray-900">
                <?= $escaper->escapeHtml($item->getTitle()) ?>
            </span>
            <svg
                class="h-5 w-5 text-gray-500 transition-transform duration-200"
                :class="openItem === <?= $index ?> ? 'rotate-180' : ''"
                fill="none" viewBox="0 0 24 24" stroke="currentColor"
            >
                <path stroke-linecap="round" stroke-linejoin="round"
                      stroke-width="2" d="M19 9l-7 7-7-7"/>
            </svg>
        </button>

        <div
            x-show="openItem === <?= $index ?>"
            x-transition:enter="transition ease-out duration-200"
            x-transition:enter-start="opacity-0 -translate-y-2"
            x-transition:enter-end="opacity-100 translate-y-0"
            x-transition:leave="transition ease-in duration-150"
            x-transition:leave-start="opacity-100 translate-y-0"
            x-transition:leave-end="opacity-0 -translate-y-2"
            class="pb-4 text-gray-600"
        >
            <?= $block->getLayout()->createBlock('...')->toHtml() ?>
        </div>
    </div>
    <?php endforeach; ?>
</div>

x-transition gives you smooth animations without writing a single line of CSS animation code. The openItem pattern ensures only one item is open at a time — when you click an open item it closes (sets to null), otherwise it opens the clicked item.

Component 3 — Ajax Add to Cart with Loading State

Cart button with loading spinner and success feedback

Hyvä’s $store.cart handles the cart state globally. This component adds a product and shows appropriate feedback states.

<div
    x-data="{
        loading: false,
        added: false,
        async addToCart(productId, qty) {
            this.loading = true;
            try {
                await $store.cart.addToCart({
                    product: productId,
                    qty: qty
                });
                this.added = true;
                setTimeout(() => this.added = false, 2000);
            } finally {
                this.loading = false;
            }
        }
    }"
>
    <button
        @click="addToCart(<?= $productId ?>, 1)"
        :disabled="loading"
        class="w-full py-3 px-6 rounded-lg font-semibold transition-all duration-200
               disabled:opacity-60 disabled:cursor-not-allowed"
        :class="{
            'bg-green-600 hover:bg-green-700 text-white': !added,
            'bg-green-500 text-white': added
        }"
    >
        <!-- Loading spinner -->
        <span x-show="loading" class="inline-flex items-center gap-2">
            <svg class="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
                <circle class="opacity-25" cx="12" cy="12" r="10"
                        stroke="currentColor" stroke-width="4"/>
                <path class="opacity-75" fill="currentColor"
                      d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
            </svg>
            Adding...
        </span>
        <!-- Added state -->
        <span x-show="added && !loading">Added to Cart ✓</span>
        <!-- Default state -->
        <span x-show="!loading && !added">Add to Cart</span>
    </button>
</div>

Component 4 — Sticky Header with Scroll Detection

Header that changes style on scroll using @scroll.window

<header
    x-data="{ scrolled: false }"
    @scroll.window="scrolled = window.scrollY > 60"
    class="fixed top-0 left-0 right-0 z-50 transition-all duration-300"
    :class="scrolled
        ? 'bg-white shadow-md py-2'
        : 'bg-transparent py-4'"
>
    <div class="container mx-auto px-4 flex items-center justify-between">
        <a href="/" class="font-bold text-xl"
           :class="scrolled ? 'text-gray-900' : 'text-white'">
            <?= $block->getLogoHtml() ?>
        </a>
        <nav :class="scrolled ? 'text-gray-700' : 'text-white'">
            <!-- Navigation items -->
        </nav>
    </div>
</header>

The @scroll.window modifier attaches a scroll listener to the window object. Alpine debounces this automatically. The scrolled boolean toggles all your style bindings cleanly — no JavaScript classes being added and removed manually.

Component 5 — Cross-Component Communication with $dispatch

Firing and listening to custom events between components

When two components need to communicate — like a filter sidebar updating a product grid — use $dispatch to fire events and @event-name.window to listen.

<!-- Filter sidebar — dispatches when filter changes -->
<div x-data="{ selectedCategory: null }">
    <button
        @click="
            selectedCategory = 'bags';
            $dispatch('filter-changed', { category: selectedCategory })
        "
        class="px-4 py-2 rounded border"
        :class="selectedCategory === 'bags' ? 'bg-green-600 text-white border-green-600' : ''"
    >
        Bags
    </button>
</div>

<!-- Product grid — listens for filter events -->
<div
    x-data="{
        activeFilter: null,
        products: initialProducts,
        filterProducts(category) {
            this.activeFilter = category;
            // trigger your Ajax product reload here
        }
    }"
    @filter-changed.window="filterProducts($event.detail.category)"
>
    <template x-for="product in products" :key="product.id">
        <!-- product card -->
    </template>
</div>

Custom events dispatched with $dispatch bubble up to the window, so any component listening with @event-name.window will receive them regardless of where it is in the DOM. This is how Hyvä keeps components decoupled without a central state manager.

Tailwind CSS Patterns for Every Hyvä Custom Component

A few Tailwind CSS patterns that come up in almost every Hyvä custom component we build:

  • container mx-auto px-4 — standard page wrapper with horizontal padding
  • aspect-square / aspect-video — consistent image ratio without JS
  • line-clamp-3 — truncate product description to 3 lines
  • @apply in your CSS for repeating component classes (keep templates clean)
  • group + group-hover: — parent hover affecting child elements
  • md:grid-cols-3 lg:grid-cols-4 — responsive product grid in one line

One habit that makes Hyvä custom components much cleaner: extract longer Alpine component definitions into a hyva-components.js file in your theme, and reference them by function name in x-data="componentName()". This keeps Hyvä custom components readable and reusable.

Further Reading — Official Documentation

The official references for every tool used in building Hyvä custom components:

  • Alpine.js full documentation: alpinejs.dev — every directive, magic property and plugin reference
  • Tailwind CSS utility reference: tailwindcss.com — the utility-first CSS framework powering all Hyvä components
  • Official Hyvä developer guide: docs.hyva.io — Hyvä-specific Alpine.js integration patterns

Need Help Building Hyvä Custom Components?

Whether you’re migrating an existing Magento store or need help with Hyvä custom components from scratch, we handle the full frontend — clean Alpine.js Hyvä custom components, performant Tailwind CSS, and production-ready code.

Talk to a Hyvä Developer →

CodeAtoZ
Lead Engineer & Founder, CodeAtoZ

10+ years building Magento 2 and WooCommerce platforms for high-volume e-commerce businesses. Writes about performance engineering, plugin architecture, and scalable commerce systems.

Join the Discussion

Your email address will not be published. Required fields are marked *