PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Hyvä Theme – architecture, Alpine.js, Tailwind, PHTML templates, events

by Henryk Tews / Monday, 22 January 2024 / Published in Magento 2

Hyvä Theme has become the default choice for new Magento 2 projects. It replaces the Knockout.js and RequireJS stack with Alpine.js and Tailwind CSS, keeping templates in standard PHP/PHTML with no build step for JavaScript. The result is dramatically simpler frontend code and Lighthouse scores that Luma could never reach. I show the architecture and how development actually differs from Luma.

Why Hyvä – the problem with Luma

Luma’s JavaScript architecture was designed in 2015 for a different web:

  • RequireJS with hundreds of modules = slow JS loading
  • Knockout.js for UI = heavy runtime, hard to debug
  • CSS build required (LESS compiler) = slow development cycle
  • Score on Lighthouse: typically 20-40 on mobile out of the box

Hyvä’s architecture:

  • Alpine.js (15KB gzipped) – reactive UI with simple x-data directives
  • Tailwind CSS with JIT compiler – build generates only used classes
  • No RequireJS – plain ES modules or inline JS where needed
  • PHTML templates with PHP directly – the same template language as before
  • Score on Lighthouse: typically 80-95+ on mobile

Template structure – PHTML with Alpine.js

<?php
// Hyvä product listing item - the difference from Luma is striking
/** @var \Magento\Catalog\Block\Product\ListProduct $block */
/** @var \Magento\Catalog\Model\Product $product */
/** @var \Magento\Framework\Escaper $escaper */
?>
<div class="card flex flex-col"
     x-data="initAddToCart()"
     x-id="['qty-counter']">

    <!-- Product image -->
    <a href="<?= $escaper->escapeUrl($product->getProductUrl()) ?>"
       class="block relative overflow-hidden">
        <img src="<?= $escaper->escapeUrl($block->getImage($product, 'category_page_grid')->getImageUrl()) ?>"
             alt="<?= $escaper->escapeHtmlAttr($product->getName()) ?>"
             class="w-full h-48 object-cover hover:scale-105 transition-transform duration-300"
             loading="lazy" />
    </a>

    <!-- Product info -->
    <div class="p-4 flex flex-col flex-1">
        <h2 class="text-lg font-medium mb-2">
            <a href="<?= $escaper->escapeUrl($product->getProductUrl()) ?>">
                <?= $escaper->escapeHtml($product->getName()) ?>
            </a>
        </h2>

        <p class="text-primary text-xl font-bold mb-4">
            <?= $block->getProductPrice($product) ?>
        </p>

        <!-- Qty counter with Alpine.js -->
        <div class="flex items-center gap-2 mb-4">
            <button @click="qty = Math.max(1, qty - 1)"
                    :disabled="qty <= 1"
                    class="btn btn-secondary w-8 h-8 flex items-center justify-center">-</button>
            <input :id="$id('qty-counter')"
                   x-model="qty"
                   type="number" min="1"
                   class="w-16 text-center border rounded">
            <button @click="qty++"
                    class="btn btn-secondary w-8 h-8 flex items-center justify-center">+</button>
        </div>

        <!-- Add to cart -->
        <button @click="addToCart(getId() ?>, qty)"
                :class="added ? 'bg-green-500' : 'bg-primary'"
                class="btn text-white w-full transition-colors">
            <span x-show="!adding && !added">Add to cart</span>
            <span x-show="adding">Adding...</span>
            <span x-show="added">Added ✓</span>
        </button>
    </div>
</div>

Alpine.js component – cart add logic

// Defined once globally, used in every product card
function initAddToCart() {
    return {
        qty:   1,
        adding: false,
        added:  false,

        async addToCart(productId, quantity) {
            if (this.adding) return;
            this.adding = true;

            try {
                const formKey = hyva.getFormKey();
                const response = await fetch('/checkout/cart/add', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
                    body: new URLSearchParams({
                        product:  productId,
                        qty:      quantity,
                        form_key: formKey,
                    }),
                });

                if (response.ok) {
                    this.added = true;
                    // Dispatch event to update cart count in header
                    window.dispatchEvent(new CustomEvent('reload-customer-section-data'));
                    setTimeout(() => { this.added = false; }, 2000);
                }
            } catch (error) {
                console.error('Add to cart failed:', error);
            } finally {
                this.adding = false;
            }
        }
    };
}

Tailwind configuration for Hyvä

// tailwind.config.js
module.exports = {
    content: [
        './templates/**/*.phtml',
        './Magento_Theme/templates/**/*.phtml',
        './Magento_Catalog/templates/**/*.phtml',
        // ... all template directories
        './web/js/**/*.js',
    ],
    theme: {
        extend: {
            colors: {
                primary: {
                    DEFAULT: '#1a73e8',
                    dark:    '#1557b0',
                    light:   '#4a90d9',
                },
                secondary: '#f5f5f5',
            },
            fontFamily: {
                sans: ['Inter', 'system-ui', 'sans-serif'],
            },
        },
    },
    plugins: [],
};
# Build Tailwind CSS
cd app/design/frontend/Vendor/Theme
npm install
npm run build    # production: minified, only used classes
npm run watch    # development: rebuild on file change

Key differences from Luma development

Task Luma Hyvä
Add interactive element RequireJS module + Knockout component x-data on HTML element
Modify template Override PHTML + layout XML Override PHTML only
Custom CSS LESS variables, compile Tailwind utility classes or custom CSS
Cart update Knockout observable + section reload Alpine.js + CustomEvent
Debug JS DevTools + Knockout debug mode DevTools (standard JS)
New page section Layout XML + PHTML + JS block PHTML with inline x-data

Summary

Hyvä makes Magento 2 frontend development approachable again. PHTML templates with Alpine.js directives are something any PHP developer can read and modify without deep JavaScript framework knowledge. Tailwind’s utility classes mean no more hunting through LESS files. The tradeoff is that third-party modules need Hyvä-compatible versions – the ecosystem has grown significantly but not every module has a Hyvä port yet. For new projects, Hyvä is the clear choice.

About Henryk Tews

What you can read next

Strategy pattern in PHP – and how Magento 2 uses it in pricing
Xdebug – configuration, PHPStorm, debugging Magento plugins

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}