Skip to content
intermediate Phase 57 · Modern Frontend

Hyvä Theme

Understanding the Hyvä theme for Magento 2: performance benefits, architecture, and replacing the Luma frontend

1h
0 problems
Topic Progress 0%

Hyvä Architecture and Trade-offs

Why Hyvä Exists

The default Magento Luma frontend uses KnockoutJS + RequireJS + jQuery. This stack was designed in 2013. The result:

Luma Frontend Problems:
├── 500KB+ JavaScript (even on homepage)
├── KnockoutJS: Heavy framework for simple UI
├── RequireJS: Async loading overhead
├── jQuery: Redundant with modern browsers
├── LESS compilation: Slow build times
├── FCP: 2-5 seconds on mobile
└── Lighthouse: 50-70 typical

Hyvä replaces this with:

Hyvä Stack:
├── Alpine.js: 15KB, declarative reactivity
├── Tailwind CSS: Utility-first, no LESS compilation
├── ES Modules: Native browser support
├── Vite: Fast builds, HMR
├── FCP: 0.5-1 second on mobile
└── Lighthouse: 90-100 typical

Architecture Comparison

Luma:                              Hyvä:
┌─────────────┐                   ┌─────────────┐
│  KnockoutJS │ ← Heavy           │  Alpine.js  │ ← 15KB
│  (200KB)    │                   │             │
├─────────────┤                   ├─────────────┤
│  RequireJS  │ ← AMD loading     │  ES Modules │ ← Native
│  jQuery     │ ← Redundant       │  No jQuery  │
├─────────────┤                   ├─────────────┤
│  LESS       │ ← Compilation     │  Tailwind   │ ← Utility
│  compiled   │                   │  classes    │
└─────────────┘                   └─────────────┘

Data binding:                    Data binding:
data-bind="text: price"          x-text="price"
data-bind="visible: inStock"     x-show="inStock"
data-bind="click: addToCart"      @click="addToCart()"

Real Trade-offs

Hyvä Advantages:
├── 10x less JavaScript
├── 3-5x faster page loads
├── Better mobile performance
├── Simpler debugging (no RequireJS)
├── Lower server load
└── Better Core Web Vitals

Hyvä Disadvantages:
├── Extension compatibility (30-50% of extensions need adaptation)
├── KnockoutJS knowledge not directly applicable
├── Learning curve for Alpine.js + Tailwind
├── Some Magento UI Components need rewriting
├── Checkout requires custom implementation
├── No built-in admin theme changes
└── Community support still growing

When to Use Hyvä:
├── New Magento builds (no legacy extensions)
├── Performance-critical stores (B2C, mobile-heavy)
├── Stores willing to invest in frontend rewrite
├── Teams comfortable with modern frontend tools
└── Budget for extension compatibility work

When to Stay with Luma:
├── Many third-party extensions with KnockoutJS
├── Heavy customization of existing Luma theme
├── Small team without frontend expertise
├── Budget constraints
└── Internal admin-focused stores

Real Migration Case Study

Store: Fashion retailer, 50K products, 70% mobile traffic
Before Luma:
├── FCP: 4.2 seconds
├── Lighthouse: 52
├── Bounce rate: 65%
├── Conversion rate: 1.8%
└── Extensions: 45 (12 with KnockoutJS)

After Hyvä (3 month migration):
├── FCP: 0.8 seconds
├── Lighthouse: 96
├── Bounce rate: 38% (-42%)
├── Conversion rate: 2.9% (+61%)
├── Extensions rewritten: 12
├── Development cost: $45,000
├── Monthly revenue increase: $35,000
└── Payback period: 6 weeks

What was hard:
├── 3 extensions had deep KnockoutJS integration
├── Custom checkout needed full rewrite
├── Some UI components had no Hyvä equivalent
└── Team needed 2 months to learn Alpine.js + Tailwind

Extension Compatibility

