Development of Custom Grav Theme (Twig)
A Grav theme is a directory in user/themes/ containing Twig templates, CSS, JS, and configuration files. Each .md content file uses a template with the same name: services.md → templates/services.html.twig. If the template is not found, default.html.twig is used.
Theme Structure
user/themes/my-theme/
templates/
partials/
base.html.twig # base layout
navigation.html.twig # navigation
sidebar.html.twig
modular/
hero.html.twig # modular sections
features.html.twig
testimonials.html.twig
default.html.twig # fallback
home.html.twig
blog.html.twig
post.html.twig
service-detail.html.twig
error.html.twig
css/
theme.css
js/
theme.js
images/
blueprints/ # theme field configuration for admin
pages/
service-detail.yaml
blueprints.yaml # theme metadata
my-theme.php # PHP theme class (optional)
my-theme.yaml # theme default settings
thumbnail.jpg
Base Layout: base.html.twig
{# templates/partials/base.html.twig #}
<!DOCTYPE html>
<html lang="{{ grav.language.getLanguage() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>
{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}
</title>
{% if page.header.metadata.description is defined %}
<meta name="description" content="{{ page.header.metadata.description }}">
{% elseif page.summary %}
<meta name="description" content="{{ page.summary|striptags|trim }}">
{% endif %}
{% block stylesheets %}
{% do assets.addCss('theme://css/theme.css', 100) %}
{% endblock %}
{{ assets.css()|raw }}
</head>
<body class="{{ page.template }}{% if page.header.body_class %} {{ page.header.body_class }}{% endif %}">
{% include 'partials/navigation.html.twig' %}
{% block content %}{% endblock %}
{% include 'partials/footer.html.twig' %}
{% block javascripts %}
{% do assets.addJs('theme://js/theme.js', 100) %}
{% endblock %}
{{ assets.js()|raw }}
</body>
</html>
Page Template
{# templates/service-detail.html.twig #}
{% extends 'partials/base.html.twig' %}
{% block content %}
<section class="hero
{%- if page.header.hero_image %} hero--image{% endif %}">
{% if page.media[page.header.hero_image] is defined %}
{% set hero = page.media[page.header.hero_image] %}
<img src="{{ hero.url }}"
srcset="{{ hero.resize(800,400).url }} 800w,
{{ hero.resize(1600,800).url }} 1600w"
alt="{{ page.title }}">
{% endif %}
<div class="hero__inner">
<h1>{{ page.title }}</h1>
{% if page.header.intro %}<p>{{ page.header.intro }}</p>{% endif %}
</div>
</section>
<div class="container layout-main">
<article class="content">
{{ page.content|raw }}
{% if page.header.features is defined %}
<ul class="features-list">
{% for feature in page.header.features %}
<li>
<i class="icon {{ feature.icon }}"></i>
{{ feature.text }}
</li>
{% endfor %}
</ul>
{% endif %}
</article>
{% if page.header.show_sidebar %}
{% include 'partials/sidebar.html.twig' %}
{% endif %}
</div>
{% endblock %}
Navigation with Active State
{# templates/partials/navigation.html.twig #}
{% set tree = pages.find('/').children.visible %}
<nav class="main-nav">
<ul>
{% for item in tree %}
{% set is_active = (item.active or item.activeChild) %}
<li class="{{ is_active ? 'active' : '' }}
{{- item.children.visible|length ? ' has-children' : '' }}">
<a href="{{ item.url }}">{{ item.title }}</a>
{% if item.children.visible|length %}
<ul class="submenu">
{% for child in item.children.visible %}
<li class="{{ child.active ? 'active' : '' }}">
<a href="{{ child.url }}">{{ child.title }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
Modular Pages
A modular page is assembled from several .md files in one directory:
pages/01.home/
home.md # type: modular
_hero/
hero.md # template: modular/hero
_features/
features.md # template: modular/features
_cta/
cta.md
{# templates/home.html.twig #}
{% extends 'partials/base.html.twig' %}
{% block content %}
{% for module in page.collection %}
{% include ['modular/' ~ module.template ~ '.html.twig',
'modular/default.html.twig'] ignore missing with {page: module} %}
{% endfor %}
{% endblock %}
{# templates/modular/hero.html.twig #}
<section class="section-hero">
<h1>{{ page.header.title_large ?? page.title }}</h1>
<p>{{ page.header.subtitle }}</p>
{% if page.header.cta_url %}
<a href="{{ page.header.cta_url }}" class="btn btn-primary">
{{ page.header.cta_text ?? 'Learn more' }}
</a>
{% endif %}
</section>
Working with Media Files
{# Images from page directory #}
{% set images = page.media.images %}
{% for image in images %}
{# Resize while maintaining aspect ratio #}
<img src="{{ image.cropResize(400, 300).url }}"
alt="{{ image.attribute('alt') }}"
loading="lazy">
{% endfor %}
{# Specific image #}
{% set img = page.media['hero.jpg'] %}
{% if img %}
<picture>
<source srcset="{{ img.format('webp').resize(1200, 0).url }}" type="image/webp">
<img src="{{ img.resize(1200, 0).url }}" alt="{{ page.title }}">
</picture>
{% endif %}
my-theme.yaml — Theme Configuration
# user/themes/my-theme/my-theme.yaml
enabled: true
favicon: images/favicon.png
google_analytics_id: ''
show_breadcrumbs: true
sidebar_position: right # left, right, none
posts_per_page: 10
social:
twitter: ''
telegram: ''
In template: {{ theme_config.google_analytics_id }}, {{ theme_config.posts_per_page }}.
Grav Twig Extensions
Grav adds global objects and filters:
{# Global objects #}
{{ grav.language.getLanguage() }} {# current language #}
{{ grav.user.username }} {# current user #}
{{ site.title }} {# site name #}
{{ page.url }} {# page URL #}
{{ base_url_absolute }} {# absolute base URL #}
{{ theme_url }} {# theme URL #}
{# Filters #}
{{ 'hello world'|t }} {# translation #}
{{ somevar|defined('default') }} {# default value #}
{{ page.date|date('d.m.Y') }}
{# Functions #}
{{ url('theme://images/logo.png') }}
{{ random(['a', 'b', 'c']) }}
Development Timelines
| Theme | Composition | Timeline |
|---|---|---|
| Basic theme from design | 5–8 templates, no modular | 1–2 weeks |
| Full theme with modular | 8–15 templates, blueprints | 3–5 weeks |
| Multilingual theme | + translations, language menus | +3–5 days |







