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

$wp_customize->add_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

$wp_customize->add_section()

A group of controls. It can stand alone or sit in a panel through its panel argument.

Setting

$wp_customize->add_setting()

One stored value: its storage type, default, capability, sanitize and validate callbacks and preview transport. It has no UI.

Control

$wp_customize->add_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

theme_mod (default)

One serialized array per theme, in the option theme_mods_{stylesheet} (here theme_mods_lumen-classic). Read with get_theme_mod(), written with set_theme_mod().

Theme presentation options. Each theme keeps its own values, so switching themes and back restores them.

option

Its own row in wp_options, named after the setting ID; an ID such as lumen_catalog[per_page] stores one key of an array option. Read with get_option().

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

default

''

Value the preview shows before anything is saved. It is not stored and not known to get_theme_mod(), which takes its own default as second parameter; keep both in one constant.

capability

edit_theme_options

Capability needed to change the setting (Administrators have it; Editors do not).

transport

refresh

refresh reloads the preview iframe; postMessage sends the value to the preview, where JavaScript or a partial updates the page.

sanitize_callback

none

Function that receives the value (and the setting) and returns the clean value to store. Runs on save and on preview.

validate_callback

none

Receives a WP_Error, the value and the setting; adding an error blocks the publish and shows the message next to the control (4.6+). sanitize_js_callback, theme_supports and dirty are rarely needed.

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 (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 with sanitize_hex_color, previewed instantly through postMessage and 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 checkbox lumen_classic_show_catalogue_link with 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.

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.

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.'
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";'
<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
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 &amp; 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 section ID is misspelt, or the user lacks the setting’s capability. A missing active_callback match hides it on other pages.

"Invalid value." under a control, nothing publishes

The sanitize_callback returned null or the validate_callback added an error. A changeset publishes all settings or none.

postMessage set but the preview never changes

The preview script is enqueued on customize_controls_enqueue_scripts (wrong frame) or without the customize-preview dependency, or the setting ID in wp.customize() is misspelt.

Partial reloads the whole preview

No element matches the selector (often the template prints the element conditionally), so fallback_refresh kicks in.

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.