Compatibility Categories

Category 1: Works Out of the Box (60-70%)
├── Backend-only modules (no frontend changes)
├── Modules using standard PHTML blocks
├── Modules using only PHP (no JS)
└── Action: Install normally

Category 2: Needs Template Override (20-30%)
├── Modules with custom PHTML templates
├── Modules using LESS for styling
├── Modules with simple JavaScript
└── Action: Override templates, convert LESS → Tailwind

Category 3: Needs Rewrite (5-10%)
├── Modules using KnockoutJS components
├── Modules using RequireJS AMD modules
├── Modules with complex UI Components
└── Action: Rewrite with Alpine.js + Tailwind

Category 4: Incompatible (1-5%)
├── Modules deeply coupled to Luma
├── Modules using deprecated Magento JS
├── Modules that modify core JS behavior
└── Action: Find alternative or build custom

Compatibility Check Process

# 1. List all frontend modules
bin/magento module:status --enabled | grep -v Magento_

# 2. Check each module for compatibility
composer why hyva-themes/magento2-compatibility-module

# 3. Search for KnockoutJS usage
grep -r "knockoutjs\|ko.observable\|data-bind" app/code/

# 4. Search for RequireJS usage
grep -r "require\(['"]knockout\|define\(\[" app/code/

# 5. Search for jQuery usage
grep -r "\$\\|jQuery" app/code/

# 6. Check for Tailwind conflicts
grep -r "class=\".*bg-\|class=\".*text-\|class=\".*p-" app/code/

Common Migration Patterns

Pattern 1: KnockoutJS → Alpine.js

<!-- Luma: KnockoutJS -->
<div data-bind="visible: isOpen">
    <span data-bind="text: items().length"></span>
</div>

<!-- Hyvä: Alpine.js -->
<div x-show="isOpen">
    <span x-text="items.length"></span>
</div>

Pattern 2: RequireJS → ES Modules

// Luma: RequireJS
define(['jquery', 'mage/url'], function($, url) {
    var urlBuilder = url.build('custom/action');
    $.ajax({ "url": urlBuilder });
});

// Hyvä: ES Modules
import $ from 'jquery';
const urlBuilder = '/custom/action';
fetch(urlBuilder).then(r => r.json());

Pattern 3: LESS → Tailwind

// Luma: LESS
.product-card {
    padding: 1rem;
    background: white;
    border-radius: 0.5rem;
    &:hover {
        box-shadow: 0 4px 6px rgba(0,0,0,0.1);
    }
}

/* Hyvä: Tailwind */
/* No CSS file needed - use utility classes */
<div class="p-4 bg-white rounded hover:shadow-md">

Extension Compatibility Layers

// Some extensions provide Hyvä compatibility modules
// Install via composer:
composer require hyva-themes/magento2-module-name

// Or create your own compatibility layer:
// 1. Create a module that overrides Luma templates
// 2. Convert KnockoutJS components to Alpine.js
// 3. Replace LESS with Tailwind classes
// 4. Test all functionality

// Example: Converting a custom warranty module
class WarrantyHyvaCompatibility
{
    // Override Luma template
    public function getTemplate(): string
    {
        return 'Vendor_Warranty::hyva/warranty-form.phtml';
    }
    
    // Convert KO component to Alpine
    public function getAlpineComponent(): string
    {
        return <<<JS
        { 
            warrantySelected: false,
            toggleWarranty() { 
                this.warrantySelected = !this.warrantySelected;
            }
        }
        JS;
    }
}

Building a Custom Hyvä Module

Module Structure

app/code/Vendor/Warranty/├── registration.php
├── etc/module.xml
├── composer.json
├── view/
│   └── frontend/
│       ├── layout/
│       │   ├── catalog_product_view.xml
│       │   └── checkout_cart_index.xml
│       ├── templates/
│       │   ├── warranty-form.phtml
│       │   └── warranty-summary.phtml
│       ├── web/
│       │   ├── js/
│       │   │   └── warranty.js
│       │   └── css/
│       │       └── warranty.css (optional)
│       └── requirejs-config.js (if needed)
└── Block/
    └── Warranty.php

Layout XML for Hyvä

<!-- catalog_product_view.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="product.info.addtocart">
            <block class="Vendor\Warranty\Block\Warranty"
                   name="warranty.options"
                   template="Vendor_Warranty::warranty-form.phtml"
                   after="product.info.addtocart.addto"/>
        </referenceBlock>
    </body>
</page>

Alpine.js Component

<!-- warranty-form.phtml -->
<?php
/** @var \Vendor\Warranty\Block\Warranty $block */
$warrantyOptions = $block->getWarrantyOptions();
?>
<div x-data="warrantyForm()" class="mt-4 border rounded p-4">
    <h3 class="text-lg font-bold mb-2">Warranty Options</h3>
    
    <?php foreach ($warrantyOptions as $option): ?>
    <label class="flex items-center space-x-2 mb-2 cursor-pointer">
        <input type="radio"
               name="warranty"
               value="<?= $block->escapeHtmlAttr($option['id']) ?>"
               x-model="selectedWarranty"
               class="text-blue-600">
        <span class="text-sm">
            <?= $block->escapeHtml($option['name']) ?>
            - $<?= number_format($option['price'], 2) ?>
        </span>
    </label>
    <?php endforeach; ?>
    
    <label class="flex items-center space-x-2 mt-4">
        <input type="checkbox"
               x-model="agreeToTerms"
               class="text-blue-600">
        <span class="text-sm text-gray-600">
            I agree to warranty terms
        </span>
    </label>
    
    <div x-show="selectedWarranty && !agreeToTerms"
         x-text="'Please agree to warranty terms'"
         class="text-red-500 text-sm mt-2">
    </div>
</div>

<script>
function warrantyForm() {
    return {
        selectedWarranty: null,
        agreeToTerms: false,
        
        init() {
            // Watch for changes
            this.$watch('selectedWarranty', (value) => {
                // Update cart warranty
                this.updateWarranty(value);
            });
        },
        
        async updateWarranty(warrantyId) {
            if (!warrantyId) return;
            
            const response = await fetch('/warranty/update', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-Requested-With': 'XMLHttpRequest'
                },
                body: JSON.stringify({
                    warranty_id: warrantyId,
                    product_id: <?= $block->getProductId() ?>
                })
            });
            
            if (!response.ok) {
                console.error('Failed to update warranty');
            }
        }
    };
}
</script>

