Skip to content

Build an overlay widget

You will build a Tally hub: a button on the overlay that drags, snaps to an edge, opens a panel toward the middle of the screen and closes itself when you move away. You write about 60 lines. The OverlayHubWidget base class supplies the rest.

The Tally widget open on the overlay
The finished Tally widget.

An overlay widget lives inside a plugin. If you want the plugin side explained step by step, read Build your first plugin first. If you only want a small UI with no TypeScript, use a custom widget instead.

  • The TeamHUD repository cloned, with pnpm install done and pnpm start working. See Contribute to TeamHUD.
  • Ten minutes.
You get From
Drag, fling and snap to an edge, position saved as a fraction of the screen OverlayHubWidget
Panel opens toward the screen centre and stays fully on screen OverlayHubWidget and th-hub-panel
Hover to reveal, close on pointer-leave, outside click and Escape OverlayHubWidget
Clickable regions registered so clicks outside the widget pass through OverlayHubWidget
Colours, spacing and motion that follow the user’s theme --th-* tokens

You write three things: the region id, the hub button, and the panel body. Never write your own drag, placement or collapse code; pnpm check:widget-contract fails the build if you do.

  1. Copy the starter. plugins/widget-hello/ is the smallest real overlay widget. Copy it and rename it:

    Terminal window
    mkdir plugins/widget-tally
    cp -R plugins/widget-hello/src plugins/widget-hello/package.json plugins/widget-hello/tsconfig.json plugins/widget-tally/

    Copy only these three. Don’t copy the whole folder: its node_modules holds workspace links, and copying through them fills your disk.

    Then replace the name everywhere in the copy. Use a find-and-replace across the folder.

    Find Replace with
    widget-hello widget-tally
    WidgetHello WidgetTally
    Hello (display text) Tally

    Check package.json: name is now @teamhud/widget-tally, and teamhud.plugins[0] has slug: "widget-tally" and mainEntry: "./src/WidgetTallyPlugin.ts". Rename the files WidgetHelloPlugin.ts and widget-hello-widget.ts to match. Then run pnpm install once so the workspace sees the new package.

  2. Register the bundle. In src/WidgetTallyPlugin.ts, startup() tells the overlay where your renderer code lives:

    ComponentBundleRegistry.getInstance().registerBundle({
    bundleName: 'widget-tally-widget',
    targetWindows: ['overlay'],
    federationModule: '/plugins/widget-tally/components',
    eagerPreload: false,
    pluginSlug: 'widget-tally',
    resourceName: 'widget-tally',
    });

    resourceName must equal the name in info().resources, or the on/off switch in Preferences does nothing.

  3. Mount the element. src/components/index.ts creates your element when the overlay loads the bundle:

    import { mountCapableWidget } from '@teamhud/widget-mixins';
    import type { BundleContext } from '@teamhud/renderer';
    import './widget-tally-widget.js';
    let activeWidget: HTMLElement | null = null;
    export function initialize(context: BundleContext): void {
    dispose();
    activeWidget = mountCapableWidget('widget-tally-widget', context);
    }
    export function dispose(): void {
    if (activeWidget) { activeWidget.remove(); activeWidget = null; }
    }
  4. Write the widget. Replace src/components/widget-tally-widget.ts with this. The starter hand-rolls a <button> and marks it; here we use the shared th-button instead, which a theme can restyle.

    import { html, css, nothing } from 'lit';
    import { customElement, state } from 'lit/decorators.js';
    import { hubOverlayBaseStyles } from '@teamhud/pipeline-primitives/hub-overlay-shell';
    import { OverlayHubWidget } from '@teamhud/widget-mixins';
    import '@teamhud/ui-core/components/th-hub-panel.js';
    import '@teamhud/ui-core/components/th-hub-panel-header.js';
    import '@teamhud/ui-core/components/th-button.js';
    const HUB_ID = 'widget-tally';
    // Inline SVG only: currentColor, a viewBox, no stroke-width. The hub sizes and strokes it.
    const TALLY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M6 5v14M10 5v14M14 5v14M18 5v14M3 17L21 7"/></svg>`;
    // The mixin's members are erased by the TypeScript mixin cast, so name the ones you use.
    type OverlayHubHost = {
    _panelSide: 'right' | 'left' | 'top' | 'bottom';
    overlayHubMountPointerDown: (e: PointerEvent) => void;
    overlayHubMountClick: (e: MouseEvent) => void;
    overlayOpenHubContextMenu: (e: MouseEvent) => void;
    overlayRenderHubContextMenu: () => unknown;
    overlayPanelMounted: () => boolean;
    overlayPanelClosing: () => boolean;
    overlayHubPanelWidth: () => number;
    overlayHubPanelHeight: () => number;
    overlayOnHubPanelResize: (e: Event) => void;
    overlayOnHubPanelResizeEnd: (e: Event) => void;
    };
    @customElement('widget-tally-widget')
    export class WidgetTallyWidget extends OverlayHubWidget {
    static override styles = [
    ...hubOverlayBaseStyles,
    css`
    :host { position: fixed; z-index: 9998; display: block; user-select: none;
    font-family: var(--th-font-ui); color: var(--th-text-primary); }
    .body { display: flex; flex-direction: column; gap: var(--th-space-3); padding: var(--th-space-3); }
    .label { font-size: var(--th-text-sm); color: var(--th-text-muted); }
    .count { font-size: var(--th-text-xl); font-variant-numeric: tabular-nums; }
    .row { display: flex; gap: var(--th-space-2); }
    `,
    ];
    @state() private accessor _hovered = false;
    @state() private accessor _count = 0;
    protected overlayRegionOwnerId(): string { return HUB_ID; }
    protected overlayHubRegistryEntry() { return { id: HUB_ID, label: 'Tally', icon: TALLY_ICON }; }
    protected overlayRefreshHoveredVisual(hovered: boolean): void { this._hovered = hovered; }
    private hub(): OverlayHubHost { return this as unknown as OverlayHubHost; }
    override render() {
    const hx = this.hub();
    return html`
    <div id="hub-btn-mount"
    @pointerdown=${hx.overlayHubMountPointerDown}
    @click=${hx.overlayHubMountClick}
    @contextmenu=${hx.overlayOpenHubContextMenu}></div>
    ${hx.overlayPanelMounted() ? this._renderPanel() : nothing}
    ${hx.overlayRenderHubContextMenu()}
    `;
    }
    private _renderPanel() {
    const hx = this.hub();
    return html`
    <th-hub-panel
    .side=${hx._panelSide}
    ?revealed=${this._hovered}
    ?closing=${hx.overlayPanelClosing()}
    .panelWidth=${hx.overlayHubPanelWidth()}
    .panelHeight=${hx.overlayHubPanelHeight()}
    @th-resize=${hx.overlayOnHubPanelResize}
    @th-resize-end=${hx.overlayOnHubPanelResizeEnd}>
    <th-hub-panel-header slot="header"><div slot="title">Tally</div></th-hub-panel-header>
    <div class="body">
    <div class="label">Things counted</div>
    <div class="count">${this._count}</div>
    <div class="row">
    <th-button size="sm" variant="primary" @click=${() => (this._count += 1)}>Count up</th-button>
    <th-button size="sm" @click=${() => (this._count = 0)}>Reset</th-button>
    </div>
    </div>
    </th-hub-panel>`;
    }
    }

    The panel is th-hub-panel, never a hand-made div: that element carries the glass look, the placement and the click regions. If your panel needs an extra popover outside it, override overlayCollectRegions(regions) and push its rectangle. Otherwise leave it alone.

  5. Wire it into the app. The app loads a hand-maintained list, so adding the package is not enough. Make three edits in apps/desktop-lite/:

    // package.json, dependencies
    "@teamhud/widget-tally": "workspace:*",
    src/plugin-entries.ts
    import { WidgetTallyPlugin } from '@teamhud/widget-tally';
    // inside getEnabledPluginEntries():
    toPluginEntry(WidgetTallyPlugin),

    In src/preload.ts, add "WidgetTallyPlugin" to the plugin-name list that sits beside "WidgetWindowFocusPlugin".

  6. Build and run.

    Terminal window
    pnpm install
    pnpm --filter @teamhud/widget-tally build
    pnpm --filter @teamhud/desktop-lite build:component-bundles
    pnpm start
  7. Test it. Add a lifecycle test next to the plugin, the same way widget-window-focus does. Add "test": "vitest run" to scripts and "vitest": "^4.1.11" plus "@mythic/resource-registry": "*" to devDependencies in package.json, a vitest.config.ts with environment: 'node' and include: ['src/**/*.test.ts'], then run pnpm install.

    plugins/widget-tally/src/WidgetTallyPlugin.test.ts
    import { describe, expect, it, vi } from 'vitest';
    import { ResourceRegistry } from '@mythic/resource-registry';
    const registerBundle = vi.fn();
    vi.mock('@mythic/logging', () => ({ Logger: class { debug() {} info() {} warn() {} error() {} } }));
    vi.mock('@teamhud/component-bundle-registry', () => ({
    ComponentBundleRegistry: { getInstance: () => ({ registerBundle }) },
    }));
    import { WidgetTallyPlugin } from './WidgetTallyPlugin.js';
    describe('WidgetTallyPlugin', () => {
    it('registers its overlay bundle under the resource the toggle controls', async () => {
    // A plugin needs the app's EventManager; the test supplies a stand-in.
    if (!ResourceRegistry.get('EventManager')) ResourceRegistry.setValue('EventManager', { emit: vi.fn(), on: vi.fn() });
    const plugin = new WidgetTallyPlugin();
    await plugin.startup();
    const bundle = registerBundle.mock.calls[0][0];
    expect(bundle.targetWindows).toEqual(['overlay']);
    expect(plugin.info().resources?.map((r) => r.name)).toContain(bundle.resourceName);
    });
    });
    Terminal window
    pnpm --filter @teamhud/widget-tally test

    This test catches the most common mistake: a resourceName that does not match resources[].name. To test the element itself, @teamhud/widget-mixins/testing provides createMockWidgetHostContext, createMockCapBridge and createRecordingRegionCaps. See Test and debug for e2e.

  8. Run the checks.

    Terminal window
    pnpm --filter @teamhud/widget-tally type-check
    pnpm check:widget-contract
    pnpm check:theme-tokens
    pnpm check:src-artifacts

A Tally hub appears on the overlay. Click it and the panel opens toward the middle of the screen with a count and two buttons. Drag the hub to another edge and it snaps there; the panel still opens toward the centre. Move the pointer away, click outside, or press Escape and the panel closes. Change your theme in Preferences and the widget changes with it, because it reads only --th-* tokens.

  • Nothing appears on the overlay. One of the three wiring edits in step 5 is missing, or you skipped build:component-bundles. bundleName must equal the bundle key built from your folder, widget-tally-widget.
  • A blank panel, no error. A renderer import failed. Check the overlay with the logs and MCP tools in Test and debug.
  • check:widget-contract fails. You wrote your own drag, snap or placement code. Delete it; the base class does it.
  • check:theme-tokens fails. A hex colour, pixel size or hand-made <button>/<input> slipped in. Use a --th-* token or a th-* element.
  • The hub icon is a black blob. Remove width, height and stroke-width from the SVG and use currentColor.