Development of Custom Magento 2 Theme
A Magento 2 theme is a set of XML configurations, PHTML templates, Less/CSS and JavaScript that determines the appearance and behavior of the frontend. Unlike Shopify, there is no visual editor here — development is done with files with an understanding of the theme inheritance system and layout XML.
Theme Inheritance System
Magento themes are built on an inheritance chain. A custom theme doesn't copy all files from the base theme — it only overrides the necessary ones. Example of an inheritance chain:
Magento/blank
└── Magento/luma
└── MyCompany/mytheme (custom theme)
During rendering, Magento looks for a template first in the custom theme, then goes up the chain to the base. This allows you to change one file without touching the others.
Theme File Structure
app/design/frontend/MyCompany/mytheme/
├── etc/
│ └── view.xml # image configuration (sizes, quality)
├── i18n/
│ └── en_US.csv # string translations
├── media/
│ └── preview.jpg # preview in Admin
├── registration.php # theme registration
├── theme.xml # metadata and parent
├── web/
│ ├── css/
│ │ ├── source/
│ │ │ ├── _theme.less # theme variables
│ │ │ └── _extend.less # base CSS extensions
│ │ └── _module.less
│ ├── fonts/
│ ├── images/
│ └── js/
│ └── theme.js
└── Magento_Catalog/ # module overrides for Catalog
├── layout/
│ └── catalog_product_view.xml
└── templates/
└── product/
└── view/
└── attributes.phtml
theme.xml:
<theme xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Config/etc/theme.xsd">
<title>MyCompany Theme</title>
<parent>Magento/luma</parent>
<media>
<preview_image>media/preview.jpg</preview_image>
</media>
</theme>
registration.php:
<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::THEME,
'frontend/MyCompany/mytheme',
__DIR__
);
Layout XML — Foundation of the Rendering System
Layout XML manages page structure: which blocks are where, which templates they use. Without understanding Layout, you cannot properly change the page structure.
Types of layout files:
-
default.xml— applies to all pages -
catalog_product_view.xml— product page -
catalog_category_view.xml— category page -
checkout_cart_index.xml— shopping cart -
cms_index_index.xml— homepage (CMS)
Example — adding a custom block to a product page:
<!-- app/design/frontend/MyCompany/mytheme/Magento_Catalog/layout/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>
<!-- Remove standard comparison block -->
<referenceBlock name="product.info.addtocart.additional" remove="true"/>
<!-- Add warranty block after price -->
<referenceContainer name="product.info.main">
<block class="MyCompany\Catalog\Block\Product\Warranty"
name="product.warranty.info"
template="MyCompany_Catalog::product/warranty.phtml"
after="product.info.price"/>
</referenceContainer>
<!-- Move reviews above tabs -->
<move element="product.info.details" destination="content" before="product.info.media"/>
</body>
</page>
PHTML Templates
Templates are PHP files with HTML markup. Overriding the standard price template:
<?php
// app/design/frontend/MyCompany/mytheme/Magento_Catalog/templates/product/price/final_price.phtml
/** @var \Magento\Catalog\Block\Product\View\Description $block */
/** @var \Magento\Catalog\ViewModel\Product\Checker\AddToCompareAvailability $compareAvailability */
$priceType = $block->getPriceType('final_price');
$id = $block->getPriceId() ?: 'product-price-' . $block->getProduct()->getId();
?>
<span class="price-wrapper price-final_price"
data-price-amount="<?= $block->escapeHtmlAttr($priceType->getAmount()->getValue()) ?>"
data-price-type="finalPrice">
<?php if ($block->getProduct()->hasCustomOptions()): ?>
<span class="price-label"><?= $block->escapeHtml(__('As low as')) ?></span>
<?php endif; ?>
<span id="<?= $block->escapeHtmlAttr($id) ?>" class="price-container">
<span class="price">
<?= $block->renderAmount($priceType->getAmount(), ['display_label' => false]) ?>
</span>
</span>
</span>
Less/CSS System
Magento 2 uses Less with a preprocessor pipeline. Base variables are overridden in web/css/source/_theme.less:
// Brand colors
@color-primary: #1a73e8;
@color-primary-darker: #1557b0;
@color-secondary: #ff6b35;
// Typography
@font-family-name__base: 'Inter';
@font-size-base: 16px;
@line-height-base: 1.6;
@font-weight__regular: 400;
@font-weight__bold: 700;
// Buttons
@button__background: @color-primary;
@button__border: 1px solid @color-primary;
@button__color: #ffffff;
@button__hover__background: @color-primary-darker;
// Header
@header__background-color: #ffffff;
@header-icons-color: @color-primary;
// Breakpoints
@screen__m: 768px;
@screen__l: 1024px;
@screen__xl: 1440px;
Extensions in _extend.less:
// Import custom font
@font-face {
font-family: 'Inter';
src: url('../fonts/Inter-Regular.woff2') format('woff2'),
url('../fonts/Inter-Regular.woff') format('woff');
font-weight: 400;
font-display: swap;
}
// Custom product card style
.product-item {
border: 1px solid @color-border;
border-radius: 8px;
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
}
&-photo {
border-radius: 8px 8px 0 0;
overflow: hidden;
}
}
JavaScript in Theme
Magento 2 uses RequireJS to load modules. Configuration in requirejs-config.js:
// web/requirejs-config.js
var config = {
map: {
'*': {
// Replace standard component
'Magento_Catalog/js/product/view/product-info': 'MyCompany_Catalog/js/product-info-extended',
}
},
config: {
mixins: {
'Magento_Checkout/js/view/minicart': {
'MyCompany_Checkout/js/view/minicart-mixin': true
}
}
}
};
Mixin to extend existing component without replacement:
// web/js/view/minicart-mixin.js
define(['jquery', 'mage/utils/wrapper'], function ($, wrapper) {
'use strict';
return function (Component) {
return Component.extend({
// Method override
getCartParam: wrapper.wrap(Component.prototype.getCartParam, function (original, name) {
if (name === 'summary_count') {
// Add custom count logic
return this.cartData()[name] || 0;
}
return original(name);
})
});
};
});
Image Configuration
Image sizes are set in etc/view.xml:
<vars module="Magento_Catalog">
<var name="product_image_white_borders">1</var>
</vars>
<images module="Magento_Catalog">
<image id="product_page_image_large" type="image">
<width>1200</width>
<height>1200</height>
</image>
<image id="category_page_grid" type="image">
<width>400</width>
<height>400</height>
<constrain>true</constrain>
</image>
<image id="product_thumbnail_image" type="thumbnail">
<width>80</width>
<height>80</height>
</image>
</images>
Static Content Deployment
# Deploy for specific theme and locale
bin/magento setup:static-content:deploy en_US \
--theme MyCompany/mytheme \
--jobs=4 \
-f
# During development — symlinks instead of copying
bin/magento dev:source-theme:deploy \
--locale en_US \
--theme MyCompany/mytheme \
css/styles-m css/styles-l
# Manual Less compilation (for fast iteration)
grunt watch
Hyva — Alternative to Luma
Hyva Themes — modern stack for Magento 2 themes: Tailwind CSS + Alpine.js + server-side rendering through Magento layout. Instead of complex RequireJS + KnockoutJS + Less — simple, fast and understandable stack.
Commercial license ($1000/site). Delivers Lighthouse 90+ without additional optimization. Development is 2–3 times faster compared to Luma.
Timeline
Custom theme based on Luma/Blank per ready design (10–20 page types): 5–8 weeks. Hyva theme with same complexity: 3–5 weeks. Customization of existing theme (few templates, branding): 1–2 weeks.