Block Class

<?php
namespace Vendor\Warranty\Block;

use Magento\Framework\View\Element\Template;

class Warranty extends Template
{
    private $warrantyRepository;
    private $serializer;
    
    public function __construct(
        Template\Context $context,
        \Vendor\Warranty\Api\WarrantyRepositoryInterface $warrantyRepository,
        \Magento\Framework\Serialize\Serializer\Json $serializer,
        array $data = []
    ) {
        parent::__construct($context, $data);
        $this->warrantyRepository = $warrantyRepository;
        $this->serializer = $serializer;
    }
    
    public function getWarrantyOptions(): array
    {
        return $this->warrantyRepository->getActiveWarranties();
    }
    
    public function getProductId(): int
    {
        return (int) $this->getProduct()->getId();
    }
    
    public function getJsonConfig(): string
    {
        return $this->serializer->jsonEncode([
            'productId' => $this->getProductId(),
            'warrantyUrl' => $this->getUrl('warranty/update'),
        ]);
    }
}

Testing Checklist

â–¡ Product page renders warranty form
â–¡ Alpine.js components initialize correctly
â–¡ Warranty selection updates cart
â–¡ Checkout shows warranty items
â–¡ Admin shows warranty in order
â–¡ Mobile responsive
â–¡ Accessible (keyboard navigation, screen reader)
â–¡ Performance: No extra JS bundles loaded

