Tutorial: create a theme extension
This tutorial is for designers who build their first Exponential theme. At the end you have an extension mytheme
with a design mytheme, used by one siteaccess, with a pagelayout, a stylesheet with light and dark tokens, a script
file, one override and translatable texts. Every file is shown in full. Allow about two hours.
You need: shell access to a development installation (never a live site), and someone who may change installation settings (an administrator), for step 7.
Contents
- Step 1: the folders
- Step 2: metadata
- Step 3: register the design
- Step 4: the pagelayout
- Step 5: stylesheet and script
- Step 6: an override
- Step 7: a siteaccess that uses the design
- Step 8: activate and clear caches
- Step 9: check it
- Next steps
Step 1: the folders
cd /path/to/exponential
mkdir -p extension/mytheme/settings \
extension/mytheme/design/mytheme/templates/pagelayout \
extension/mytheme/design/mytheme/override/templates/full \
extension/mytheme/design/mytheme/stylesheets \
extension/mytheme/design/mytheme/javascript \
extension/mytheme/design/mytheme/images \
extension/mytheme/translations/ger-DEName rules: lowercase, letters, digits and underscores; a prefix of your own so it never collides with another theme (Naming your theme extension).
Step 2: metadata
extension/mytheme/extension.xml:
<extension>
<name>mytheme</name>
<summary>My theme</summary>
<version>1.0.0</version>
<license>GPL-2.0-or-later</license>
<maintainers>
<maintainer>Your name</maintainer>
</maintainers>
</extension>extension/mytheme/ezinfo.php:
class mythemeInfo
{
static function info()
{
return array(
'Name' => 'My theme',
'Version' => '1.0.0',
'Copyright' => 'Copyright (C) 2026 Your name',
'License' => 'GNU General Public License v2.0 (or any later version)',
);
}
}These two files are what the administration's About page reads. Versions in both must always agree (ezinfo.php, extension.xml).
Step 3: register the design
extension/mytheme/settings/design.ini.append.php:
[ExtensionSettings]
DesignExtensions[]=mytheme
*/ ?>From now on extension/mytheme/design/mytheme/ is a design base for the design mytheme
(Design extensions).
Step 4: the pagelayout
extension/mytheme/design/mytheme/templates/pagelayout.tpl:
def $mt_node = false()
if and( is_array( $module_result.content_info ), is_set( $module_result.content_info.node_id ) )
set $mt_node = fetch( 'content', 'node', hash( 'node_id', $module_result.content_info.node_id ) )
/if
<html lang="$site.http_equiv.Content-language|wash"if eq( first_set( $site.text_direction, 'ltr' ), 'rtl' ) dir="rtl"/if>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>if $mt_node$mt_node.name|wash - /if$site.title|wash</title>
ezscript( array( 'theme-guard.js' ) )
ezcss_load( array( 'tokens.css', 'mytheme.css' ) )
</head>
<body>
<a class="skip-link" href="#main">'Skip to main content'|i18n( 'design/mytheme' )</a>
include uri='design:pagelayout/header.tpl'
<main id="main" tabindex="-1">
$module_result.content
</main>
include uri='design:pagelayout/footer.tpl'
ezscript_load( array( 'mytheme.js' ) )
</body>
</html>
undef $mt_nodetemplates/pagelayout/header.tpl:
<header class="site-header">
<a class="site-name" href='/'|ezurl>$site.title|wash</a>
<button type="button" class="theme-toggle" id="theme-toggle">'Color mode'|i18n( 'design/mytheme' )</button>
</header>templates/pagelayout/footer.tpl:
<footer class="site-footer">
<p>'Powered by Exponential'|i18n( 'design/mytheme' )</p>
</footer>No <style>, no inline script: this pagelayout runs under a strict Content Security Policy
(CSP-safe design).
Step 5: stylesheet and script
stylesheets/tokens.css: copy the starter set of Design tokens (light values on :root, dark
values on :root[data-theme="dark"]).
stylesheets/mytheme.css:
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; color: var(--site-ink); background: var(--site-surface); font-family: var(--site-font); line-height: 1.5; }
a { color: var(--site-accent); }
.site-header, .site-footer, main { max-width: 72rem; margin-inline: auto; padding: var(--site-space-3); }
.site-header { display: flex; justify-content: space-between; align-items: center; }
.skip-link { position: fixed; inset-inline-start: 0.5rem; inset-block-start: 0.5rem; transform: translateY(-200%);
background: var(--site-card); color: var(--site-ink); padding: var(--site-space-2) var(--site-space-3); }
.skip-link:focus { transform: none; }
:where(a, button):focus-visible { outline: 3px solid var(--site-focus); outline-offset: 2px; }javascript/theme-guard.js: the guard from The theme toggle and the pre-paint guard.
javascript/mytheme.js:
(function () {
var b = document.getElementById('theme-toggle');
if (!b || !window.mythemeTheme) return;
b.addEventListener('click', function () {
var order = { light: 'dark', dark: 'system', system: 'light' };
window.mythemeTheme.save(order[document.documentElement.getAttribute('data-theme-choice')] || 'light');
});
})();Step 6: an override
Give articles their own full view. extension/mytheme/settings/override.ini.append.php (active for every siteaccess
that has the extension; for one siteaccess only, put it in settings/siteaccess/<name>/ of the extension):
[mytheme_full_article]
Source=node/view/full.tpl
MatchFile=full/article.tpl
Subdir=templates
Match[class_identifier]=article
*/ ?>design/mytheme/override/templates/full/article.tpl:
<article class="article">
<h1>$node.name|wash</h1>
if $node.data_map.intro.has_content
<div class="article-intro">attribute_view_gui attribute=$node.data_map.intro</div>
/if
attribute_view_gui attribute=$node.data_map.body
</article>Use the attribute identifiers of your installation's article class (Override basics).
Step 7: a siteaccess that uses the design
Ask your administrator to create (or let you create on your development installation) a siteaccess mytheme_site,
listed in AvailableSiteAccessList[], with settings/siteaccess/mytheme_site/site.ini.append.php holding, besides the
usual site settings:
[DesignSettings]
SiteDesign=mytheme
AdditionalSiteDesignList[]
AdditionalSiteDesignList[]=standard
[ExtensionSettings]
ActiveAccessExtensions[]=mythemeSee Previewing a design on a siteaccess.
Step 8: activate and clear caches
php bin/php/ezpgenerateautoloads.php -e
php bin/php/ezcache.php --clear-allOn installations that run Velocity, ./console exp:velocity deploy does both and reloads PHP.
Step 9: check it
- To do:
/mytheme_site/shows your header, the content, your footer. - To do: An article shows
full/article.tpl(turn onShowTemplatePathCommentsto see it). - To do: The color mode button switches light, dark, system; reloading keeps the choice; no white flash.
- To do: Tab once: the skip link appears.
- To do:
./console exp:eztemplatecheck extension/mytheme/design/mytheme/templates/reports every file valid. - To do: No errors in the browser console; no
<style>or inline script in the page source.
Next steps
- Translate the texts: Tutorial: internationalize a theme.
- Put the extension under Git and give it a
composer.json: Theme extensions: start here. - Run the theme launch checklist.
Related pages
- Design extensions
- The pagelayout
- What a theme extension is
- Free documentation: this page is free to copy, translate and improve under the GNU FDL 1.3.
Become an Exponential Partner
Partners share their information, provide funding, contribute back and develop the products with us, and that is how Exponential grows. A partner website and dashboard are planned for mid-2027: please become a partner today to speed up our growth rate and land new features faster.