Using Web Components (Custom Elements) in the WordPress Admin Panel

Custom Elements — the core API of Web Components — allow defining reusable HTML elements with encapsulated behavior and optional style isolation, implemented as JavaScript classes that extend HTMLElement and registered with customElements.define( 'tag-name', ClassName ). In the WordPress admin context, Custom Elements provide a framework-agnostic way to build interactive UI components that work alongside React-based Gutenberg, wp-scripts builds, and plain-JavaScript enqueued scripts — because they are native browser APIs that require no npm package, no build step, and no framework runtime import. A Custom Element class defines lifecycle callbacks: connectedCallback() (element inserted into the DOM — initialize Shadow DOM, fetch data, add event listeners), disconnectedCallback() (element removed — clean up timers and listeners), and attributeChangedCallback(name, oldValue, newValue) (a declared observed attribute changed — update internal state). Shadow DOM (created with this.attachShadow({ mode: 'open' })) creates a separate DOM subtree with scoped CSS — WordPress admin’s global wp-admin.css cannot accidentally style elements inside the Shadow DOM, and the component’s internal styles cannot leak to the surrounding page. This isolation is especially valuable in wp-admin pages where WordPress and plugin CSS frequently clash with custom UI elements. The <template> HTML element is the standard pattern for initializing Shadow DOM content — it is parsed but not rendered until cloned with template.content.cloneNode(true). For REST API integration, a Custom Element’s connectedCallback can read a data-nonce attribute set by PHP and call fetch() with the X-WP-Nonce header — no separate wp_localize_script() object required per instance. Custom Elements are supported in all modern browsers (Chrome 67+, Firefox 63+, Safari 10.1+, Edge 79+) — no polyfill is needed for WordPress admin, where the browser environment is controlled. The ES2023 array methods post covered immutable data manipulation; Custom Elements provide the UI layer for displaying and interacting with that data in WordPress admin without requiring React or Vue as a dependency.

Problem: A WordPress plugin needs a reusable star-rating input on three different admin pages — a post edit meta box, a user profile page, and a custom settings page. Building it as a React component requires bundling React with the plugin; building it as a plain JavaScript function requires manual state management for multiple simultaneous instances on the same page.

Solution: Build a <star-rating> Custom Element with Shadow DOM — define it once in a plain JavaScript file with no build step, enqueue it once with wp_enqueue_script(), and use it declaratively in any PHP admin template via HTML attributes.

// assets/js/star-rating.js — no build step required

const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
    
`;

class StarRating extends HTMLElement {
    static get observedAttributes() { return ['value', 'max', 'readonly']; }

    #shadow  = null;
    #hidden  = null;
    #stars   = [];
    #current = 0;

    connectedCallback() {
        this.#shadow = this.attachShadow({ mode: 'open' });
        this.#shadow.appendChild(TEMPLATE.content.cloneNode(true));

        const max      = parseInt(this.getAttribute('max')   ?? '5', 10);
        const value    = parseInt(this.getAttribute('value') ?? '0', 10);
        const name     = this.getAttribute('name') ?? 'star_rating';
        const readonly = this.hasAttribute('readonly');

        // Hidden input MUST live in main DOM (not Shadow DOM) for form POST to work
        this.#hidden = Object.assign(document.createElement('input'), {
            type: 'hidden', name, value: String(value)
        });
        this.appendChild(this.#hidden);

        for (let i = 1; i <= max; i++) {
            const btn = Object.assign(document.createElement('button'), {
                type: 'button', textContent: '★',
            });
            btn.setAttribute('aria-label', `Rate ${i} of ${max} stars`);
            if (!readonly) {
                btn.addEventListener('click',      () => this.#set(i));
                btn.addEventListener('mouseenter', () => this.#highlight(i));
                btn.addEventListener('mouseleave', () => this.#highlight(this.#current));
            } else {
                btn.disabled = true;
            }
            this.#shadow.appendChild(btn);
            this.#stars.push(btn);
        }

        this.#set(value, false);
    }

    #set(value, dispatch = true) {
        this.#current = value;
        if (this.#hidden) this.#hidden.value = String(value);
        this.#highlight(value);
        if (dispatch) this.dispatchEvent(new CustomEvent('rating-change', {
            detail: { value }, bubbles: true, composed: true
        }));
    }

    #highlight(upTo) {
        this.#stars.forEach((btn, i) => btn.classList.toggle('active', i < upTo));
    }

    attributeChangedCallback(name, _, newValue) {
        if (name === 'value' && this.#shadow) this.#set(parseInt(newValue, 10), false);
    }
}

customElements.define('star-rating', StarRating);

// Enqueue the Custom Element once — no dependencies, defer until DOM is ready
add_action( 'admin_enqueue_scripts', function(): void {
    wp_enqueue_script(
        'myplugin-star-rating',
        plugin_dir_url( __FILE__ ) . 'assets/js/star-rating.js',
        [],
        '1.0.0',
        [ 'strategy' => 'defer', 'in_footer' => true ]
    );
} );

// Use in a meta box — declarative HTML, no JS initialization code needed
function myplugin_render_rating_meta_box( WP_Post $post ): void {
    $rating = absint( get_post_meta( $post->ID, '_quality_rating', true ) );
    ?>
    
ID, 'myplugin_rating_nonce' ); } // Save the rating add_action( 'save_post', function( int $post_id ): void { if ( ! isset( $_POST['myplugin_rating_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['myplugin_rating_nonce'] ) ), 'myplugin_save_rating_' . $post_id ) ) { return; } if ( isset( $_POST['myplugin_quality_rating'] ) ) { update_post_meta( $post_id, '_quality_rating', min( 5, max( 0, absint( $_POST['myplugin_quality_rating'] ) ) ) ); } } );

NOTE: Hidden <input> elements placed inside a Shadow DOM are NOT included in standard HTML form submissions — they must be placed in the main document DOM (as in the example: this.appendChild(this.#hidden) rather than shadow.appendChild()) to be picked up by native form serialization and WordPress admin form POSTs. This is the most common gotcha when first building Web Components that participate in traditional HTML form submission. Also note: Shadow DOM mode 'open' allows JavaScript outside the component to access its shadow root via element.shadowRoot — use 'closed' only when you specifically need to prevent external script access, for example in a payment card input widget where page scripts should not be able to read field values.