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(= (int)$product->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.
