Skip to main content

🎛️ 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_register hook with proper sanitization callbacks
  • Output saved options to the front end with get_theme_mod()
  • Wire up live preview using the postMessage transport 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.
flowchart LR U[User moves a control] --> S[Setting value changes] S --> PV[Live preview updates] PV --> Pub{Publish?} Pub -->|Yes| DB[(Saved to database)] Pub -->|No| Discard[Discarded] DB --> Site[Live website]

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:

How Customizer objects nest A panel contains sections, a section contains controls, and each control is bound to a setting which is stored in the database. Panel Section Control (color picker) → bound to a Setting the UI the user touches Control (select) → bound to a Setting grouped inside the section Setting stored in the database
Figure 1 — Panels hold sections, sections hold controls, and each control edits a setting that lives in the database.
ObjectRoleAnalogy
SettingA single stored value (a color, a number, a string) with a sanitizer and a transport method.The data in the vault
ControlThe UI widget the user manipulates — color picker, select, text field — bound to one setting.The dial on the panel
SectionA titled group of related controls (e.g. "Colors").A drawer of dials
PanelA 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 typeSanitizer
Hex colorsanitize_hex_color
Plain textsanitize_text_field
URLesc_url_raw
Integerabsint
Rich textwp_kses_post
Emailsanitize_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 );
    },
) );
TransportSpeedAccuracyUse when
refreshSlowPerfectRare/complex changes; no JS available
postMessageInstantDepends on your JSColors, fonts, simple CSS tweaks
Selective refreshFastPerfectMarkup-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.

  1. In inc/customizer.php, add a setting link_color (default #2563eb, sanitizer sanitize_hex_color, transport postMessage).
  2. Add a WP_Customize_Color_Control for it in your colors section.
  3. Output it as a CSS variable in wp_head and use var(--link) on a elements.
  4. In the preview JS, update --link live.
💡 Hint

This is the accent-color example from the lesson with the id renamed. Reuse the same four snippets and change accent_colorlink_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_callback and a sensible default.
  • 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 in functions.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) with get_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

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