Skip to content

Menu, widgets and appearance

A theme should offer not only templates but also a surface the administrator can shape without writing code: which menu appears where, which boxes go in the sidebar, what the brand colours are. This page explains how these are wired up.

You build menus in Studio → Menu Management; a location says where a menu will be drawn in the theme. The theme declares its locations in theme.json:

{
    "menu_locations": {
        "main_header": "Main header",
        "footer": "Footer",
        "footer_legal": "Legal links"
    }
}

The administrator assigns a menu to one of these locations. In the template you draw it in two ways:

{# 1. Ready-made markup: including rel, target, icon and CSS classes #}
{{ cp_menu_location('main_header') }}

{# 2. Your own markup: the item tree #}
<nav aria-label="Main menu">
    <ul>
    {% for item in cp_menu_location_items('main_header') %}
        <li>
            <a href="{{ item.url }}"{{ item.openInNewTab ? ' target="_blank" rel="noopener"' : '' }}>{{ item.label }}</a>
            {% if item.children %}
                <ul>{% for child in item.children %}<li><a href="{{ child.url }}">{{ child.label }}</a></li>{% endfor %}</ul>
            {% endif %}
        </li>
    {% endfor %}
    </ul>
</nav>

Item fields: label, url, openInNewTab, children (nested in the same form). If no menu is assigned to the location, the item list is empty; handle the empty state gracefully in the template.

Modules can also declare locations (MenuLocationProviderInterface); these appear in the administrator's choice list next to the theme's locations. Location ids must be stable: renaming breaks the existing assignment.

Widget areas

A widget is a piece of content placed in an area of the page (sidebar, footer column) ("Recent posts", "Custom HTML", "Tag cloud"). The administrator adds and orders widgets in areas from Studio → Widget.

Drawing the area in a template:

<aside class="sidebar">
    {{ cp_widget_area('blog_sidebar') }}
</aside>

cp_widget_area returns an empty string if the Widget module is off; on a site without the Widget module the theme is still drawn. The area must be declared, otherwise it does not appear in Studio (a module or theme declares its areas with WidgetAreaProviderInterface).

Writing your own widget type

A module adds a new widget type by implementing WidgetTypeInterface; the Widget module collects it, offers it in Studio and draws it. The core does not import the type class; on a site without the Widget module the type is silently not used.

final class RecentNotesWidget implements WidgetTypeInterface
{
    public function id(): string { return 'notes.recent'; }
    public function label(): string { return 'notes.widget.recent'; }
    public function description(): string { return 'notes.widget.recent_help'; }
    public function icon(): string { return 'heroicons:pencil-square'; }
    public function group(): string { return 'content'; }

    public function fields(): array
    {
        return [
            WidgetField::text('title', 'notes.widget.title', 'Recent notes'),
            WidgetField::number('limit', 'notes.widget.limit', 5, 1, 20),
            WidgetField::checkbox('show_dates', 'notes.widget.dates', true),
        ];
    }

    public function render(array $settings, WidgetRenderContext $context): string
    {
        // Escape the output: widget HTML is accepted as safe.
        return '<div class="widget">…</div>';
    }
}

Field types: text, textarea, html, number, checkbox, url, select. If you make a field translatable(), a separate value is entered for each language. The type is collected automatically with autoconfigure.

Brand and colours

From the AACP → Appearance → Theme options screen the administrator chooses the site's logo, favicon, social sharing image, colours and sizes. The theme gets them in the template like this:

<link rel="icon" href="{{ cp_appearance().favicon }}">
<img src="{{ cp_appearance().logo }}" alt="{{ 'website.title'|trans }}" height="{{ cp_appearance().metrics.logo_height }}">
<style{{ csp_nonce_attr() }}>{{ cp_appearance_css() }}</style>

cp_appearance_css() writes the colours the administrator saved into :root as CSS variables. A colour left empty stays at the theme's default; each colour saved overrides only that one variable. That is why your theme's CSS must use these variables:

.btn-primary { background: var(--cp-primary); color: var(--cp-white); }
a { color: var(--cp-accent); }

Variable contract

Group Variables (default)
Accent --cp-primary (#4A7C9B), --cp-primary-dark, --cp-primary-light, --cp-accent (#4AADE4), --cp-accent-hover, --cp-gold
Surface --cp-dark, --cp-darker, --cp-light, --cp-lighter, --cp-white
Text --cp-text, --cp-text-secondary, --cp-text-muted, --cp-border
State --cp-success, --cp-danger

Sizes: logo_height (32 px; 16-160), logo_width (0 = automatic), site_width (1200 px; 960-1680), navbar_height (64 px; 48-120).

If you are writing a dark/light theme you can define your own variables (like --gh-*) and point the --cp-* variables at them; Githeme follows this route.

Fonts

From the AACP → Appearance → Fonts screen the administrator chooses body, heading, code and serif fonts (and separate ones for H1-H3). The chosen fonts are downloaded locally to the server; no CDN is called (privacy and CSP). The result is a single CSS file:

{% set cpFonts = cp_font_stylesheet() %}
{% if cpFonts %}<link rel="stylesheet" href="{{ cpFonts }}">{% endif %}

Put this link after your theme's CSS: the :root block overrides the theme's default stacks. If the administrator has not installed a font, cp_font_stylesheet() is empty and the theme defaults apply.

Cookie consent is turned on by an administrator setting (consent.enabled). The two things the theme must do:

{{ cp_consent_mode() }}       {# inside <head>, before every other script: Google Consent Mode v2 defaults #}
{{ cp_cookie_consent() }}     {# at the end of the body: the consent bar #}

Write scripts that need consent with type="text/plain" and data-cp-consent; they run only when the relevant category is approved:

<script type="text/plain" data-cp-consent="analytics" src="/js/counter.js"></script>

The categories are analytics and marketing; necessary cookies are always allowed. A link or button carrying data-cp-consent-open reopens the preferences.

Media picker texts

If you will use the media picker in a template outside Studio (e.g. profile image upload on the front end), pass the picker's translation texts with a JSON block:

<script type="application/json" id="cp-media-picker-i18n">{{ {
    title: 'media.picker.title'|trans,
    close: 'media.picker.close'|trans,
    loading: 'media.picker.loading'|trans,
    uploadFailed: 'media.picker.upload_failed'|trans,
    listFailed: 'media.picker.list_failed'|trans,
}|json_encode|raw }}</script>
Last updated: 13.10.2026