Hyvä Checkout Implementation

The Checkout Challenge

Magento's default checkout uses KnockoutJS extensively. Hyvä provides hyva/checkout module that replaces it with Alpine.js. This is the hardest part of migration.

Default Checkout (KnockoutJS):
├── Checkout index with multiple steps
├── Shipping, Payment, Review steps
├── KnockoutJS components for each
├── RequireJS modules for logic
└── 200KB+ JavaScript

Hyvä Checkout (Alpine.js):
├── Single-page checkout (optional steps)
├── Alpine.js components
├── ES Modules for logic
├── Minimal JavaScript
└── Better mobile UX

Hyvä Checkout Module

# Install Hyvä checkout
composer require hyva-themes/magento2-checkout
bin/magento module:enable Hyva_Checkout
bin/magento setup:upgrade

Custom Checkout Step

<!-- app/code/Vendor/CustomCheckout/view/frontend/templates/checkout/gift-options.phtml -->
<div x-data="giftOptions()" class="checkout-step">
    <h2 class="text-xl font-bold mb-4">Gift Options</h2>
    
    <div class="space-y-4">
        <label class="flex items-center space-x-2">
            <input type="checkbox"
                   x-model="includeGiftMessage"
                   class="text-blue-600">
            <span>Include gift message</span>
        </label>
        
        <div x-show="includeGiftMessage" x-transition class="space-y-3">
            <div>
                <label class="block text-sm font-medium mb-1">Recipient Name</label>
                <input type="text"
                       x-model="recipientName"
                       class="w-full border rounded px-3 py-2"
                       placeholder="Recipient name">
            </div>
            
            <div>
                <label class="block text-sm font-medium mb-1">Gift Message</label>
                <textarea x-model="giftMessage"
                          class="w-full border rounded px-3 py-2"
                          rows="3"
                          placeholder="Your message..."></textarea>
            </div>
        
        </div>
        
        <button @click="saveGiftOptions()"
                :disabled="!canSave"
                class="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50">
            Save Gift Options
        </button>
    </div>
</div>

<script>
function giftOptions() {
    return {
        includeGiftMessage: false,
        recipientName: '',
        giftMessage: '',
        
        get canSave() {
            if (!this.includeGiftMessage) return true;
            return this.recipientName.length > 0 && this.giftMessage.length > 0;
        },
        
        async saveGiftOptions() {
            const data = {
                include_gift_message: this.includeGiftMessage,
                recipient_name: this.recipientName,
                gift_message: this.giftMessage
            };
            
            // Save to checkout data
            window.checkoutData = {
                ...window.checkoutData,
                gift_options: data
            };
            
            // Move to next step
            this.$dispatch('checkout:next-step');
        }
    };
}
</script>

Payment Method Integration

<!-- Payment method with Alpine.js -->
<div x-data="paymentMethod()" class="payment-method">
    <label class="flex items-center space-x-3 p-4 border rounded cursor-pointer"
           :class="{ 'border-blue-500 bg-blue-50': isSelected }">
        <input type="radio"
               name="payment_method"
               value="custom_payment"
               x-model="selectedMethod"
               class="text-blue-600">
        <div>
            <div class="font-medium">Custom Payment</div>
            <div class="text-sm text-gray-500">Pay with custom gateway</div>
        </div>
    </label>
    
    <div x-show="isSelected" x-transition class="mt-4 p-4 border rounded">
        <div class="space-y-3">
            <div>
                <label class="block text-sm font-medium mb-1">Card Number</label>
                <input type="text"
                       x-model="cardNumber"
                       class="w-full border rounded px-3 py-2"
                       placeholder="1234 5678 9012 3456"
                       maxlength="19">
            </div>
            
            <div class="grid grid-cols-2 gap-3">
                <div>
                    <label class="block text-sm font-medium mb-1">Expiry</label>
                    <input type="text"
                           x-model="expiry"
                           class="w-full border rounded px-3 py-2"
                           placeholder="MM/YY"
                           maxlength="5">
                </div>
                <div>
                    <label class="block text-sm font-medium mb-1">CVV</label>
                    <input type="text"
                           x-model="cvv"
                           class="w-full border rounded px-3 py-2"
                           placeholder="123"
                           maxlength="4">
                </div>
            </div>
        </div>
    </div>
