🎛️ Theme Customization API
A great theme lets users change colors, fonts, and layout without touching a line of code — and see the result instantly. The Customization API (the "Customizer") is WordPress's standardized way to build exactly that: a live-preview control panel wired straight into your theme.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe the Customizer's building blocks — panels, sections, settings, and controls
- Register options on the
customize_registerhook with proper sanitization callbacks - Output saved options to the front end with
get_theme_mod() - Wire up live preview using the
postMessagetransport and JavaScript - Choose between
refresh,postMessage, and selective refresh for previewing changes
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Add a color option end-to-end — register, sanitize, output, and live-preview it.
In This Lesson
Why the Customizer?
Before the Customizer, themes shipped their own bespoke "Theme Options" admin pages — every one different, none with a live preview. The Customization API replaced that chaos with a single, consistent interface at Appearance → Customize: controls on the left, a live preview of the actual site on the right.
💡 A useful analogy: The Customizer is a theater lighting booth. You slide the dimmers and swap the gels (controls), and the stage lights up in real time (live preview) — but nothing goes to the paying audience until you hit Publish. Experiment freely; the live site stays untouched until you commit.
Changes preview instantly but only persist when the user clicks Publish.
✅ What you gain by using it
- A familiar, accessible UI users already know from other themes.
- Free live preview and safe experimentation.
- A clean separation between settings (data) and controls (UI).
- Built-in control types: color pickers, image uploads, selects, and more.
💡 A note on block themes
Modern block themes steer users toward the Site Editor and theme.json for global styles rather than the Customizer. But the Customization API remains fully supported, is essential for classic themes, and is still the right tool for many plugin options — so it's very much worth knowing.
The Four Building Blocks
Everything in the Customizer is assembled from four object types. Understanding how they nest is the whole game:
| Object | Role | Analogy |
|---|---|---|
| Setting | A single stored value (a color, a number, a string) with a sanitizer and a transport method. | The data in the vault |
| Control | The UI widget the user manipulates — color picker, select, text field — bound to one setting. | The dial on the panel |
| Section | A titled group of related controls (e.g. "Colors"). | A drawer of dials |
| Panel | A top-level container that groups several sections. Optional; use only when you have many sections. | A cabinet of drawers |
📖 Settings vs. controls — why split them?
A setting is what is stored and how it's cleaned and previewed. A control is how the user edits it. Separating them means two controls could edit one setting, or a setting could exist with no visible control at all (edited by code). This separation is the API's core design idea.
Registering Your First Option
All Customizer registration happens inside a function hooked to customize_register, which receives the $wp_customize manager object. Conventionally this lives in inc/customizer.php, pulled in from functions.php.
<?php
// functions.php
require get_template_directory() . '/inc/customizer.php';
A complete option needs three calls: add a section (or reuse one), add a setting, and add a control bound to that setting.
<?php
// inc/customizer.php
function mytheme_customize_register( $wp_customize ) {
// 1. A section to hold our controls.
$wp_customize->add_section( 'mytheme_colors', array(
'title' => __( 'Theme Colors', 'my-theme' ),
'priority' => 40,
) );
// 2. A setting: the stored value + how to clean and preview it.
$wp_customize->add_setting( 'accent_color', array(
'default' => '#3b82f6',
'sanitize_callback' => 'sanitize_hex_color',
'transport' => 'postMessage',
) );
// 3. A control: the UI the user actually sees.
$wp_customize->add_control(
new WP_Customize_Color_Control(
$wp_customize,
'accent_color',
array(
'label' => __( 'Accent Color', 'my-theme' ),
'section' => 'mytheme_colors',
)
)
);
}
add_action( 'customize_register', 'mytheme_customize_register' );
💡 Reusing built-in sections
You don't always add a new section. WordPress ships several, most notably title_tagline (the "Site Identity" section). To add a control there, just set 'section' => 'title_tagline' and skip the add_section call.
For simple controls (text, select, checkbox, number), you can pass a plain array instead of a control object — the object form is only needed for specialized controls like the color picker or image uploader:
<?php
$wp_customize->add_setting( 'body_font', array(
'default' => 'system-ui',
'sanitize_callback' => 'sanitize_text_field',
'transport' => 'postMessage',
) );
$wp_customize->add_control( 'body_font', array(
'type' => 'select',
'label' => __( 'Body Font', 'my-theme' ),
'section' => 'mytheme_colors',
'choices' => array(
'system-ui' => __( 'System', 'my-theme' ),
'Georgia' => __( 'Georgia', 'my-theme' ),
'Arial' => __( 'Arial', 'my-theme' ),
),
) );
Sanitization: Never Trust Input
Every setting must declare a sanitize_callback. This function runs before the value is saved, guaranteeing that only clean, expected data reaches your database and, later, your page. Skipping it is a security hole — and WordPress theme review will reject the theme.
| Data type | Sanitizer |
|---|---|
| Hex color | sanitize_hex_color |
| Plain text | sanitize_text_field |
| URL | esc_url_raw |
| Integer | absint |
| Rich text | wp_kses_post |
sanitize_email |
For values with no built-in sanitizer — a float, a checkbox, or a value from a fixed list — write your own:
<?php
// Checkbox → strict boolean
function mytheme_sanitize_checkbox( $checked ) {
return ( isset( $checked ) && true === (bool) $checked );
}
// Select → only allow known choices
function mytheme_sanitize_layout( $value, $setting ) {
$choices = array( 'standard', 'grid', 'masonry' );
return in_array( $value, $choices, true ) ? $value : $setting->default;
}
// Float
function mytheme_sanitize_float( $value ) {
return (float) $value;
}
⚠️ Sanitize on the way in, escape on the way out
Sanitization (saving) and escaping (printing) are two separate defenses, and you need both. Even a sanitized value should be escaped again when you output it — with esc_attr(), esc_html(), or esc_url() — because context matters and data can be modified by other code in between.
Outputting Settings on the Front End
Saving a setting does nothing visible on its own — you have to read it in your theme with get_theme_mod( $id, $default ) and apply it. The cleanest approach for colors and typography is a small block of inline CSS printed in the head, driven by CSS custom properties.
<?php
function mytheme_customizer_css() {
$accent = get_theme_mod( 'accent_color', '#3b82f6' );
$font = get_theme_mod( 'body_font', 'system-ui' );
?>
<style id="mytheme-customizer">
:root {
--accent: <?php echo esc_attr( $accent ); ?>;
}
body { font-family: <?php echo esc_attr( $font ); ?>, sans-serif; }
a, .button { color: var(--accent); }
</style>
<?php
}
add_action( 'wp_head', 'mytheme_customizer_css' );
✅ Why route through a CSS variable
Emitting --accent once and referencing var(--accent) throughout your stylesheet means a single PHP echo controls every accent-colored element. It also makes the JavaScript live preview trivial — you just update one variable.
💡 get_theme_mod vs. get_option
Customizer values are stored as theme modifications, scoped per-theme, and read with get_theme_mod(). That's different from get_option(), which reads site-wide options. Always pass a sensible default as the second argument so the theme looks right before the user ever opens the Customizer.
Live Preview with postMessage
Each setting has a transport that decides how the preview updates:
refresh(the default) — reloads the whole preview iframe on every change. Reliable, but slow and jarring.postMessage— sends the change to JavaScript, which updates the preview instantly with no reload. Much nicer, but you must write that JavaScript.
Because our settings above used 'transport' => 'postMessage', we now enqueue a preview script:
<?php
function mytheme_customize_preview_js() {
wp_enqueue_script(
'mytheme-customizer-preview',
get_theme_file_uri( 'assets/js/customizer-preview.js' ),
array( 'customize-preview' ), // dependency provides the wp.customize API
wp_get_theme()->get( 'Version' ),
true
);
}
add_action( 'customize_preview_init', 'mytheme_customize_preview_js' );
The script listens for each setting and updates the DOM live. With CSS variables, most updates are one line:
// assets/js/customizer-preview.js
( function () {
// Accent color → update the CSS custom property
wp.customize( 'accent_color', function ( value ) {
value.bind( function ( newValue ) {
document.documentElement.style.setProperty( '--accent', newValue );
} );
} );
// Body font
wp.customize( 'body_font', function ( value ) {
value.bind( function ( newValue ) {
document.body.style.fontFamily = newValue + ', sans-serif';
} );
} );
}() );
📖 How the wp.customize binding works
wp.customize( id, fn ) hands you a value object for that setting. Calling value.bind( callback ) runs your callback every time the user changes the control — receiving the new value. It's a simple publish/subscribe channel between the control panel and the preview.
⚠️ postMessage doesn't replace the PHP output
The JavaScript only updates the preview. The inline-CSS PHP from the previous section is what makes the setting stick on the live site after Publish. You need both: PHP for persistence, JS for the instant preview.
Selective Refresh & Beyond
Some changes are hard to reproduce accurately in JavaScript — for example, re-rendering a copyright line that mixes markup and dynamic data. Selective refresh is the middle path: it re-renders only the one element that changed via a server callback, giving refresh-level accuracy with near-postMessage speed.
<?php
$wp_customize->add_setting( 'footer_text', array(
'default' => '',
'sanitize_callback' => 'wp_kses_post',
'transport' => 'postMessage',
) );
$wp_customize->add_control( 'footer_text', array(
'type' => 'textarea',
'label' => __( 'Footer Text', 'my-theme' ),
'section' => 'mytheme_colors',
) );
// Re-render just the .footer-text element when this setting changes.
$wp_customize->selective_refresh->add_partial( 'footer_text', array(
'selector' => '.footer-text',
'render_callback' => function () {
return wp_kses_post( get_theme_mod( 'footer_text' ) );
},
) );
Panels for large option sets
Once a theme has many sections, wrap them in a panel so the interface stays tidy:
<?php
$wp_customize->add_panel( 'mytheme_options', array(
'title' => __( 'Theme Options', 'my-theme' ),
'priority' => 30,
) );
// Then assign sections to it:
$wp_customize->add_section( 'mytheme_colors', array(
'title' => __( 'Colors', 'my-theme' ),
'panel' => 'mytheme_options',
) );
Conditional controls with active_callback
Show a control only when another setting warrants it — e.g. reveal "Search placeholder" only when header search is enabled:
<?php
$wp_customize->add_control( 'search_placeholder', array(
'label' => __( 'Search Placeholder', 'my-theme' ),
'section' => 'mytheme_options',
'type' => 'text',
'active_callback' => function () {
return (bool) get_theme_mod( 'enable_header_search', true );
},
) );
| Transport | Speed | Accuracy | Use when |
|---|---|---|---|
refresh | Slow | Perfect | Rare/complex changes; no JS available |
postMessage | Instant | Depends on your JS | Colors, fonts, simple CSS tweaks |
| Selective refresh | Fast | Perfect | Markup-heavy fragments like widgets or footer text |
Hands-on Exercise
🏋️ Add a "Link Color" option end-to-end
Objective: Wire a single color option through all four stages: register, sanitize, output, and live-preview.
- In
inc/customizer.php, add a settinglink_color(default#2563eb, sanitizersanitize_hex_color, transportpostMessage). - Add a
WP_Customize_Color_Controlfor it in your colors section. - Output it as a CSS variable in
wp_headand usevar(--link)onaelements. - In the preview JS, update
--linklive.
💡 Hint
This is the accent-color example from the lesson with the id renamed. Reuse the same four snippets and change accent_color → link_color and --accent → --link.
✅ Solution
<?php
// 1 & 2 — register + control (inside customize_register)
$wp_customize->add_setting( 'link_color', array(
'default' => '#2563eb',
'sanitize_callback' => 'sanitize_hex_color',
'transport' => 'postMessage',
) );
$wp_customize->add_control( new WP_Customize_Color_Control(
$wp_customize, 'link_color',
array( 'label' => __( 'Link Color', 'my-theme' ), 'section' => 'mytheme_colors' )
) );
<?php
// 3 — output (wp_head)
function mytheme_link_css() {
$c = get_theme_mod( 'link_color', '#2563eb' );
echo '<style>:root{--link:' . esc_attr( $c ) . ';} a{color:var(--link);}</style>';
}
add_action( 'wp_head', 'mytheme_link_css' );
// 4 — live preview
wp.customize( 'link_color', function ( value ) {
value.bind( function ( v ) {
document.documentElement.style.setProperty( '--link', v );
} );
} );
🎯 Quick Quiz
Question 1: In the Customizer, what is the difference between a setting and a control?
Question 2: Which transport updates the preview instantly without reloading the iframe?
Question 3: Why must every setting declare a sanitize_callback?
Best Practices
✅ Do
- Give every setting a
sanitize_callbackand a sensibledefault. - Escape again on output (
esc_attr,esc_url,esc_html). - Prefer
postMessage+ JS for a smooth preview; use selective refresh for markup-heavy fragments. - Wrap strings in translation functions with your text domain.
- Keep Customizer code in
inc/customizer.php, not inline infunctions.php.
⚠️ Don't
- Don't skip sanitization — it's a security risk and fails theme review.
- Don't rely on JS preview alone; the PHP output is what persists after Publish.
- Don't over-load the Customizer with options users won't use; fewer, clearer controls win.
- Don't confuse
get_theme_mod()(per-theme) withget_option()(site-wide).
Summary & Quiz
🎉 Key Takeaways
- The Customizer gives users a consistent, live-preview control panel; changes persist only on Publish.
- Its four blocks are settings (data), controls (UI), sections (groups), and panels (groups of groups).
- Register options on
customize_register; every setting needs a sanitize callback. - Read values on the front end with
get_theme_mod()and always escape on output. - Use
postMessage+ JS for instant preview, and selective refresh for markup-heavy fragments.
📚 Further Reading
- Theme Handbook — The Customize API
- Customizer Objects — Settings, Controls, Sections
- Reference — get_theme_mod()
🚀 What's Next?
You've now taken themes from structure through display to user customization. Next we cross into the other half of extending WordPress: Plugin Development Architecture — where functionality that must outlive any theme belongs.
🎉 Nicely done!
You can now hand users a polished, safe control panel for your theme.