Drupal: Theme Settings and Configurable Themes
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
A Drupal theme is more than templates and CSS: it can offer options that a site builder changes in the administration
interface without touching code. Which accent colour does the site use, is the header dark or light, does the body text
use a serif or a sans-serif font, is the "Tickets" banner shown? Those choices are theme settings. This page explains
the complete mechanism for a themer or developer: the two settings screens under Appearance, the file
THEME.theme-settings.php and hook_form_system_theme_settings_alter(), default values in
config/install/THEME.settings.yml, the configuration schema that validates them, the #config_target property that
removes the need for a submit handler, reading values back in preprocess code, Twig and CSS, the deprecation of
theme_get_setting() in favour of the ThemeSettingsProvider service, how to add a setting to a theme that is already
installed, and how to offer colour and font options now that the Color module no longer exists in core.
It was written against Drupal 11.4.x, with Drush 13 or 14 and PHP 8.3 or later, and checked against the official
documentation and the core source on 2026-10-30. The running example is the Meridian Museum and its meridian theme,
generated with Starterkit (see Starterkit and sub-themes). Drupal 12.0.0
is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as
upcoming; no new site should start on Drupal 10.
What theme settings are
Theme settings are configuration, stored as config objects, and they come in two layers:
-
Global settings (
system.theme.global): the defaults shared by every theme. They hold the logo and favicon choices and a handful of toggles (user pictures in posts and comments, user verification status in comments, shortcut icon). The screen is Appearance > Settings (/admin/appearance/settings). -
Per-theme settings (
THEME.settings, for examplemeridian.settings): the values for one theme. They override the global ones and are where a theme stores its own options. The screen is the theme’s own Settings tab (/admin/appearance/settings/meridian), which shows the global fields plus whatever the theme adds.
Who uses what:
-
The site builder or administrator (permission
administer themes) fills in the form and exports the result with configuration management (see Configuration management). -
The themer decides which options exist, validates them and turns them into CSS classes, custom properties or Twig variables.
-
A module developer can add settings for every theme with a form alter (see "Settings from a module" below).
The reason this feature exists is the same as for any other configuration: a museum editor can switch the header from light to dark on the staging site, review it, export the change and import it on production, without a developer editing a stylesheet. The drawback is that every setting is another state the theme must handle, so options should be few and meaningful.
The settings form and where the alter hook runs
The core form behind both screens is Drupal\system\Form\ThemeSettingsForm, a configuration form with the form ID
system_theme_settings. When the per-theme page is built, the form walks the theme and each of its base themes and:
-
requires the file
THEME.theme-settings.phpand the fileTHEME.themeof every theme in the chain, when they exist, and records them in the form’s build info so that the files are loaded again when the cached form is rebuilt (for example during an AJAX request); -
temporarily makes the theme being edited the active theme, so that code reading a setting without naming a theme gets the right one;
-
invokes
hook_form_system_theme_settings_alter()for that theme. The base theme’s alteration runs before the sub-theme’s alteration, so a sub-theme can change or remove what its base theme added.
Two consequences matter in practice. The hook is not called on the global Appearance > Settings screen, only on the per-theme screens. And it is called only for the theme being edited and its base themes: a theme that is installed but is not part of that chain does not take part.
The documented signature is:
function hook_form_system_theme_settings_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state)
The hook belongs to a theme the way hook_form_alter() belongs to a module: the function name starts with the theme’s
machine name (meridian_form_system_theme_settings_alter). It can live in THEME.theme-settings.php, which is the
convention because the file is loaded only for this form and keeps the .theme file small, or in THEME.theme.
The official guide Creating advanced theme settings is the page that this one expands. Its text still describes the
Drupal 6 and 7 mechanisms (a .info line for defaults, variable_set() for saving), which do not apply to Drupal 8 and
later; the Drupal 8+ equivalents are the ones in this page.
Source: Creating advanced theme settings
Defaults in config/install and the schema
A new setting needs three things: a default, a schema and a form element. The default is a config file in the theme’s
config/install directory, named after the theme with the suffix .settings.yml. Config in this directory is imported
once, when the theme is installed.
# web/themes/custom/meridian/config/install/meridian.settings.yml
accent_color: '#0b6e8a'
header_style: light
body_font: sans
show_ticket_banner: true
ticket_banner_text: 'Tickets for the new planetarium show are on sale'
Standard keys such as logo.use_default, favicon.use_default and the features map may also appear in this file, but
only add them when you want to change a default; the global settings provide the rest. Core’s own Olivero theme ships a file of exactly this shape, with the keys
mobile_menu_all_widths, site_branding_bg_color and base_primary_color beside the standard ones.
The schema describes the type of every key. It lives in config/schema/THEME.schema.yml and the top-level key must
equal the config object name, meridian.settings. The type theme_settings is defined by core and already knows the
standard keys (features, logo, favicon and third_party_settings), so the theme only declares its own keys in
mapping:
# web/themes/custom/meridian/config/schema/meridian.schema.yml
meridian.settings:
type: theme_settings
label: 'Meridian theme settings'
mapping:
accent_color:
type: color_hex
label: 'Accent colour'
header_style:
type: string
label: 'Header style'
constraints:
Choice:
choices:
- light
- dark
body_font:
type: string
label: 'Body font'
constraints:
Choice:
choices:
- sans
- serif
- humanist
show_ticket_banner:
type: boolean
label: 'Show the ticket banner'
ticket_banner_text:
type: label
label: 'Ticket banner text'
constraints:
Length:
max: 120
Source: Configuration schema/metadata
What the schema buys you:
-
Validation. The
Choiceconstraint is the allow-list that makes it safe to turn a stored string into a CSS class later.color_hexis a core data type that accepts a hexadecimal colour and allowsNULL. With#config_target(below), the constraints run when the form is submitted, so no hand-written validate handler is needed. -
Correct types in exports. A boolean exports as
true, not"1". -
Translation. A key typed
labelortextbecomes translatable through the configuration translation module (see Multilingual). The ticket banner text above is shown to visitors, so a Spanish version can be entered without a code change. -
Test and tooling support. A missing mapping entry is reported as a schema error by configuration tests and by schema inspection tools, which is the usual way of catching a typo in a key.
The general theory (types, mappings, sequences, constraints and FullyValidatable) is covered in
Configuration API and schema. This page only adds what is specific
to themes.
Writing THEME.theme-settings.php
Create web/themes/custom/meridian/theme-settings.php (the file name is the literal theme-settings.php, not
prefixed by the theme name in the Drupal 8+ loading code; the documentation writes it as THEME.theme-settings.php for
readability). A first version adds a fieldset with four elements and ties each one to a config key with
#config_target:
<?php
/**
* @file
* Theme settings for the Meridian Museum theme.
*/
use Drupal\Core\Form\FormStateInterface;
/**
* Implements hook_form_system_theme_settings_alter().
*/
function meridian_form_system_theme_settings_alter(array &$form, FormStateInterface $form_state): void {
$form['meridian'] = [
'#type' => 'details',
'#title' => t('Meridian options'),
'#open' => TRUE,
];
$form['meridian']['accent_color'] = [
'#type' => 'color',
'#title' => t('Accent colour'),
'#description' => t('Used for links, buttons and the ticket banner. Pick a colour with enough contrast against white text.'),
'#config_target' => 'meridian.settings:accent_color',
];
$form['meridian']['header_style'] = [
'#type' => 'radios',
'#title' => t('Header style'),
'#options' => [
'light' => t('Light'),
'dark' => t('Dark'),
],
'#config_target' => 'meridian.settings:header_style',
];
$form['meridian']['body_font'] = [
'#type' => 'select',
'#title' => t('Body font'),
'#options' => [
'sans' => t('Sans-serif (system fonts)'),
'serif' => t('Serif (system fonts)'),
'humanist' => t('Humanist (self-hosted)'),
],
'#config_target' => 'meridian.settings:body_font',
];
$form['meridian']['show_ticket_banner'] = [
'#type' => 'checkbox',
'#title' => t('Show the ticket banner'),
'#config_target' => 'meridian.settings:show_ticket_banner',
];
$form['meridian']['ticket_banner_text'] = [
'#type' => 'textfield',
'#title' => t('Ticket banner text'),
'#maxlength' => 120,
'#config_target' => 'meridian.settings:ticket_banner_text',
'#states' => [
'visible' => [
':input[name="show_ticket_banner"]' => ['checked' => TRUE],
],
],
];
}
Source: Creating advanced theme settings
Notes on the example:
-
In a theme’s procedural file,
t()is the right translation function, and\Drupal::calls are acceptable because there is no constructor to inject into. In a class (see the next section) use the translation trait instead. -
The element names (
accent_color,body_font) are only form keys; the stored key comes from#config_target, in the formconfig.object:property.path. A nested key is written with dots, for examplemeridian.settings:third_party_settings.meridian_events.show_badge. -
The
#statesselector usesname="show_ticket_banner"because the elements are inside adetails, which does not change the submitted names unless#treeis set. If you set#treeon the container, the names becomemeridian[show_ticket_banner]and the selector must follow. -
No submit handler is needed. The parent configuration form saves every element that has a
#config_target.
What #config_target does
#config_target arrived in Drupal 10.2 for forms that extend ConfigFormBase, and ThemeSettingsForm is one of them.
For every element that carries it, the form:
-
fills the default value from the config property when the form is built (an explicit
#default_valueon the same element takes precedence); -
validates the submitted value against the schema constraints of that property, so the
ChoiceandLengthconstraints above produce a normal form error next to the field; -
writes the value back into the config object on submit.
When the stored and displayed formats differ, use the Drupal\Core\Form\ConfigTarget class instead of a string, with
optional fromConfig and toConfig callables (for example to show a list as a textarea with one entry per line).
Prefer static methods to closures in those callables, because closures complicate AJAX rebuilds. One known limit: two
targets that map to overlapping paths in the same config (for example a text_format element and a sibling for its
format) raise a logic exception, so keep one element per leaf key.
Older tutorials, and the official page quoted above in its Drupal 6/7 form, set #default_value from
theme_get_setting() and register a custom submit handler that copies the values to config. That still works, but it
duplicates the schema in code and skips constraint validation.
The same hook as an object-oriented hook
Drupal 11.3 allows themes to use object-oriented hooks: a class in src/Hook with a #[Hook('...')] method, which can be
autowired like a module hook class. The change record lists the alter hooks that a theme may implement this way (the
family form_alter, form_BASE_FORM_ID_alter and form_FORM_ID_alter is among them), limits themes to the
attribute without the order or module parameters, and says hooks of non-active themes do not run. Core’s own
Olivero theme implements the settings alteration as such a class, under the hook name
form_system_theme_settings_alter. A theme that must also run on Drupal 10.x or on 11.0 to 11.2 keeps the procedural
function (and may add #[LegacyHook] to it when it also has a class implementation).
<?php
declare(strict_types=1);
namespace Drupal\meridian\Hook;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Settings form options of the Meridian theme.
*/
class MeridianThemeSettingsHooks {
use StringTranslationTrait;
/**
* Implements hook_form_FORM_ID_alter() for system_theme_settings.
*/
#[Hook('form_system_theme_settings_alter')]
public function formSystemThemeSettingsAlter(array &$form, FormStateInterface $form_state): void {
$form['meridian']['accent_color'] = [
'#type' => 'color',
'#title' => $this->t('Accent colour'),
'#config_target' => 'meridian.settings:accent_color',
];
}
}
A theme that has both the procedural function and the class would run the code twice; migrate one hook at a time and keep
only one implementation per hook name. Hooks that are not in the supported list, and the file THEME.theme-settings.php
of older tutorials, remain procedural. For the general rules about ordering, LegacyHook and which hooks must stay
procedural, see Hooks and OOP hooks.
Reading a setting
In PHP: ThemeSettingsProvider replaces theme_get_setting()
For years the answer was theme_get_setting('accent_color'). The function is deprecated in Drupal 11.3.0 and will be
removed in Drupal 13.0.0; calling it triggers a deprecation notice. The replacement is the
Drupal\Core\Extension\ThemeSettingsProvider service, whose method getSetting($setting_name, $theme) has the same
semantics: without a theme name it uses the active theme, the value comes from the global settings overridden by the
theme’s own, and a missing key gives NULL. The service id is the class name, so it can be autowired.
Internally the provider keeps a memory-cache entry per theme for the duration of the request, tagged with
config:core.extension, config:system.theme.global and config:THEME.settings. It merges the global config with
THEME.settings, sets any standard feature the theme’s .info.yml does not declare (the features list) to NULL, and
computes logo.url and favicon.url. Two details are easy to miss: the per-theme config is read from the theme’s own
THEME.settings only, so a sub-theme must ship its own defaults rather than count on the base theme’s file; and
dotted names address nested keys (logo.url, features.favicon).
In procedural code, call the service statically; in a class, inject it:
<?php
/**
* @file
* Meridian theme preprocess code.
*/
use Drupal\Component\Utility\DeprecationHelper;
use Drupal\Core\Extension\ThemeSettingsProvider;
/**
* Reads a Meridian setting on Drupal 10.x and 11.x.
*/
function meridian_get_setting(string $name): mixed {
return DeprecationHelper::backwardsCompatibleCall(
currentVersion: \Drupal::VERSION,
deprecatedVersion: '11.3.0',
currentCallable: fn() => \Drupal::service(ThemeSettingsProvider::class)->getSetting($name, 'meridian'),
deprecatedCallable: fn() => theme_get_setting($name, 'meridian'),
);
}
A theme that targets 11.3 or later calls the service directly and drops the helper. Note that the provider is an
ordinary service, not a hook: inside a class of src/Hook the constructor takes ThemeSettingsProvider and the
settings are read without any static call.
<?php
declare(strict_types=1);
namespace Drupal\meridian\Hook;
use Drupal\Core\Extension\ThemeSettingsProvider;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Turns Meridian settings into body classes.
*/
class MeridianPreprocessHooks {
public function __construct(
protected readonly ThemeSettingsProvider $themeSettings,
) {
}
/**
* Implements hook_preprocess_html().
*/
#[Hook('preprocess_html')]
public function preprocessHtml(array &$variables): void {
$header = $this->themeSettings->getSetting('header_style');
$font = $this->themeSettings->getSetting('body_font');
// Only values from the schema allow-list may reach a class name.
if (in_array($header, ['light', 'dark'], TRUE)) {
$variables['attributes']['class'][] = 'meridian-header--' . $header;
}
if (in_array($font, ['sans', 'serif', 'humanist'], TRUE)) {
$variables['attributes']['class'][] = 'meridian-font--' . $font;
}
}
}
Theme preprocess hooks as classes require Drupal 11.3 (the change record covers hook_preprocess and
hook_preprocess_HOOK). Earlier versions use function meridian_preprocess_html(array &$variables): void in
meridian.theme, which is what every older tutorial shows and remains valid.
The two rules of the example matter more than the syntax. First, the values are checked against an allow-list before they
become part of a class name, even though the schema already constrains them: configuration can be changed by drush
config:set or an import, and the theme should not trust it blindly. Second, never print a stored string with |raw in
a template; the default Twig autoescaping is what makes a free-text option such as the ticket banner text safe.
Cacheability
A setting that changes the markup makes the page depend on configuration. The config save invalidates the cache tag
config:meridian.settings, but only render arrays that carry that tag are invalidated by it. When a setting affects
attachments, add the tag where you read it. A theme can do this in meridian_page_attachments_alter(), which is also the
place to emit a small inline stylesheet:
/**
* Implements hook_page_attachments_alter().
*/
function meridian_page_attachments_alter(array &$attachments): void {
$accent = meridian_get_setting('accent_color');
// Validate again at the point of output: only #rrggbb reaches the page.
if (is_string($accent) && preg_match('/^#[0-9a-f]{6}$/i', $accent)) {
$attachments['#attached']['html_head'][] = [
[
'#tag' => 'style',
'#value' => ':root{--meridian-accent:' . $accent . ';}',
],
'meridian_accent',
];
}
\Drupal\Core\Cache\CacheableMetadata::createFromRenderArray($attachments)
->addCacheTags(['config:meridian.settings'])
->applyTo($attachments);
}
Source: Cacheability of render arrays
The same guidance applies to a block or component that shows the ticket banner: the block’s build array declares
'#cache' => ['tags' => ['config:meridian.settings']], so a change in the form refreshes it. See
Performance and caching for cache tags, contexts and max-age in general.
In Twig
Twig has no function that reads theme settings. The Drupal 8+ pattern is to turn the setting into a variable or a class in preprocess code and print that in the template. For the banner text, a preprocess hook for the page adds the variables:
/**
* Implements hook_preprocess_page().
*/
function meridian_preprocess_page(array &$variables): void {
$variables['ticket_banner'] = [
'show' => (bool) meridian_get_setting('show_ticket_banner'),
'text' => (string) meridian_get_setting('ticket_banner_text'),
];
}
{# templates/layout/page.html.twig #}
{% if ticket_banner.show and ticket_banner.text %}
<aside class="ticket-banner" role="region" aria-label="{{ 'Tickets'|t }}">
<p>{{ ticket_banner.text }}</p>
</aside>
{% endif %}
Source: Creating advanced theme settings
The text is printed with {{ ticket_banner.text }}, so Twig escapes it. For the template naming and debugging of
page.html.twig, see Twig templates and debugging and
Template suggestions and preprocess.
Colour and font options without the Color module
The Color module let a site builder choose a palette from a form and regenerated a stylesheet (and images) for themes that
shipped a color/color.inc file. It depended on the Farbtastic JavaScript library. Core removed the module and Farbtastic
in Drupal 10.0; a contributed version of the module exists, but no core theme uses it any more, and the removal discussion
recommended alternatives for long-term use. Older tutorials, and any guide written for Drupal 8 and 9 that shows Bartik
or , describe a mechanism that is not present in Drupal 10, 11 or 12. Do not
install the contrib module for a new theme.
The replacement is simpler and needs no generated files: store the choice as a setting, expose it as a CSS custom
property or a class, and write the stylesheet once with var() references. This also works with the Single Directory
Components of the meridian theme (see Single Directory Components).
A free colour with a custom property
The theme library defines the default value and uses the variable:
/* web/themes/custom/meridian/css/base/variables.css */
:root {
--meridian-accent: #0b6e8a;
}
/* web/themes/custom/meridian/css/components/button.css */
.button--primary,
.ticket-banner {
background-color: var(--meridian-accent);
color: #fff;
}
.ticket-banner a:focus-visible {
outline: 3px solid #fff;
outline-offset: 2px;
}
Because the page attachment shown earlier emits :root{--meridian-accent:#...} after the library CSS, the setting wins
without any build step. The values the theme accepts is a trade-off:
-
Free hex colour (
'#type' => 'color'or a text field): flexible, but nothing stops an editor from choosing a colour with poor contrast. Document the contrast requirement in the field description and, if the theme has a build step, consider computing the text colour from the accent. -
A short list of schemes (a
selectthat maps to class names or fixed hex values): predictable and testable, which is also how core’s Olivero offers its Firehouse, Ice, Plum and Slate schemes alongside a custom colour.
Olivero’s own approach is a useful reference: its form attaches a JavaScript library that updates the colour fields from
the selected scheme, its schema types the colour key as color_hex, and its stylesheet derives the other colours from the
base one. Note, however, that Olivero, Claro and Stable 9 are removed from core in Drupal 12, so a theme should not depend
on them as a base theme to inherit this behaviour; copy the idea, not the dependency.
A font with an allow-list
Never store a font-family string typed by the editor and print it into CSS. Store a key and map it to fixed CSS in the
theme. The body_font setting above becomes a body class, and the stylesheet defines what each class means:
/* web/themes/custom/meridian/css/base/typography.css */
.meridian-font--sans {
--meridian-font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.meridian-font--serif {
--meridian-font-body: Georgia, "Times New Roman", serif;
}
.meridian-font--humanist {
--meridian-font-body: "Meridian Humanist", "Segoe UI", sans-serif;
}
body {
font-family: var(--meridian-font-body, system-ui, sans-serif);
}
@font-face {
font-family: "Meridian Humanist";
src: url("../../fonts/meridian-humanist.woff2") format("woff2");
font-display: swap;
}
Self-hosting the font file avoids a request to a third-party font service and the privacy questions that go with it; the
stylesheet above relies on the theme’s fonts directory and the libraries.yml file that attaches the CSS (see
Libraries and assets). If you do load fonts from an external host, pin the
URL and review your content security policy.
Choosing between a setting, a block and a field
A setting is site-wide and theme-wide. If the editor needs a different colour per exhibition page, a setting is the wrong tool: use a field on the content type (see Content types and fields) and a display or template that reads it. If the content is editorial copy that several pages share, a block or a custom block is better. Theme settings are for presentation choices the whole site shares.
Adding a setting to a theme that is already installed
config/install is read only at install time. A new key added later to meridian.settings.yml does not appear on an
existing site, so getSetting() returns NULL for it there. There are three clean ways to supply the value:
-
Code default. Treat
NULLas the default in the reading code (?? 'sans'). Simple, but the exported configuration does not show the value until someone saves the form. -
A post-update function. A theme may ship
THEME.post_update.php, run bydrush updatedb(ordrush deploy), as core Olivero does for its settings. This writes the value once on every environment:<?php /** * @file * Post-update functions for the Meridian theme. */ /** * Adds the default header style to existing sites. */ function meridian_post_update_add_header_style(): void { $settings = \Drupal::configFactory()->getEditable('meridian.settings'); if ($settings->get('header_style') === NULL) { $settings->set('header_style', 'light')->save(); } } -
Configuration management. Make the change on a development site, save the form, and export; the change then reaches other environments with
drush config:importas part ofdrush deploy.
ddev drush config:get meridian.settings
ddev drush config:set meridian.settings header_style dark --yes
ddev drush config:export --yes
ddev drush deploy
Source: drush config:set
The reverse also matters: when a setting is removed from the schema, remove the stored key as well (a post-update with
clear('old_key')->save()), or the leftover key fails schema checks in tests. If the theme is uninstalled, its
THEME.settings config is deleted with it, and so are the values.
Two more points about environments. A value can be overridden for one environment in settings.php
($config['meridian.settings']['header_style'] = 'dark';), which is read by getSetting() but does not appear in the
form as stored config; use it for staging banners, not for editorial options. And the Configuration Split module can keep
an environment-specific meridian.settings (see Configuration management).
Settings from a module and third-party settings
A module can add elements to every theme’s settings form by implementing hook_form_system_theme_settings_alter() itself,
and theme_settings defines a third_party_settings key for exactly this case. The meridian_events recipe could add a
"Show event badge" option for any theme:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Adds a collection option to every theme settings form.
*/
class ThemeSettingsHooks {
use StringTranslationTrait;
/**
* Implements hook_form_FORM_ID_alter() for system_theme_settings.
*/
#[Hook('form_system_theme_settings_alter')]
public function alter(array &$form, FormStateInterface $form_state): void {
$theme = $form_state->getBuildInfo()['args'][0] ?? NULL;
if (!$theme) {
return;
}
$form['meridian_collection'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show the on display badge'),
'#config_target' => $theme . '.settings:third_party_settings.meridian_collection.show_badge',
];
}
}
The module must also declare a schema type theme_settings.third_party.meridian_collection for the stored key, or the
configuration is reported as lacking a schema. Prefer a module-level setting (a settings form of its own, see
Form API) unless the option is truly a presentation choice per theme. The older
mytheme_engine_settings function that a theme engine could provide is deprecated in Drupal 11.3 and removed from
Drupal 12, with the recommendation to alter the form from a module; it is not needed for ordinary themes.
Security and accessibility
-
Escape output. Settings are administrator-controlled but are still data. Print them through Twig autoescaping, never
|raw, and pass them through an allow-list or a regular expression before they enter a CSS value or a class. The CSS example above accepts only#rrggbb; a string such asred;}body{display:nonewould otherwise break the stylesheet (or, in a<style>tag, close it). -
Permission. Changing a theme setting needs
administer themes, which should be given to administrators only. A setting that renders HTML (a banner with markup) needs a text format and its filter rules; a plain textfield with autoescaping is safer. -
Content security policy. An inline
<style>with a custom property needsstyle-srcto allow inline styles (or a nonce or hash). If your policy forbids it, use classes for a short list of schemes instead of a free colour. -
Contrast and focus. A free accent colour can fail the contrast ratio that WCAG 2.2 requires for text and for interactive component boundaries. Provide a
:focus-visiblestyle that does not depend on the accent, as in the CSS above, and test the extremes (very light and very dark). See Accessibility and performance in themes. -
Secrets. Theme settings are exported with the rest of the configuration and are readable by anyone with repository access. Never store keys, tokens or passwords in them.
Common errors and pitfalls
-
Nothing appears on the settings page. The file is not named exactly
theme-settings.php, the function name does not start with the theme’s machine name, or the cache was not rebuilt after adding the file (ddev drush cr). The hook is not called on the global Appearance settings page, only on the per-theme page. -
The form shows no saved value. The key is missing from the config object, the
#config_targetstring has the wrong object name, or an explicit#default_valueoverrides it. -
"Schema missing" or "mapping key not allowed" errors. The key is not declared in
config/schema/THEME.schema.yml, or the top-level key does not equal the config name (meridian.settings). -
A new default has no effect on the live site.
config/installis read only at install; use a post-update or import configuration (see above). -
A sub-theme loses its parent’s defaults. The provider reads only the theme’s own
THEME.settings; copy the file to the sub-theme’sconfig/installor read the base theme’s setting with an explicit theme name. -
Deprecation notices about
theme_get_setting(). Replace the call with the service, or wrap it withDeprecationHelperfor a code base that supports Drupal 10.x. -
The page shows the old colour after saving. The page was cached and the output does not carry the config cache tag; add
config:THEME.settingsto the attachments or the render arrays that use the value. -
A rebuilt AJAX form fails with "class not found". The theme-settings file is required by the form’s build info; do not move the function to a file that the form does not load.
-
The Color module is expected. Tutorials that call for
color/color.inc, "Color scheme" or Bartik refer to a module removed in Drupal 10.0.
Versions and corrections of older material
-
Drupal 10.0: the Color module and Farbtastic were removed from core; Bartik and Seven went with the same release, so no core theme provides a Color-based palette.
-
Drupal 10.2:
#config_targetis available in configuration forms, including the theme settings form. -
Drupal 11.3:
theme_get_setting()is deprecated in favour ofThemeSettingsProvider(removal in Drupal 13.0.0); themes can implement hooks as classes insrc/Hook; the theme-engine-specificENGINE_engine_settings()function is deprecated (removed in Drupal 12). -
Drupal 12: Olivero, Claro and Stable 9 are removed from core and the default admin theme is based on Gin. A custom theme that inherits options from Olivero’s form must carry its own copy. Version 12.0.0 is scheduled for the week of 2026-12-07.
Older books such as Modernizing Drupal 10 Theme Development (2023), written for Drupal 10.1, show the pattern of a
settings form whose elements take #default_value from theme_get_setting(), a custom submit handler, and \Drupal::
service calls in .theme code. In current Drupal the form uses #config_target with a schema, the value is read with
ThemeSettingsProvider, and classes in src/Hook replace procedural hooks from 11.3. Drupal 10 Masterclass (2023)
and the Drupal 10 Development Cookbook (2023) do not cover theme settings in depth; neither mentions the 11.x
deprecation. Tutorials that predate Drupal 8 use a .info line for defaults and variable_set(); both are gone.
For the surrounding theming topics see Theme .info.yml and regions, Theming overview and the render pipeline and Where older books and tutorials are outdated.
Checklist
-
Settings are few, named by purpose and described in the form.
-
config/install/THEME.settings.ymlhas a default for each, andconfig/schema/THEME.schema.ymltypes and constrains it. -
Every element uses
#config_target(no custom submit handler unless a transformation is needed). -
Values are read with
ThemeSettingsProvider(orDeprecationHelperfor 10.x), validated against an allow-list, and printed through Twig autoescaping. -
Cache tags
config:THEME.settingsaccompany output that depends on a setting. -
Existing sites receive new defaults through a post-update or configuration import.
-
No setting stores a secret, and colour options are checked for contrast.