Customizer options, menus and widgets
A theme declares what it can be customised with, and core builds the screens: the Customizer, the menu editor and the widget areas. You write no admin code. This page covers each one, and the theme's optional boot.php.
Customizer options
Add a customizer key to theme.json. Options are grouped into sections:
"customizer": {
"brand": {
"label": "Brand",
"description": "Colours and layout.",
"options": {
"accent": { "type": "color", "label": "Accent", "default": "#e63329" },
"content_width": { "type": "range", "label": "Page width", "min": 960, "max": 1600, "step": 20, "default": 1200, "unit": "px" },
"body_font": { "type": "font", "label": "Body font", "default": "Georgia, serif" },
"card_layout": { "type": "select", "label": "Post list", "choices": { "grid": "Grid", "list": "List" }, "default": "grid" },
"show_author": { "type": "toggle", "label": "Show the author", "default": true },
"hero": { "type": "image", "label": "Hero image" }
}
}
}
Site owners edit them under Appearance → Customize, with a live preview.
| Type | Value | Notes |
|---|---|---|
text, textarea, url | string | placeholder; rows and mono for textarea |
color | #rrggbb | Validated as a hex colour |
number, range | number | min, max, step, unit; clamped to the range |
font | CSS font stack | Braces and semicolons are refused |
select | one of choices | choices is required |
toggle | boolean | |
image | URL | Picked from the media library |
Every option can also have help (text under the field) and css_var (see below). Option keys must be lowercase letters, digits and underscores. A malformed option is dropped, with a line in the log, and the rest of the screen still works. A theme can't override core's own sections, such as the site identity.
Options become CSS variables
Every color, number, range and font option is printed by bh_head() as a custom property named --bh- plus the key, with underscores turned into dashes:
accent → --bh-accent: #e63329
content_width → --bh-content-width: 1200px (unit appended)
body_font → --bh-body-font: Georgia, serif
Write your stylesheet against them, with the declared default as a fallback:
:root {
--t-accent: var(--bh-accent, #e63329);
--t-width: var(--bh-content-width, 1200px);
}
.button { background: var(--t-accent); }
.container { max-width: var(--t-width); }
These options update the preview instantly, with no reload. Set "css_var": "--my-name" to choose the property name yourself. Options that change markup (select, toggle, image, text) reload the preview instead. "preview": "css" or "preview": "reload" overrides the choice.
Reading options in PHP
For options that change markup rather than style:
<?php if (bh_theme_option('show_author', true)): ?>
<span class="byline"><?= htmlspecialchars($post['author_name']) ?></span>
<?php endif; ?>
<div class="posts posts--<?= htmlspecialchars(bh_theme_option('card_layout', 'grid')) ?>">
bh_theme_option() returns the pending value inside the Customizer preview and the saved value everywhere else.
Tip: Until an option has been saved once,
bh_theme_option()returns the default you pass in code, not the one intheme.json. Pass the same default in both places.
Menu locations
Declare where menus can go:
"menu_locations": {
"primary": "Main menu",
"footer": "Footer links",
"utility": "Small links above the header"
}
Site owners build menus under Appearance → Menus and assign them to your locations. Print them with menu_html():
<?= menu_html($primary_menu, ['class' => 'bh-menu site-nav', 'aria' => 'Primary']) ?>
<?= menu_html($footer_menu, ['class' => 'bh-menu footer-nav', 'aria' => 'Footer']) ?>
<?= menu_html(menu_at('utility'), ['class' => 'bh-menu bh-menu--stack utility-nav']) ?>
$primary_menu and $footer_menu are passed to every template. Other locations need menu_at(). menu_html() prints nested lists up to three levels, with aria-haspopup and aria-expanded on parents. Echo menu_assets() once in <head> to get the dropdown styles and behaviour. They apply to lists with the bh-menu class, so keep it in class: the option replaces the default rather than adding to it. Add bh-menu--stack for a vertical menu. Then restyle .bh-menu and .bh-submenu to fit your theme.
Widget areas
Declare areas, preferably with wrapper markup so widgets pick up your styles:
"widget_areas": {
"sidebar": {
"name": "Sidebar",
"description": "Beside posts and pages.",
"before_widget": "<section class=\"widget %2$s\">",
"after_widget": "</section>",
"before_title": "<h2 class=\"widget__title\">",
"after_title": "</h2>"
},
"footer": "Footer"
}
Site owners fill them under Appearance → Widgets. Print an area only when it has widgets, so you don't reserve an empty column:
<?php if (has_widget_area('sidebar')): ?>
<aside class="sidebar"><?= widget_area('sidebar') ?></aside>
<?php endif; ?>
Widgets a theme provides
A theme can add widgets in widgets.php, which returns an array. Core reads it, prefixes each key with theme.{slug}., and registers them. They then appear in widget areas and in the editor's Widget block.
<?php
// content/themes/my-theme/widgets.php
return [
'promo' => [
'title' => 'Promo box',
'description' => 'A heading and a short line of text.',
'fields' => [
['key' => 'heading', 'label' => 'Heading', 'type' => 'text'],
['key' => 'text', 'label' => 'Text', 'type' => 'textarea'],
['key' => 'tone', 'label' => 'Tone', 'type' => 'select', 'options' => ['light', 'dark'], 'default' => 'light'],
],
'render' => function (array $settings, string $surface): string {
$heading = trim((string) ($settings['heading'] ?? ''));
if ($heading === '') return '';
$tone = ($settings['tone'] ?? 'light') === 'dark' ? 'dark' : 'light';
return '<div class="promo promo--' . $tone . '"><strong>' . htmlspecialchars($heading) . '</strong>'
. '<p>' . htmlspecialchars((string) ($settings['text'] ?? '')) . '</p></div>';
},
],
];
fields is a list, and each field has a key. Field types: text, textarea, number, checkbox and select (with options). The render callback gets the saved settings and the surface (frontend, editor or dashboard), and returns HTML. Escape everything you print. A widget that throws costs that widget only.
Comments and the author box
Core provides both, so every theme gets the CSRF token, the spam honeypot, replies and the privacy rules right. Pass your own classes to make them match:
<?= bh_comment_form($post, [
'class' => 'card',
'title_class' => 'card__title',
'label_class' => 'field__label',
'input_class' => 'input',
'textarea_class' => 'input input--area',
'button_class' => 'btn btn--primary',
'label_submit' => 'Post comment',
'placeholder' => 'Share your thoughts…',
'show_url' => false,
'closed_text' => 'Comments are closed.',
]) ?>
<?= bh_author_box($post, [
'title' => 'About the author',
'class' => 'card',
'avatar_size' => 56,
'link_text' => 'All %d posts',
]) ?>
More comment form options:
- Text:
title,title_tag,label_name,label_email,label_url,label_comment,label_submitting,label_reply,label_cancel,logged_in_text. - Behaviour:
rows,show_logout,id(needed only for a second form on one page). - Styles:
'styles' => falsedrops core's small stylesheet entirely.
Signed-in members aren't asked for a name or email, and the server enforces the same rule.
A comment that's published immediately reloads the page at that comment. To handle it yourself, listen for bh:comment-posted on the form and call preventDefault(). The event's detail has status, pending, message, comment, count and postId.
boot.php: filters for the theme
boot.php at the theme's root runs once per request, after apps have booted and before anything renders. $app (the application) and $hooks (the hook registry) are in scope. Use it to register filters, never to print anything:
<?php
// content/themes/my-theme/boot.php
// Prefix category and tag page titles.
$hooks->addFilter('seo.title', function (string $title, array $ctx): string {
return $ctx['type'] === 'archive' ? 'Topic: ' . $title : $title;
}, 10, 2);
// Change the comment form's defaults for this theme.
$hooks->addFilter('comment_form.args', function (array $args): array {
$args['label_submit'] = 'Send';
return $args;
});
A boot.php that throws is logged, and the site carries on without its filters. Themes can't register routes, AJAX actions or database tables; put those in an app.
Leave a comment