In the recent article, Zack Pyle mentioned the ability to add images to the menu manager, and he has a snippet on his SnippetNest site that works well. One thing it doesn’t do is extend the icons to the Customizer interface, which is my preferred way to manage the menus. I asked ChatGPT if it was possible, and 90 seconds later I had a working solution.

Here is the code:

// Enqueue media library + picker script for nav menus
add_action( 'admin_enqueue_scripts', function( $hook ) {
    if ( $hook !== 'nav-menus.php' ) return;

    wp_enqueue_media();

    wp_add_inline_script( 'jquery', '
        jQuery(function($) {
            $(document).on("click", ".menu-item-icon-select", function(e) {
                e.preventDefault();

                var button = $(this);
                var container = button.closest(".field-icon");
                var input = container.find(".edit-menu-item-icon");
                var preview = container.find(".menu-item-icon-preview");

                var frame = wp.media({
                    title: "Select Icon Image",
                    button: { text: "Use this image" },
                    multiple: false
                });

                frame.on("select", function() {
                    var attachment = frame.state().get("selection").first().toJSON();
                    input.val(attachment.url);
                    preview.attr("src", attachment.url).show();
                    container.find(".menu-item-icon-remove").show();
                });

                frame.open();
            });

            $(document).on("click", ".menu-item-icon-remove", function(e) {
                e.preventDefault();

                var container = $(this).closest(".field-icon");
                container.find(".edit-menu-item-icon").val("");
                container.find(".menu-item-icon-preview").hide();
                $(this).hide();
            });
        });
    ' );
} );

// Add icon field to menu items
add_action( 'wp_nav_menu_item_custom_fields', function( $item_id, $item, $depth, $args ) {
    $icon = get_post_meta( $item_id, '_menu_item_icon', true );
    ?>
    <p class="field-icon description description-wide">
        <label for="edit-menu-item-icon-<?php echo esc_attr( $item_id ); ?>">
            <?php esc_html_e( 'Icon Image', 'your-textdomain' ); ?>
        </label>
        <br />

        <input type="hidden"
               id="edit-menu-item-icon-<?php echo esc_attr( $item_id ); ?>"
               class="edit-menu-item-icon"
               name="menu-item-icon[<?php echo esc_attr( $item_id ); ?>]"
               value="<?php echo esc_attr( $icon ); ?>" />

        <?php if ( $icon ) : ?>
            <img src="<?php echo esc_url( $icon ); ?>"
                 class="menu-item-icon-preview"
                 style="max-width:60px;max-height:60px;display:block;margin:6px 0;" />
        <?php else : ?>
            <img class="menu-item-icon-preview"
                 style="max-width:60px;max-height:60px;display:none;margin:6px 0;" />
        <?php endif; ?>

        <button type="button" class="button menu-item-icon-select">
            <?php esc_html_e( $icon ? 'Change Image' : 'Select Image', 'your-textdomain' ); ?>
        </button>

        <button type="button"
                class="button-link menu-item-icon-remove"
                style="color:#b32d2e;margin-left:8px;<?php echo $icon ? '' : 'display:none;'; ?>">
            <?php esc_html_e( 'Remove', 'your-textdomain' ); ?>
        </button>
    </p>
    <?php
}, 10, 4 );

// Save the icon field
add_action( 'wp_update_nav_menu_item', function( $menu_id, $menu_item_db_id, $args ) {
    // Only process submissions from Appearance > Menus.
    if (
        ! isset( $_POST['menu-item-icon'] ) ||
        ! is_array( $_POST['menu-item-icon'] )
    ) {
        return;
    }

    $raw  = $_POST['menu-item-icon'][ $menu_item_db_id ] ?? '';
    $icon = esc_url_raw( wp_unslash( $raw ) );

    if ( $icon ) {
        update_post_meta( $menu_item_db_id, '_menu_item_icon', $icon );
    } else {
        delete_post_meta( $menu_item_db_id, '_menu_item_icon' );
    }
}, 10, 3 );
/**
 * Make the saved icon available to the Customizer's JavaScript setting.
 */
add_filter( 'wp_setup_nav_menu_item', function( $item ) {
    if ( ! empty( $item->ID ) ) {
        $item->menu_item_icon = get_post_meta(
            $item->ID,
            '_menu_item_icon',
            true
        );
    }

    return $item;
} );


/**
 * Add the icon controls to each menu item in the Customizer.
 */
add_action( 'wp_nav_menu_item_custom_fields_customize_template', function() {
    ?>
    <p class="field-icon description description-wide">
        <span class="customize-control-title">
            <?php esc_html_e( 'Icon Image', 'your-textdomain' ); ?>
        </span>

        <img
            class="menu-item-icon-preview"
            src=""
            alt=""
            style="max-width:60px;max-height:60px;display:none;margin:6px 0;"
        />

        <button type="button" class="button menu-item-icon-select">
            <?php esc_html_e( 'Select Image', 'your-textdomain' ); ?>
        </button>

        <button
            type="button"
            class="button-link menu-item-icon-remove"
            style="color:#b32d2e;margin-left:8px;display:none;"
        >
            <?php esc_html_e( 'Remove', 'your-textdomain' ); ?>
        </button>
    </p>
    <?php
} );


/**
 * Load the media picker and connect the custom field to each
 * Customizer menu-item setting.
 */
add_action( 'customize_controls_enqueue_scripts', function() {
    wp_enqueue_media();
    wp_enqueue_script( 'customize-nav-menus' );

    wp_add_inline_script(
        'customize-nav-menus',
        <<<'JS'
(function ($, api) {
    function updateSetting(control, iconUrl) {
        var value = _.clone(control.setting());

        value.menu_item_icon = iconUrl;
        control.setting.set(value);
    }

    function updateFields(control) {
        var container = control.container.find('.field-icon');
        var preview   = container.find('.menu-item-icon-preview');
        var select    = container.find('.menu-item-icon-select');
        var remove    = container.find('.menu-item-icon-remove');
        var value     = control.setting();
        var iconUrl   = value.menu_item_icon || '';

        if (iconUrl) {
            preview.attr('src', iconUrl).show();
            select.text('Change Image');
            remove.show();
        } else {
            preview.attr('src', '').hide();
            select.text('Select Image');
            remove.hide();
        }
    }

    function extendMenuItemControl(control) {
        var container = control.container.find('.field-icon');

        if (!container.length || container.data('icon-control-ready')) {
            return;
        }

        container.data('icon-control-ready', true);

        updateFields(control);

        control.setting.bind(function () {
            updateFields(control);
        });

        container.on('click', '.menu-item-icon-select', function (event) {
            var frame;

            event.preventDefault();

            frame = wp.media({
                title: 'Select Icon Image',
                button: {
                    text: 'Use this image'
                },
                multiple: false
            });

            frame.on('select', function () {
                var attachment = frame
                    .state()
                    .get('selection')
                    .first()
                    .toJSON();

                updateSetting(control, attachment.url);
            });

            frame.open();
        });

        container.on('click', '.menu-item-icon-remove', function (event) {
            event.preventDefault();
            updateSetting(control, '');
        });
    }

    api.control.bind('add', function (control) {
        if (!control.extended(api.Menus.MenuItemControl)) {
            return;
        }

        control.deferred.embedded.done(function () {
            extendMenuItemControl(control);
        });
    });
})(jQuery, wp.customize);
JS
    );
} );


/**
 * Save the icon metadata after the Customizer publishes its changes.
 */
add_action( 'customize_save_after', function( WP_Customize_Manager $wp_customize ) {
    $posted_values = $wp_customize->unsanitized_post_values();

    foreach ( $posted_values as $setting_id => $value ) {
        if (
            ! preg_match(
                '/^nav_menu_item\[-?\d+\]$/',
                $setting_id
            ) ||
            ! is_array( $value ) ||
            ! array_key_exists( 'menu_item_icon', $value )
        ) {
            continue;
        }

        $setting = $wp_customize->get_setting( $setting_id );

        if (
            ! $setting instanceof WP_Customize_Nav_Menu_Item_Setting ||
            ! $setting->check_capabilities()
        ) {
            continue;
        }

        // Placeholder IDs for new items are replaced during publishing.
        $menu_item_id = (int) $setting->post_id;

        if ( $menu_item_id <= 0 ) {
            continue;
        }

        $icon = esc_url_raw( $value['menu_item_icon'] );

        if ( $icon ) {
            update_post_meta(
                $menu_item_id,
                '_menu_item_icon',
                $icon
            );
        } else {
            delete_post_meta(
                $menu_item_id,
                '_menu_item_icon'
            );
        }
    }
}, 20 );

As Zack mentions on his site, this is the code needed to add it to the menu loop. I opted to put them in the same snippet:

// Expose the icon field in Etch templates
add_filter( 'wp_menus_for_etch/item', function( $node, $item ) {
    $icon = get_post_meta( $item->ID, '_menu_item_icon', true );
    if ( ! empty( $icon ) ) {
        $node['icon'] = $icon;
    }
    return $node;
}, 10, 2 );