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 example meridian.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.

flowchart LR I[config/install/meridian.settings.yml<br/>defaults at theme install] --> C[(meridian.settings<br/>active configuration)] F[Appearance settings form<br/>theme-settings.php alter] -->|submit| C G[(system.theme.global)] --> P[ThemeSettingsProvider<br/>getSetting] C --> P P --> H[preprocess code] H --> T[Twig classes and variables] H --> S[CSS custom properties]

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:

  1. requires the file THEME.theme-settings.php and the file THEME.theme of 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);

  2. temporarily makes the theme being edited the active theme, so that code reading a setting without naming a theme gets the right one;

  3. 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.

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

What the schema buys you:

  • Validation. The Choice constraint is the allow-list that makes it safe to turn a stored string into a CSS class later. color_hex is a core data type that accepts a hexadecimal colour and allows NULL. 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 label or text becomes 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],
      ],
    ],
  ];
}

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 form config.object:property.path. A nested key is written with dots, for example meridian.settings:third_party_settings.meridian_events.show_badge.

  • The #states selector uses name="show_ticket_banner" because the elements are inside a details, which does not change the submitted names unless #tree is set. If you set #tree on the container, the names become meridian[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:

  1. fills the default value from the config property when the form is built (an explicit #default_value on the same element takes precedence);

  2. validates the submitted value against the schema constraints of that property, so the Choice and Length constraints above produce a normal form error next to the field;

  3. 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);
}

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 %}

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 Appearance  Settings  Color scheme, 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 select that 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 NULL as 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 by drush updatedb (or drush 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:import as part of drush 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

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 as red;}body{display:none would 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 needs style-src to 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-visible style 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_target string has the wrong object name, or an explicit #default_value overrides 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/install is 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’s config/install or 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 with DeprecationHelper for 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.settings to 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_target is available in configuration forms, including the theme settings form.

  • Drupal 11.3: theme_get_setting() is deprecated in favour of ThemeSettingsProvider (removal in Drupal 13.0.0); themes can implement hooks as classes in src/Hook; the theme-engine-specific ENGINE_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.

Checklist

  • Settings are few, named by purpose and described in the form.

  • config/install/THEME.settings.yml has a default for each, and config/schema/THEME.schema.yml types and constrains it.

  • Every element uses #config_target (no custom submit handler unless a transformation is needed).

  • Values are read with ThemeSettingsProvider (or DeprecationHelper for 10.x), validated against an allow-list, and printed through Twig autoescaping.

  • Cache tags config:THEME.settings accompany 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.