Skip to content

example register widget

github-actions[bot] edited this page Aug 29, 2026 · 2 revisions

Register a widget

Widgets are passive cards in the right-side column — or floating freely on the desktop if movable: true. They are for glanceable, persistent content: a clock, a comment queue, a stats chart. Not launchers, not interactive tools.


Recipe 1 — Minimum viable widget

The smallest complete example. A PHP file registers the widget metadata and script handle; a TypeScript file declares the mount callback.

my-plugin.php

<?php
/** Plugin Name: My Widget Plugin */
defined( 'ABSPATH' ) || exit;

function myplugin_register_hello_widget_assets() {
    $version = '1.0.0';

    // Register CSS eagerly — the JS loads lazily but the stylesheet needs to
    // be in the DOM before the first render to avoid a flash of unstyled content.
    wp_register_style(
        'myplugin-hello-widget',
        plugin_dir_url( __FILE__ ) . 'assets/js/widget-hello.min.css',
        array(),
        $version
    );

    // Register JS — do NOT enqueue it directly. The shell's server-sync
    // loads this bundle lazily when the widget picker opens or the widget
    // mounts. Using wp_enqueue_script() here would load it on every admin page.
    wp_register_script(
        'myplugin-hello-widget',
        plugin_dir_url( __FILE__ ) . 'assets/js/widget-hello.min.js',
        array(),
        $version,
        true
    );
}
add_action( 'init', 'myplugin_register_hello_widget_assets', 5 );

// Eagerly enqueue the CSS on OpenStation shell pages only.
function myplugin_enqueue_hello_widget_styles() {
    if ( function_exists( 'openstation_is_enabled' ) && ! openstation_is_enabled() ) {
        return;
    }
    if ( function_exists( 'openstation_is_chromeless_request' ) && openstation_is_chromeless_request() ) {
        return;
    }
    wp_enqueue_style( 'myplugin-hello-widget' );
}
add_action( 'admin_enqueue_scripts', 'myplugin_enqueue_hello_widget_styles', 20 );

// Announce the widget to OpenStation so it appears in the picker.
function myplugin_register_hello_widget() {
    if ( ! function_exists( 'openstation_register_widget' ) ) {
        return;
    }
    openstation_register_widget( 'myplugin/hello', array(
        'label'          => __( 'Hello Widget', 'myplugin' ),
        'description'    => __( 'A simple greeting.', 'myplugin' ),
        'icon'           => 'dashicons-smiley',
        'script'         => 'myplugin-hello-widget',
        'movable'        => true,
        'resizable'      => true,
        'min_width'      => 200,
        'min_height'     => 100,
        'default_width'  => 260,
        'default_height' => 140,
    ) );
}
add_action( 'init', 'myplugin_register_hello_widget', 6 );

src/plugins/hello-widget/index.ts

import './styles.css';
import type { WidgetContext, WidgetTeardown } from '../../widgets/types';

// Must match the id passed to openstation_register_widget() in PHP exactly.
// Do not rename this after users have the widget enabled — it is the
// localStorage key for their preference and renaming it resets everyone.
const WIDGET_ID = 'myplugin/hello';

const mount = (
    container: HTMLElement,
    _ctx: WidgetContext,
): WidgetTeardown => {
    const p = document.createElement( 'p' );
    p.className = 'my-hello__text';
    p.textContent = 'Hello from my widget!';
    container.appendChild( p );

    // Return a teardown — always required even with nothing to clean up.
    return () => undefined;
};

const w = window as unknown as {
    openStationWidgets?: Record< string, typeof mount >;
};
w.openStationWidgets = w.openStationWidgets ?? {};
w.openStationWidgets[ WIDGET_ID ] = mount;

Add a Vite target in vite.config.js inside the TARGETS object:

'widget-hello': {
    entry:    'src/plugins/hello-widget/index.ts',
    fileBase: 'widget-hello',
    iifeName: 'openStationHelloWidget',
},