</div>

<script>
function paymentMethod() {
    return {
        selectedMethod: null,
        cardNumber: '',
        expiry: '',
        cvv: '',
        
        get isSelected() {
            return this.selectedMethod === 'custom_payment';
        },
        
        formatCardNumber(e) {
            let value = e.target.value.replace(/\s/g, '').replace(/\D/g, '');
            let formatted = value.match(/.{1,4}/g)?.join(' ') || value;
            this.cardNumber = formatted;
        }
    };
}
</script>

Performance Comparison

Checkout Performance:

Luma Checkout:
├── JS Bundle: 350KB+
├── FCP: 3-4 seconds
├── Steps: 3 (Shipping → Payment → Review)
├── Network requests: 15-20
└── Mobile: Poor (heavy JS)

Hyvä Checkout:
├── JS Bundle: 30KB
├── FCP: 0.8-1.2 seconds
├── Steps: Flexible (single page or multi-step)
├── Network requests: 5-8
└── Mobile: Excellent (lightweight)

Real impact:
├── Checkout conversion: +25% (faster = more completions)
├── Mobile conversion: +40% (mobile users most affected)
├── Cart abandonment: -15% (less friction)
└── Revenue impact: $50K+/month for mid-size store

Quiz

1. What is the main reason to migrate from Luma to Hyvä?

Question 1 options

2. What percentage of Magento extensions typically need rewrite for Hyvä?

Question 2 options

3. What is the hardest part of Hyvä migration?

Question 3 options

4. What is the typical Lighthouse score improvement from Luma to Hyvä?

Question 4 options

Flashcards

Question

Hyvä JS stack?

Answer

Alpine.js (15KB) instead of KnockoutJS (200KB+)

Question

Hyvä CSS stack?

Answer

Tailwind CSS instead of LESS compilation

Question

Extension compatibility?

Answer

60-70% work, 20-30% need templates, 5-10% need rewrite

Question

Hardest migration part?

Answer

Checkout (deeply coupled to KnockoutJS)

Question

Typical conversion rate improvement?

Answer

+25% overall, +40% on mobile

Revision Notes

Key Takeaways

  • 1. Hyvä replaces Luma stack: Alpine.js + Tailwind vs KnockoutJS + RequireJS + jQuery
  • 2. 10x less JavaScript, 3-5x faster page loads, 90+ Lighthouse scores
  • 3. 5-10% of extensions need full rewrite, 20-30% need template overrides
  • 4. Checkout is hardest migration part — Hyvä provides module but custom steps need rewrite
  • 5. ROI is strong: $45K migration cost, $35K/month revenue increase for mid-size store

Interview Tips

  • Explain Hyvä vs Luma trade-offs with specific metrics
  • Discuss extension compatibility categories and migration process
  • Walk through building a custom Hyvä module with Alpine.js
  • Analyze Hyvä checkout implementation vs Luma
  • Evaluate when to use Hyvä vs stay with Luma

Cheat Sheet

Hyvä Theme

  • JS: Alpine.js (15KB) vs KnockoutJS (200KB+)
  • CSS: Tailwind vs LESS
  • Build: Vite vs static deploy
  • Lighthouse: 90-100 vs 50-70
  • Extensions: 60-70% work, 5-10% rewrite
  • Checkout: Hardest part, use hyva/checkout module
  • ROI: ~$45K cost, ~$35K/month revenue increase