Customizer API
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
The Customizer (Appearance > Customize, wp-admin/customize.php) is the live-preview settings screen of classic
themes: the controls sit in a sidebar, the site renders in an iframe next to them, and nothing reaches visitors until
the user clicks Publish. The Customize API is the PHP and JavaScript interface a theme (or plugin) uses to add its own
options to that screen. This page is for developers of classic and hybrid themes, and for anyone maintaining one: it
explains the four kinds of Customizer object, where the values are stored, how they are sanitized, previewed and
printed safely, and why a block theme such as Lumen Press’s lumen has no Customizer menu at all. The example adds an
accent colour and footer options to the classic theme lumen-classic.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, and WP-CLI 2.12.0, and checked on 2026-10-28. The Customize API has not changed in the 7.x releases; WordPress 7.2 (in beta, due in December 2026) announces no change to it so far.
Where the Customizer stands today
The Customizer became the standard place for theme options, and the Theme Review rules required themes to use it
instead of their own options pages. WordPress 3.4 (2012) introduced it with sections, settings, controls and
postMessage; 4.0 added panels and contextual controls (active_callback); 4.5 selective refresh; 4.6 setting
validation; 4.7 changesets, Additional CSS and visible edit shortcuts; 4.9 drafts, scheduling and changeset
locking. In 5.9, with block themes, the Customize link became hidden unless a plugin or theme hooks
customize_register. No API change has followed through 7.1.
For a classic theme the Customizer remains the supported way to expose theme options in 7.1. For a block theme
the same jobs belong to theme.json presets and the Site Editor’s Styles (see
Site Editor and Global Styles and
theme.json Settings); a hybrid theme can use both, and
Hybrid Themes and Migration to Block Themes shows
how lumen-classic's Customizer options become theme.json settings.
The four kinds of object
Everything is registered on the customize_register action, which passes the single WP_Customize_Manager instance
(the global $wp_customize). The manager only exists on Customizer requests (the controls screen, the preview iframe
and its Ajax calls), so registration code costs nothing on normal front-end requests.
| Object | Method | Role |
|---|---|---|
Panel |
|
A group of sections with its own screen (core uses panels for Menus and Widgets). Shown only if it holds at least one section with at least one control. |
Section |
|
A group of controls. It can stand alone or sit in a panel through its |
Setting |
|
One stored value: its storage type, default, capability, sanitize and validate callbacks and preview transport. It has no UI. |
Control |
|
The UI element bound to a setting (by default the setting with the same ID), placed in a section. |
A fifth object, the partial, belongs to selective refresh: $wp_customize->selective_refresh->add_partial()
names a page fragment (a CSS selector) and the PHP callback that re-renders it when its settings change.
Core’s own sections can receive theme controls: title_tagline (Site Identity, priority 20), colors (40),
header_image (60), background_image (80), static_front_page (120) and custom_css (200); new sections default to
160. The Theme Handbook keeps panels for whole features; lumen-classic uses one only to show the API.
Settings: theme mod or option
The type argument of a setting decides where the value is stored:
type |
Storage | Use |
|---|---|---|
|
One serialized array per theme, in the option |
Theme presentation options. Each theme keeps its own values, so switching themes and back restores them. |
|
Its own row in |
Site data that must survive a theme switch, which is normally plugin territory, so a theme should rarely use it. |
The other setting arguments:
| Argument | Default | Meaning |
|---|---|---|
|
|
Value the preview shows before anything is saved. It is not stored and not known to |
|
|
Capability needed to change the setting (Administrators have it; Editors do not). |
|
|
|
|
none |
Function that receives the value (and the setting) and returns the clean value to store. Runs on save and on preview. |
|
none |
Receives a |
When a sanitize_callback returns null or a WP_Error, the setting is invalid: core shows "Invalid value." and the
changeset is not published. sanitize_hex_color() returns null for anything that is not #rgb or #rrggbb, so it
both sanitizes and validates a colour.
Controls
add_control() takes a core type (text, email, url, number, range, date and other <input> types,
checkbox, textarea, radio, select, dropdown-pages) or a control class such as WP_Customize_Color_Control,
WP_Customize_Media_Control, WP_Customize_Cropped_Image_Control (4.3) or WP_Customize_Code_Editor_Control (4.9).
An active_callback (for example is_front_page) shows a control only while the preview shows a matching page.
Lumen Classic: accent colour and footer options
lumen-classic (introduced in Themes
Overview) gets a Lumen Classic panel with two sections:
-
Accent colour: a colour picker stored as the theme mod
lumen_classic_accent_color, sanitized withsanitize_hex_color, previewed instantly throughpostMessageand a small preview script, and printed as the CSS custom property--lumen-classic-accent; -
Footer: the footer text (
lumen_classic_footer_text, plain text, refreshed through a selective-refresh partial) and a checkboxlumen_classic_show_catalogue_linkwith a custom sanitize callback.
The Customizer code lives in inc/customizer.php, the preview script in js/customizer-preview.js.
Registering the objects
<?php
/**
* Customizer settings for Lumen Classic.
*
* @package Lumen_Classic
*/
const LUMEN_CLASSIC_DEFAULT_ACCENT = '#a3271f';
/**
* Registers the Lumen Classic panel, sections, settings, controls and partial.
*
* @param WP_Customize_Manager $wp_customize Customizer manager.
*/
function lumen_classic_customize_register( WP_Customize_Manager $wp_customize ) {
$wp_customize->add_panel(
'lumen_classic_options',
array(
'title' => __( 'Lumen Classic', 'lumen-classic' ),
'priority' => 130,
)
);
$wp_customize->add_section(
'lumen_classic_colors',
array(
'title' => __( 'Accent colour', 'lumen-classic' ),
'panel' => 'lumen_classic_options',
)
);
$wp_customize->add_section(
'lumen_classic_footer',
array(
'title' => __( 'Footer', 'lumen-classic' ),
'panel' => 'lumen_classic_options',
)
);
$wp_customize->add_setting(
'lumen_classic_accent_color',
array(
'type' => 'theme_mod',
'capability' => 'edit_theme_options',
'default' => LUMEN_CLASSIC_DEFAULT_ACCENT,
'sanitize_callback' => 'sanitize_hex_color',
'transport' => 'postMessage',
)
);
$wp_customize->add_control(
new WP_Customize_Color_Control(
$wp_customize,
'lumen_classic_accent_color',
array(
'label' => __( 'Accent colour', 'lumen-classic' ),
'section' => 'lumen_classic_colors',
)
)
);
$wp_customize->add_setting(
'lumen_classic_footer_text',
array(
'default' => __( 'Lumen Press, independent publishers since 1998.', 'lumen-classic' ),
'sanitize_callback' => 'sanitize_text_field',
'transport' => 'postMessage',
)
);
$wp_customize->add_control(
'lumen_classic_footer_text',
array(
'label' => __( 'Footer text', 'lumen-classic' ),
'section' => 'lumen_classic_footer',
'type' => 'text',
)
);
$wp_customize->selective_refresh->add_partial(
'lumen_classic_footer_text',
array(
'selector' => '.site-footer__text',
'container_inclusive' => false,
'render_callback' => 'lumen_classic_render_footer_text',
'fallback_refresh' => true,
)
);
$wp_customize->add_setting(
'lumen_classic_show_catalogue_link',
array(
'default' => true,
'sanitize_callback' => 'lumen_classic_sanitize_checkbox',
)
);
$wp_customize->add_control(
'lumen_classic_show_catalogue_link',
array(
'label' => __( 'Link to the book catalogue in the footer', 'lumen-classic' ),
'section' => 'lumen_classic_footer',
'type' => 'checkbox',
)
);
}
add_action( 'customize_register', 'lumen_classic_customize_register' );
/**
* Sanitizes a checkbox value to a strict boolean.
*
* @param mixed $checked Raw value sent by the control.
*/
function lumen_classic_sanitize_checkbox( $checked ) {
return true === $checked || '1' === $checked || 1 === $checked || 'true' === $checked;
}
/**
* Prints the footer text; also the render callback of the selective-refresh partial.
*/
function lumen_classic_render_footer_text() {
echo esc_html( get_theme_mod( 'lumen_classic_footer_text', __( 'Lumen Press, independent publishers since 1998.', 'lumen-classic' ) ) );
}
/**
* Builds the inline CSS that exposes the accent colour as a custom property.
*/
function lumen_classic_accent_css() {
$accent = sanitize_hex_color( get_theme_mod( 'lumen_classic_accent_color', LUMEN_CLASSIC_DEFAULT_ACCENT ) );
if ( empty( $accent ) ) {
$accent = LUMEN_CLASSIC_DEFAULT_ACCENT;
}
return sprintf( ':root { --lumen-classic-accent: %s; }', $accent );
}
/**
* Loads the live-preview script inside the preview iframe only.
*/
function lumen_classic_customize_preview_js() {
wp_enqueue_script(
'lumen-classic-customizer-preview',
get_template_directory_uri() . '/js/customizer-preview.js',
array( 'customize-preview' ),
wp_get_theme()->get( 'Version' ),
array( 'in_footer' => true )
);
}
add_action( 'customize_preview_init', 'lumen_classic_customize_preview_js' );
Every setting has a sanitize_callback, including the checkbox, whose raw value arrives as a string, a boolean or
nothing; lumen_classic_sanitize_checkbox() returns a strict bool, where a plain (bool) cast would turn the string
'false' into true. sanitize_text_field() strips tags and line breaks from the footer text (a theme that allows
links there uses wp_kses_post() on save and on output). The control shares its setting’s ID, so it needs no
settings argument, and every ID carries the theme prefix. Core settings such as blogname can also be
switched to postMessage with $wp_customize->get_setting( 'blogname' )->transport = 'postMessage';, in a callback
that runs after core’s own registration at priority 10.
Printing the values safely
<?php
/**
* Lumen Classic theme setup: additions to the functions.php of the Themes Overview page.
*
* @package Lumen_Classic
*/
require get_template_directory() . '/inc/customizer.php';
// Inside lumen_classic_setup(), after the existing add_theme_support() calls:
add_theme_support( 'customize-selective-refresh-widgets' );
// Inside lumen_classic_enqueue_assets(), after wp_enqueue_style( 'lumen-classic-style', ... ):
wp_add_inline_style( 'lumen-classic-style', lumen_classic_accent_css() );
wp_add_inline_style() prints the rule in a <style id="lumen-classic-style-inline-css"> element right after the
theme stylesheet. style.css declares the same default (:root { --lumen-classic-accent: #a3271f; }) and uses
var(--lumen-classic-accent) for links and the footer’s top border, so it still works if the inline rule is missing.
The value is sanitized again when it is printed (lumen_classic_accent_css() runs sanitize_hex_color() on the
stored value and falls back to the default). This is the late-escaping rule of the WordPress security APIs applied to
CSS: the sanitize callback protects only values saved through the Customizer, while a theme mod can also be written
by set_theme_mod() in other code, by wp theme mod set, by an import or directly in the database. esc_html() and
esc_attr() are not CSS escapers: they would leave red;}body{display:none intact. In CSS, allow only a known
format (a hex colour, a number with a fixed unit, a slug from choices) and drop everything else.
The footer template prints the text through the same function the partial uses, which escapes with esc_html(), and
reads the checkbox with the same default the setting declares:
<?php /* index.php: the footer, between </main> and wp_footer(); the rest is unchanged. */ ?>
<footer class="site-footer">
<p class="site-footer__text"><?php lumen_classic_render_footer_text(); ?></p>
<?php if ( get_theme_mod( 'lumen_classic_show_catalogue_link', true ) ) : ?>
<p><a href="<?php echo esc_url( home_url( '/books/' ) ); ?>"><?php esc_html_e( 'Browse the catalogue', 'lumen-classic' ); ?></a></p>
<?php endif; ?>
</footer>
The older-book pattern, corrected
Older books on theme design show a colour setting registered without a sanitize_callback and printed with something
like echo get_theme_mod( ... ) straight into a <style> block or an attribute. Both halves are wrong. Without a
sanitize callback the Customizer stores whatever the request sent, and since 4.6 core can only validate what the theme
tells it to; unescaped output then lets a stored value close the <style> element or the attribute and inject markup or
script. The Theme Review requirements reject themes that do either. The fix is the pair shown above: a
sanitize_callback on every setting, and escaping (or format-checking) at the point of output, every time.
Live preview with postMessage
With 'transport' => 'postMessage' the Customizer does not reload the preview; it sends the new value to the
iframe, and a script loaded only there applies it. The script is enqueued on customize_preview_init with the
customize-preview dependency, which provides wp.customize inside the preview:
/* global wp */
( function ( api ) {
// Accent colour: update the custom property without reloading the preview.
api( 'lumen_classic_accent_color', function ( value ) {
value.bind( function ( to ) {
document.documentElement.style.setProperty( '--lumen-classic-accent', to || '#a3271f' );
} );
} );
} )( wp.customize );
api( id, callback ) waits until the setting exists in the preview and passes its wp.customize.Value;
value.bind() runs on every change. Setting the property on document.documentElement overrides the inline
:root rule without touching the stylesheet. The script uses plain DOM APIs and an IIFE; a preview script that relies
on jQuery’s $ must declare jquery as a dependency and wrap the code as ( function ( $ ) { ... } )( jQuery );,
because WordPress loads jQuery in no-conflict mode, where the global $ is not defined. Scripts that change the
controls pane itself (not the preview) go on customize_controls_enqueue_scripts instead.
Selective refresh for the footer text
A partial pairs a selector with a PHP render callback. When a setting of the partial changes, the preview sends an
Ajax request, the server renders only that callback with the previewed values, and the preview swaps the result into
every element matching the selector. No JavaScript is needed for the footer text: its setting uses postMessage,
and the partial takes over.
The arguments of add_partial() are selector (the element or elements to update), settings (the settings that
trigger it, by default the partial ID), render_callback (prints or returns the new markup, called with the previewed
values applied), container_inclusive (default false: replace the element’s children; true replaces the element
itself, so the callback must print the container too) and fallback_refresh (default true: reload the whole
preview when no element matches or rendering fails).
Selective refresh also draws the blue pencil "edit shortcut" next to each partial in the preview (4.7), which focuses
the matching control when clicked. A partial’s markup must come from the same function the template uses, as
lumen_classic_render_footer_text() does; otherwise the preview and the published page drift apart.
add_theme_support( 'customize-selective-refresh-widgets' ) enables the same mechanism for widgets in the theme’s
sidebars (each widget needs a wrapper with its ID, which register_sidebar() provides); see
Widgets and Sidebars.
Changesets, drafts and scheduling
Since 4.7 the Customizer stores pending changes in a customize_changeset post whose post_name is a UUID and whose
content is a JSON map of setting IDs to values. Publish runs every setting’s validation and sanitization, writes the
values (set_theme_mod() for theme mods, update_option() for options) and trashes the changeset post. Since 4.9 the
publish button also offers Save Draft and Schedule, a draft can be shared through a preview link, and a changeset
is locked to the user editing it. Additional CSS, added in 4.7, is stored per theme in a custom_css post and printed
by wp_custom_css_cb() in wp_head.
Two of the fixes in WordPress 7.1.4 (a security release of 2026-10-28) concern this machinery: unpublished changesets
could be discovered by unauthenticated users, and selective refresh had a stored XSS. Both are fixed in core, so the
remedy is updating; a theme still escapes what its partials' render callbacks print, as lumen-classic does with
esc_html(), and a draft changeset should never hold anything that may not become public.
Source: WordPress News: WordPress 7.1.4
Running the example
The environment is the Compose stack of
Themes Overview (WordPress
7.1.3 at https://lumenpress.test, themes/ mounted at /themes), with the files above in themes/lumen-classic/
and the two test scripts below in themes/:
docker compose exec cli sh -c 'rm -rf wp-content/themes/lumen-classic && cp -r /themes/lumen-classic wp-content/themes/ &&
for f in $(find wp-content/themes/lumen-classic -name "*.php"); do php -l "$f"; done'
docker compose exec cli wp theme activate lumen-classic
No syntax errors detected in wp-content/themes/lumen-classic/functions.php
No syntax errors detected in wp-content/themes/lumen-classic/index.php
No syntax errors detected in wp-content/themes/lumen-classic/inc/customizer.php
Success: Switched to 'Lumen Classic' theme.
Reading and writing theme mods with WP-CLI
wp theme mod works on the active theme’s theme_mods_{stylesheet} option:
docker compose exec cli wp theme mod get lumen_classic_accent_color
docker compose exec cli wp theme mod set lumen_classic_accent_color '#1f4e79'
docker compose exec cli wp theme mod set lumen_classic_footer_text 'Lumen Press, Bristol.'
Source: WP-CLI Commands: wp theme mod
key value
lumen_classic_accent_color
Success: Theme mod lumen_classic_accent_color set to #1f4e79.
Success: Theme mod lumen_classic_footer_text set to Lumen Press, Bristol..
The first get prints an empty value: the setting’s default lives only in the Customizer registration, and nothing
is stored until a value is saved. The template still shows #a3271f because lumen_classic_accent_css() passes the
same default to get_theme_mod(). The other subcommands are wp theme mod list and wp theme mod remove (one mod or
--all).
The rendered CSS
wp server serves the site from the CLI container (as on the Themes Overview page), and a PHP one-liner requests the
home page with the site’s host name and prints the inline style and the footer text:
docker compose exec -d cli wp server --host=127.0.0.1 --port=8080
docker compose exec cli php -r '
$c = stream_context_create( array( "http" => array( "header" => "Host: lumenpress.test\r\n" ) ) );
$h = file_get_contents( "http://127.0.0.1:8080/", false, $c );
preg_match_all( "#<style id=\"lumen-classic-style-inline-css\">.*?</style>|<p class=\"site-footer__text\">.*?</p>|Browse the catalogue#s", $h, $m );
echo implode( "\n", $m[0] ), "\n";'
Source: WP-CLI Commands: wp server
<style id="lumen-classic-style-inline-css">
:root { --lumen-classic-accent: #1f4e79; }
/*# sourceURL=lumen-classic-style-inline-css */
</style>
<p class="site-footer__text">Lumen Press, Bristol.</p>
Browse the catalogue
wp theme mod set calls set_theme_mod() directly and bypasses the sanitize callback, which makes it a good test
of output escaping. Storing a value that tries to break out of the rule:
docker compose exec cli wp theme mod set lumen_classic_accent_color 'red;}body{display:none'
Success: Theme mod lumen_classic_accent_color set to red;}body{display:none.
The same request then prints :root { --lumen-classic-accent: #a3271f; }: the stored value fails sanitize_hex_color() at
output time and the default colour is used. With a bare echo get_theme_mod( 'lumen_classic_accent_color' ) the
page would have printed the injected rule and hidden the whole site.
Saving through a changeset
The sanitize and validate callbacks run when the Customizer saves. This script does what the Publish button does: it
creates a manager, lets the theme register its objects, and saves a changeset with status publish, first with an
invalid colour, then with valid values and markup in the footer text:
<?php
// Saves values through a Customizer changeset, so validation and sanitize_callback run as in the UI.
require_once ABSPATH . WPINC . '/class-wp-customize-manager.php';
function lumen_publish( array $values ) {
global $wp_customize;
$wp_customize = new WP_Customize_Manager( array( 'changeset_uuid' => wp_generate_uuid4() ) );
do_action( 'customize_register', $wp_customize );
$data = array();
foreach ( $values as $id => $value ) {
$data[ $id ] = array( 'value' => $value );
}
$result = $wp_customize->save_changeset_post( array( 'status' => 'publish', 'data' => $data ) );
if ( ! is_wp_error( $result ) ) {
echo "published\n";
return;
}
echo $result->get_error_code(), "\n";
foreach ( $result->get_error_data()['setting_validities'] as $id => $validity ) {
echo ' ', $id, ': ', true === $validity ? 'valid' : $validity->get_error_code(), "\n";
}
}
lumen_publish( array( 'lumen_classic_accent_color' => 'red' ) );
lumen_publish(
array(
'lumen_classic_accent_color' => '#2E5E4E',
'lumen_classic_footer_text' => '<b>Lumen Press</b>, Bristol & Bath',
'lumen_classic_show_catalogue_link' => '',
)
);
foreach ( array( 'lumen_classic_accent_color', 'lumen_classic_footer_text', 'lumen_classic_show_catalogue_link' ) as $mod ) {
echo $mod, ' = ', var_export( get_theme_mod( $mod ), true ), "\n";
}
docker compose exec cli wp eval-file /themes/changeset.php --user=admin
Source: WP-CLI Commands: wp eval-file
transaction_fail
lumen_classic_accent_color: invalid_value
published
lumen_classic_accent_color = '#2E5E4E'
lumen_classic_footer_text = 'Lumen Press, Bristol & Bath'
lumen_classic_show_catalogue_link = false
The first changeset fails as a whole (transaction_fail) because one setting is invalid: sanitize_hex_color()
returned null for red, which core reports as invalid_value. Nothing is written, which is the behaviour the user
sees as an error under the colour control. The second publishes: the colour keeps its case (sanitize_hex_color()
does not lower-case), sanitize_text_field() removed the <b> tags, and the checkbox callback turned the empty
string into false. Both customize_changeset posts end in the trash
(wp post list --post_type=customize_changeset --post_status=trash). The home page now prints
:root { --lumen-classic-accent: #2E5E4E; }, the footer paragraph Lumen Press, Bristol & Bath (escaped by
esc_html()), and no catalogue link.
Why block themes have no Customizer
Since 5.9, wp-admin/menu.php adds the Appearance > Customize item only when the active theme is not a block theme
or something hooks customize_register; wp_admin_bar_customize_menu() applies the same test to the toolbar’s
Customize link. For block themes the manager also skips its Widgets component. This script builds the admin menu the
way wp-admin does and prints the Appearance submenu for each theme:
<?php
// Builds the admin menu the way wp-admin does and prints the Appearance submenu.
require_once ABSPATH . 'wp-admin/includes/admin.php';
global $menu, $submenu, $_wp_submenu_nopriv, $_wp_menu_nopriv;
$_SERVER['REQUEST_URI'] = '/wp-admin/';
require ABSPATH . 'wp-admin/menu.php';
printf( "%s | block theme: %s | customize_register hooked: %s\n", get_stylesheet(), wp_is_block_theme() ? 'yes' : 'no', has_action( 'customize_register' ) ? 'yes' : 'no' );
foreach ( $submenu['themes.php'] as $item ) {
echo ' ', wp_strip_all_tags( $item[0] ), ' => ', strtok( $item[2], '?' ), "\n";
}
docker compose exec cli wp eval-file /themes/menu-check.php --user=admin
docker compose exec cli wp theme activate lumen
docker compose exec cli wp eval-file /themes/menu-check.php --user=admin
docker compose exec cli wp eval 'var_dump( current_theme_supports( "customize-selective-refresh-widgets" ) );'
lumen-classic | block theme: no | customize_register hooked: yes
Themes 0 => themes.php
Patterns => site-editor.php
Customize => customize.php
Fonts => font-library.php
Theme File Editor => theme-editor.php
Success: Switched to 'Lumen' theme.
lumen | block theme: yes | customize_register hooked: no
Themes 0 => themes.php
Editor => site-editor.php
Fonts => font-library.php
bool(false)
With lumen active the Customize item is gone and Editor (the Site Editor) takes its place; lumen declares no
customize-selective-refresh-widgets support, and wp theme mod list shows none of lumen-classic's values, which
stay in theme_mods_lumen-classic for when the classic theme is reactivated.
The reason is that every Customizer job has a block-theme equivalent, and two places to set the same colour would
conflict: Site Identity becomes the Site Title, Site Logo and Site Tagline blocks; Colors, Header and Background become
theme.json presets and Styles; Menus become Navigation blocks; Widgets become template parts and patterns; Additional
CSS becomes Styles > Additional CSS (and, since 7.0, custom CSS per block instance). The Customizer previews PHP
templates and theme mods, whereas a block theme’s design lives in global styles and template posts that the Site Editor
edits with its own preview.
The page itself is not removed: customize.php still loads by URL, and the link returns as soon as any active plugin
hooks customize_register (some SEO or cookie-banner plugins do). get_theme_mod() works in any theme, so a hybrid
theme can keep reading old values while it migrates them to theme.json.
Errors and pitfalls
| Symptom | Cause and fix |
|---|---|
Control missing from the Customizer |
The section has no controls, the panel has no sections with controls, the control’s |
"Invalid value." under a control, nothing publishes |
The |
|
The preview script is enqueued on |
Partial reloads the whole preview |
No element matches the |
Security checklist
A sanitize_callback matching each control (sanitize_hex_color, absint, sanitize_text_field, esc_url_raw,
wp_kses_post, a choices check, a strict boolean); escaping again on output, with a format check inside CSS; the
default edit_theme_options capability; and wp_json_encode() for values passed to scripts. See
Security Hardening.