Add a build script in package.json:

"build:widget-hello": "OPENSTATION_TARGET=widget-hello vite build --mode development && OPENSTATION_TARGET=widget-hello vite build --mode production"

Then build:

npm run build:widget-hello

The widget appears in the picker immediately after the next page load.


Recipe 2 — Polling with REST data

Fetches the latest post title on mount and refreshes every minute.

Always use trackedFetch from ../../tracked-fetch — never raw fetch(). The repo's ESLint config bans raw fetch() calls. trackedFetch routes requests through the framework (loading spinner, activity bus) and injects the REST nonce automatically — no manual X-WP-Nonce header needed.

import { trackedFetch } from '../../tracked-fetch';
import type { WidgetContext, WidgetTeardown } from '../../widgets/types';

const WIDGET_ID = 'myplugin/latest-post';

const mount = async (
    container: HTMLElement,
    _ctx: WidgetContext,
): Promise< WidgetTeardown > => {
    // Declare destroyed at the very top. Check it after every await —
    // the widget may be removed while a network request is in flight.
    let destroyed = false;

    const body = document.createElement( 'p' );
    body.textContent = 'Loading\u2026';
    container.appendChild( body );

    const root = ( window as unknown as { wpApiSettings?: { root?: string } } )
        .wpApiSettings?.root ?? '/wp-json/';

    const refresh = async () => {
        if ( destroyed ) return;
        try {
            const res = await trackedFetch(
                root.replace( /\/$/, '' ) + '/wp/v2/posts?per_page=1&_fields=title',
                { credentials: 'same-origin' },
                { source: 'myplugin/latest-post', silent: true },
            );
            if ( destroyed ) return;         // check after every await
            if ( ! res.ok ) return;
            const posts = await res.json() as Array< { title: { rendered: string } } >;
            if ( destroyed ) return;         // check after the second await too
            body.textContent = posts[ 0 ]?.title.rendered ?? 'No posts found.';
        } catch {
            if ( ! destroyed ) body.textContent = 'Could not load data.';
        }
    };

    await refresh();

    // Poll — but pause while the tab is hidden. Nobody sees the
    // repaint and the requests still hit the server, so stop the
    // timer on visibilitychange → hidden and restart on reveal,
    // catching up immediately only when the data has gone stale
    // (a quick tab flip shouldn't cost a request).
    const POLL_MS = 60_000;
    let intervalId: ReturnType< typeof setInterval > | null = null;
    let lastRunMs = Date.now();
    const poll = () => {
        lastRunMs = Date.now();
        void refresh();
    };
    const startPolling = () => {
        if ( intervalId === null ) intervalId = setInterval( poll, POLL_MS );
    };
    const stopPolling = () => {
        if ( intervalId !== null ) {
            clearInterval( intervalId );
            intervalId = null;
        }
    };
    const onVisibilityChange = () => {
        if ( document.hidden ) {
            stopPolling();
            return;
        }
        if ( Date.now() - lastRunMs >= POLL_MS ) poll();
        startPolling();
    };
    document.addEventListener( 'visibilitychange', onVisibilityChange );
    if ( ! document.hidden ) startPolling();

    return () => {
        destroyed = true;
        stopPolling();
        document.removeEventListener( 'visibilitychange', onVisibilityChange );
    };
};

const w = window as unknown as {
    openStationWidgets?: Record< string, typeof mount >;
};
w.openStationWidgets = w.openStationWidgets ?? {};
w.openStationWidgets[ WIDGET_ID ] = mount;

Recipe 3 — Persisting user preferences

ctx.storage is a namespaced localStorage wrapper. Keys are scoped to your widget id automatically so two widgets can both use 'preferences' without colliding.

