Custom OpenCart Theme Development
Ready-made themes from OpenCart marketplace are a compromise between launch speed and brand fit. Custom theme gives full control over HTML structure, CSS, performance, and accessibility. With proper implementation, a custom theme is faster than ready-made themes due to absence of unused code.
OpenCart 4.x Theme Architecture
OpenCart 4.x uses Twig as template engine instead of PHP templates in versions 3.x. This changed theme development approach.
Custom theme structure:
catalog/view/theme/{theme_name}/
├── template/
│ ├── common/
│ │ ├── header.twig
│ │ ├── footer.twig
│ │ ├── cart.twig
│ │ └── search.twig
│ ├── product/
│ │ ├── category.twig ← catalog
│ │ ├── product.twig ← product card
│ │ ├── search.twig
│ │ └── special.twig
│ ├── checkout/
│ │ ├── cart.twig
│ │ └── checkout.twig
│ └── account/
│ ├── login.twig
│ ├── register.twig
│ └── order.twig
└── stylesheet/
└── (for minimal overrides)
CSS and JS are connected not through theme folder, but via events and controller configuration.
Inheritance from default theme
Custom theme can be fully independent or extend the default theme. For the second option — parent theme specified in settings:
Admin → System → Settings → Store → Theme → Parent Theme: default
Then if needed file is not in custom theme folder — OpenCart takes it from default. This speeds development: override only changed templates.
Theme Registration
// Create file extension/myshop/catalog/controller/startup/theme.php
// (or via event system)
// In oc_extension table register the theme:
INSERT INTO `oc_extension` (`extension_id`, `extension`, `type`, `code`)
VALUES (NULL, 'opencart', 'theme', 'myshop');
// In oc_setting specify the path:
INSERT INTO `oc_setting` (`store_id`, `code`, `key`, `value`)
VALUES (0, 'config', 'config_theme', 'myshop');
Or via Extension Installer if theme is packaged as extension.
Basic header.twig template
<!DOCTYPE html>
<html lang="{{ lang }}" dir="{{ direction }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<meta name="description" content="{{ description }}">
{% if canonical %}
<link rel="canonical" href="{{ canonical }}">
{% endif %}
{# Connect Bootstrap or custom CSS #}
<link rel="stylesheet" href="{{ stylesheet }}">
{% for style in styles %}
<link rel="stylesheet" type="text/css" href="{{ style.href }}" media="{{ style.media }}">
{% endfor %}
</head>
<body class="{{ class }}">
<header class="site-header">
<div class="container">
<a class="site-logo" href="{{ home }}">
{% if logo %}
<img src="{{ logo }}" alt="{{ name }}" loading="eager">
{% else %}
<span>{{ name }}</span>
{% endif %}
</a>
<nav class="site-nav">
{% for category in categories %}
<a href="{{ category.href }}" {% if category.children %}class="has-dropdown"{% endif %}>
{{ category.name }}
{% if category.children %}
<ul class="dropdown">
{% for child in category.children %}
<li><a href="{{ child.href }}">{{ child.name }}</a></li>
{% endfor %}
</ul>
{% endif %}
</a>
{% endfor %}
</nav>
<div class="header-actions">
<a href="{{ cart }}" class="cart-icon" data-count="{{ cart_count }}">
Cart ({{ cart_quantity }})
</a>
{% if logged %}
<a href="{{ account }}">Account</a>
{% else %}
<a href="{{ login }}">Sign In</a>
{% endif %}
</div>
</div>
</header>
Product card — product.twig
Key variables available in product card template:
{# Basic data #}
{{ product_id }}, {{ name }}, {{ description }}, {{ model }}
{{ price }}, {{ special }}, {{ tax }}
{{ rating }}, {{ reviews }}
{{ manufacturer }}, {{ manufacturer_href }}
{# Images #}
{{ thumb }} {# main image #}
{{ images }} {# array of additional images #}
{# Options #}
{% for option in options %}
{{ option.name }}, {{ option.type }}
{% for value in option.product_option_value %}
{{ value.name }}, {{ value.price }}
{% endfor %}
{% endfor %}
{# SEO #}
{{ meta_title }}, {{ meta_description }}, {{ meta_keyword }}
{{ canonical }}
Template with gallery and option selection:
<section class="product-page">
<div class="product-gallery">
<img id="product-image-main"
src="{{ thumb }}"
alt="{{ name }}"
loading="eager"
fetchpriority="high">
<div class="thumbnails">
<img src="{{ thumb }}" data-src="{{ image }}" class="thumb active">
{% for image in images %}
<img src="{{ image.thumb }}" data-src="{{ image.popup }}" class="thumb">
{% endfor %}
</div>
</div>
<div class="product-info">
<h1>{{ name }}</h1>
<div class="product-price">
{% if special %}
<span class="price-old">{{ price }}</span>
<span class="price-new">{{ special }}</span>
{% else %}
<span class="price-current">{{ price }}</span>
{% endif %}
</div>
{% for option in options %}
<div class="product-option">
<label>{{ option.name }}{% if option.required %} *{% endif %}</label>
{% if option.type == 'select' %}
<select name="option[{{ option.product_option_id }}]">
<option value="">— Select —</option>
{% for value in option.product_option_value %}
<option value="{{ value.product_option_value_id }}"
{% if value.price %}data-price="{{ value.price }}"{% endif %}>
{{ value.name }}{% if value.price %} (+ {{ value.price }}){% endif %}
</option>
{% endfor %}
</select>
{% elseif option.type == 'radio' %}
<div class="option-radios">
{% for value in option.product_option_value %}
<label class="option-radio">
<input type="radio"
name="option[{{ option.product_option_id }}]"
value="{{ value.product_option_value_id }}">
{{ value.name }}
</label>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
<div class="quantity-row">
<input type="number" name="quantity" value="1" min="1">
<button id="btn-cart" data-id="{{ product_id }}">Add to Cart</button>
</div>
</div>
</section>
JavaScript in theme
OpenCart 4.x uses its own AJAX for cart. Extension via events:
// catalog/view/javascript/myshop/theme.js
// Add to cart
document.querySelectorAll('[data-id]').forEach(btn => {
btn.addEventListener('click', async function() {
const productId = this.dataset.id
const quantity = document.querySelector('[name="quantity"]')?.value || 1
const options = {}
document.querySelectorAll('[name^="option"]').forEach(el => {
const match = el.name.match(/option\[(\d+)\]/)
if (match && el.value) {
options[match[1]] = el.value
}
})
const response = await fetch('index.php?route=checkout/cart.add', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
product_id: productId,
quantity,
...Object.fromEntries(
Object.entries(options).map(([k, v]) => [`option[${k}]`, v])
)
})
})
const data = await response.json()
if (data.success) {
updateCartWidget(data)
showNotification(data.success)
} else {
showErrors(data.error)
}
})
})
Connecting theme resources
Scripts and styles are connected in controller via event system or directly in template via variables:
// In event handler or startup controller:
$this->document->addStyle(
'catalog/view/javascript/myshop/css/theme.css',
'screen',
100 // sort_order
);
$this->document->addScript(
'catalog/view/javascript/myshop/js/theme.js',
'footer',
100
);
For production — build via Vite or Webpack: minification, hash-suffix for cache busting:
# package.json in theme root
npm run build
# Generates: theme.abc123.css, theme.abc123.js
Responsive layout
OpenCart theme should work correctly on mobile. Breakpoints:
/* Mobile-first approach */
.product-grid { grid-template-columns: repeat(2, 1fr); gap: 16px; }
@media (min-width: 768px) {
.product-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1200px) {
.product-grid { grid-template-columns: repeat(4, 1fr); }
}
Images — with loading="lazy" for everything except first screen, srcset for different screen densities.
Timeline for theme development
- Header + footer + navigation markup: 1–2 days
- Homepage (banner, categories, best sellers): 1–2 days
- Catalog with filters: 1–2 days
- Product card with gallery + options: 1–2 days
- Cart + checkout: 2–3 days
- User account + order page: 1–2 days
- Responsiveness + cross-browser compatibility: 1–2 days
Total: 1.5–2 weeks with design ready in Figma. Without design — add 1–2 weeks for design.