const mount = async ( container: HTMLElement, ctx: WidgetContext ) => {
    // get() returns null when the key does not exist yet.
    // Always provide a fallback — storage may be unavailable
    // (private browsing, quota exceeded).
    const count = ctx.storage.get< number >( 'clicks' ) ?? 0;

    const btn = document.createElement( 'button' );
    btn.textContent = `Clicked ${ count } times`;

    btn.addEventListener( 'click', () => {
        const next = ( ctx.storage.get< number >( 'clicks' ) ?? 0 ) + 1;
        ctx.storage.set( 'clicks', next );
        btn.textContent = `Clicked ${ next } times`;
    } );

    container.appendChild( btn );
    return () => undefined;
};

Values round-trip through JSON.stringify / JSON.parse. Plain objects, arrays, and primitives work. Class instances, Date, and Map do not — convert them first (date.toISOString(), Array.from( map )).


Recipe 4 — Resize-aware canvas chart

Use ResizeObserver to trigger the initial draw and all subsequent redraws. Check entry.contentRect is non-zero before drawing — the canvas may not have layout yet when mount first runs. Never use setTimeout as a workaround for waiting on layout.

const mount = async ( container: HTMLElement, _ctx: WidgetContext ) => {
    let destroyed = false;
    let ro: ResizeObserver | null = null;

    const wrap = document.createElement( 'div' );
    wrap.style.cssText = 'flex:1; min-height:0;';
    const canvas = document.createElement( 'canvas' );
    canvas.style.cssText = 'display:block; width:100%; height:100%;';
    wrap.appendChild( canvas );
    container.appendChild( wrap );

    const draw = () => {
        const rect = canvas.getBoundingClientRect();
        if ( rect.width === 0 || rect.height === 0 ) return;  // not laid out yet
        const dpr = window.devicePixelRatio || 1;
        canvas.width  = Math.round( rect.width  * dpr );
        canvas.height = Math.round( rect.height * dpr );
        const ctx = canvas.getContext( '2d' );
        if ( ! ctx ) return;
        ctx.scale( dpr, dpr );
        // ... your drawing code here
    };

    // ResizeObserver fires as soon as the element has real layout dimensions.
    // This handles both the initial draw and any subsequent resizes.
    ro = new ResizeObserver( ( entries ) => {
        if ( destroyed ) return;
        const entry = entries[ 0 ];
        if ( entry && entry.contentRect.width > 0 ) draw();
    } );
    ro.observe( wrap );

    return () => {
        destroyed = true;
        ro?.disconnect();
    };
};

Size constraints reference

All sizes are pixels, passed to openstation_register_widget():

Arg Effect
min_width Smallest width the user can drag the card to
min_height Smallest height the user can drag the card to
max_width Optional ceiling on user-driven resize
max_height Optional ceiling on user-driven resize
default_width Starting width when first added as a floating widget
default_height Starting height when first added as a floating widget

movable: true lets the user drag the widget off the column. resizable: true adds resize handles — movable: true gives all 8 corner and edge handles; column-docked widgets get a bottom-edge handle only.


Theme tokens

Your stylesheet gets five custom properties from the shell. Read them and your widget follows the desktop theme and the accent the user picked, with no extra code:

Token Role
--os-ui-color-text Primary text — body copy, headings
--os-ui-color-text-subtle Muted text — labels, timestamps, metadata
--os-ui-color-border Divider lines and borders
--os-ui-color-accent Buttons, highlights, links
--os-ui-color-surface The card's glass background (rarely needed)
.my-widget__label {
	color: var( --os-ui-color-text-subtle, rgba( 255, 255, 255, 0.7 ) );
	border-bottom: 1px solid var( --os-ui-color-border, rgba( 255, 255, 255, 0.12 ) );
}

Pick a fallback that reads on dark. The card is a dark glass panel in every theme, Legacy included, so the first four resolve to on-dark values. A light-theme grey like #6b7280 in the var() fallback looks fine while the palette is present and turns near-invisible the moment it isn't — the failure is silent, because an undeclared custom property just yields to the fallback with no error.


See also

Home

Guides

Migration notes

More

Examples

All examples

Clone this wiki locally