Skip to content

Updated

View MarkdownOpen in ChatGPTOpen in Claude

Widget SDK

@glasshome/widget-sdk (on npm) provides defineWidget, the Config API (defineConfig + field.*), composable UI components, reactive hooks, the Home Assistant data layer, and theming utilities for SolidJS widgets.

The runtime constant SDK_VERSION equals the installed package version; the host compares it against each widget’s manifest.sdkVersion range.

This page is the guide. For every public export across the three entry points, see the API Reference.

On this page

defineWidget

The entry point for every widget: a manifest (metadata), an optional Zod config schema, an optional migrate function, and a SolidJS component.

function defineWidget<C = Record<string, unknown>>(
  definition: WidgetDefinition<C>
): WidgetDefinition<C>

WidgetDefinition<C> shape:

interface WidgetDefinition<C = Record<string, unknown>> {
  manifest: WidgetManifest;
  configSchema?: ZodType<C, unknown>;
  migrate?: (config: Record<string, unknown>, fromConfigVersion: number) => Record<string, unknown>;
  component: (props: { config: C }) => any;
}

Providing configSchema auto-populates manifest.schema (JSON Schema) and manifest.defaultConfig. Types derive via z.infer; the dashboard renders the edit form from the schema.

Manifest fields

The runtime loads the inline manifest in defineWidget; the CLI reads src/<name>/manifest.json for build and publish metadata (bun widget upgrade writes sdkVersion back to that file). Keep the two in sync: the build step warns on schema drift.

Field Type Required Description
name string yes Display name and identifier
description string no Short description shown in the widget picker
icon string no Iconify icon, e.g. "mdi:lightbulb"
minSize {w, h} yes Minimum grid size
maxSize {w, h} yes Maximum grid size
defaultSize {w, h} no Initial size when added to a dashboard
sdkVersion string yes Required SDK version range, e.g. "^1.0.0"
capabilities CapabilityGrant[] yes (1.x) Home Assistant domains the widget reads or controls; approved by the user, enforced by the host. Empty array for widgets that touch no HA data. See Capabilities & Permissions.
configVersion number no Bump when config shape changes; used with migrate
schema object no Auto-populated from configSchema. Do not set manually.
defaultConfig object no Auto-populated from Zod .default() calls.
cssUrl string no Set by the build when the widget emits a stylesheet. Do not set manually.

widgetFields

Deprecated in 1.4.0

widgetFields.* and building configSchema with raw z.object({ ... }) still work, but they’re deprecated and will be removed in a future major release. Use the Config API (defineConfig + field.*) instead, or run bun widget migrate config. bun widget build warns on each use.

Pre-built Zod field helpers; each attaches metadata so the dashboard renders the right form control.

Helper Renders as Replacement
widgetFields.title() Text input labelled “Title”, hint “Optional display name override” field.title()
widgetFields.entityIds(domain) Multi-select entity picker filtered by domain field.entities(domain)
widgetFields.singleEntity(domain) Single-select entity picker filtered by domain field.entity(domain)
widgetFields.areaId() Area picker dropdown field.area()

See the Config API for the full field.* surface (field.text, field.number, field.toggle, field.choice, field.stringList, field.group) and type inference with Infer.

Complete widget example

A working example mirroring the official @glasshome widgets: config schema, entities bound from config via useEntities, group aggregation via useWidgetEntityGroup, a tap-to-toggle gesture, and a schema-driven settings dialog. Note that you bind entities yourself from the ids the user picked in config; there is no hidden injection.

// src/my-lights/index.tsx
import {
  defineWidget,
  Widget,
  WidgetDialog,
  useEntities,
  useService,
  useWidgetContext,
  useWidgetDialog,
  useWidgetEntityGroup,
  useWidgetGestures,
  widgetFields,
} from "@glasshome/widget-sdk";
// Dialog chrome (ResponsiveDialog, Button, SchemaForm) is injected, not bundled by the SDK.
// The official widgets re-export these from @glasshome/ui as a `widgetDialogProps` object.
import {
  Button,
  ResponsiveDialog,
  ResponsiveDialogContent,
  ResponsiveDialogDescription,
  ResponsiveDialogHeader,
  ResponsiveDialogTitle,
  SchemaForm,
} from "@glasshome/ui/solid";
import { onCleanup } from "solid-js";
import { z } from "zod";

const configSchema = z.object({
  title: widgetFields.title(),
  entityIds: widgetFields.entityIds("light"),
});

type Config = z.infer<typeof configSchema>;

function MyLightsWidget(props: { config: Config }) {
  const ctx = useWidgetContext();
  const { openDialog, setShowDialog, dialogProps } = useWidgetDialog();
  const { toggle } = useService();

  // Bind the entities the user picked in config. Reactive: updates when state changes.
  const entities = useEntities(() => props.config.entityIds);

  const { aggregatedData, emptyState, hasEntities } = useWidgetEntityGroup({
    entities,
    aggregationMode: () => "light",
    emptyStateConfig: {
      icon: <span>💡</span>,
      title: "No lights",
      message: "Hold to configure",
    },
  });

  const lightData = () => aggregatedData();
  const isOn = () => lightData()?.isOn ?? false;

  const gestures = useWidgetGestures(() => ({
    tap: () => toggle(entities().map((e) => e.id)),
    hold: { action: openDialog },
  }));
  onCleanup(gestures.dispose);

  return (
    <>
      <Widget
        gestures={gestures}
        variant="classic-glass"
        tone={isOn() ? "accent" : "neutral"}
        emptyState={emptyState()}
        loading={!hasEntities()}
      >
        <Widget.Title>{props.config.title || "Lights"}</Widget.Title>
        <Widget.Status>{isOn() ? "On" : "Off"}</Widget.Status>
        <Widget.Value>
          {lightData() ? `${lightData()!.onCount}/${lightData()!.totalCount}` : ""}
        </Widget.Value>
      </Widget>
      <WidgetDialog
        {...dialogProps}
        ResponsiveDialog={ResponsiveDialog}
        ResponsiveDialogContent={ResponsiveDialogContent}
        ResponsiveDialogHeader={ResponsiveDialogHeader}
        ResponsiveDialogTitle={ResponsiveDialogTitle}
        ResponsiveDialogDescription={ResponsiveDialogDescription}
        Button={Button}
        SchemaForm={SchemaForm}
        title="Lights"
        maxWidth="lg"
        configSchema={configSchema}
        config={props.config}
        onConfigSave={(config) => {
          ctx.updateConfig(config);
          setShowDialog(false);
        }}
      />
    </>
  );
}

export default defineWidget<Config>({
  manifest: {
    name: "My Lights",
    description: "Toggle a group of lights",
    icon: "mdi:lightbulb-group",
    minSize: { w: 1, h: 1 },
    maxSize: { w: 4, h: 4 },
    sdkVersion: "^1.0.0",
    capabilities: [{ domain: "light", access: "control" }],
  },
  configSchema,
  component: MyLightsWidget,
});

Binding entities from config

useEntities(() => props.config.entityIds) resolves the ids the user picked (via the widgetFields.entityIds picker) into live EntityView[]. Pass that accessor straight into useWidgetEntityGroup. For a single-entity widget use useEntity(() => props.config.entityId) instead. The scaffold template ships this exact wiring.

The dialog needs UI components

<WidgetDialog> renders no chrome of its own. It takes ResponsiveDialog, Button, SchemaForm, and friends as props so your widget controls the look. Import them from @glasshome/ui/solid (keep @glasshome/ui as a dependency). See WidgetDialog component for the full prop list.

Widget component

<Widget> is the main container: context, gesture handling, theming, and base styling.

Props

interface WidgetProps {
  variant?: string | WidgetVariantConfig;
  tone?: Tone;           // semantic color: resolves to --widget-color
  color?: string;        // CSS color override for --widget-color (overrides tone)
  colorTo?: string;      // second gradient stop (--widget-color-to)
  gradient?: string;     // full CSS gradient (overrides auto-shell)
  loading?: boolean;
  class?: string;
  isEditMode?: boolean;
  onDelete?: () => void;
  emptyState?: { icon?: JSX.Element; title?: string; message?: string };
  gestures?: GestureHandlers; // return value of useWidgetGestures(...)
  children?: JSX.Element;
}

Pass the return value of useWidgetGestures(...) to gestures, not a plain object.

Variants

variant picks a pre-built look (shell background, blur, layout defaults, hover/active behavior): a built-in id string or a full WidgetVariantConfig object. The SDK ships three:

variant id Look
"classic-glass" Default glassmorphism: blurred translucent background, gradient shell, hover/active scale. What every official @glasshome widget uses.
"minimal" Clean, no background effects. Tighter padding.
"compact-horizontal" Horizontal layout for short, wide tiles.
<Widget variant="classic-glass" tone="accent">…</Widget>

No variant means no shell styling

If you omit variant, no variant styling is applied (you get a bare shell). For the standard GlassHome look, pass variant="classic-glass". The scaffold and every built-in widget do.

WidgetVariantConfig is exported from the SDK for fully custom looks. Tone and color props (Theming, Styling) layer on top of whichever variant you choose.

Slot subcomponents

Slot Key props Purpose
Widget.Icon icon: JSX.Element, color?, dimmed?, entityCount? Entity icon with adaptive color
Widget.Title children Primary label
Widget.Status children State text (On, Off, etc.)
Widget.Value children Numeric or formatted value
Widget.Content children, class? Freeform content area
Widget.SliderFill value: number (0–100), color?, isDragging? Background fill for slider widgets

Widget.SliderFill is also exported standalone as WidgetSliderFill (same component). Pass isDragging so the fill tracks the finger 1:1 while dragging (drops the transition). See Animation.

Component props

Every widget component receives one prop, { config: C }, where C is the type passed to defineWidget<C>. The host parses, migrates, and validates the saved config first, so config always matches your current schema type.

Hooks

useWidgetContext()

Returns ReactiveWidgetContext. Must be called inside the <Widget> render tree.

interface ReactiveWidgetContext {
  isEditMode: () => boolean;
  updateConfig: (config: Record<string, unknown>) => void;
  dimensions: () => { width: number; height: number }; // CSS px, (0,0) before first layout
  registerDialogOpener?: (open: ((tab?: string) => void) | null) => void;
}

useWidgetEntityGroup(options)

Aggregates one or many entities with built-in aggregation presets and empty-state support.

function useWidgetEntityGroup<TData = unknown>(
  options: UseWidgetEntityGroupOptions<TData>
): UseWidgetEntityGroupResult<TData>

Options:

Field Type Description
entities Accessor<EntityView[]> Reactive entities array (required)
emptyStateConfig WidgetEmptyStateConfig Shown when hasEntities is false (required)
aggregationMode Accessor<AggregationPreset | undefined> "light", "sensor", "switch", "binary-sensor", "none"
sensorGroupType Accessor<SensorGroupType> For "sensor" preset: "min", "max", "mean", "median", "sum", "last", etc.
calculateGroupData (entities: EntityView[]) => TData Custom aggregation function (alternative to aggregationMode)
allEntitiesMode boolean All entities must be on for group to be “on” (light/switch presets)
minEntities number Minimum entities required; default 1

Return value:

Field Type Description
entities Accessor<EntityView[]> All entities
groupData Accessor<TData | null> Result of calculateGroupData, or null
aggregatedData Accessor<LightGroupResult | SensorGroupResult | undefined> Result of preset aggregation
emptyState Accessor<WidgetEmptyStateConfig | undefined> Defined when entities are missing
hasEntities Accessor<boolean> Whether minimum entities are present
count Accessor<number> Entity count

LightGroupResult includes: isOn, isUnavailable, brightness (0-255), brightnessPercent (0-100), color, onCount, totalCount, description.

useWidgetDialog(defaultTab?)

Controls the widget settings dialog. defaultTab defaults to "controls".

interface WidgetDialogReturn {
  showDialog: () => boolean;
  setShowDialog: (open: boolean) => void;
  openDialog: (tab?: string) => void;
  closeDialog: () => void;
  activeTab: () => string;
  setActiveTab: (tab: string) => void;
  dialogProps: {
    open: boolean;
    onOpenChange: (open: boolean) => void;
    activeTab: string;
    onActiveTabChange: (tab: string) => void;
  };
}

Spread dialogProps onto <WidgetDialog> to wire open/close and tab state automatically.

WidgetDialog component

<WidgetDialog> is the settings/detail dialog: a tabbed panel that can host device controls, a schema-driven config editor, and a debug view. It renders no chrome of its own; you inject the dialog/button/form components as props.

Injected UI components (required)

These have no defaults; the dialog will not render without them. Import from @glasshome/ui/solid:

Prop Component
ResponsiveDialog Dialog/drawer wrapper
ResponsiveDialogContent Body
ResponsiveDialogHeader Header region
ResponsiveDialogTitle Title
ResponsiveDialogDescription Subtitle/description
Button Buttons (save, delete, tab controls)
SchemaForm Form renderer for schema-driven config editing. Required only when you use configSchema.

The official @glasshome widgets bundle these into a single widgetDialogProps object and spread it (<WidgetDialog {...widgetDialogProps} {...dialogProps} … />). Do the same in your project to keep call sites short.

Content props

Prop Type Purpose
title string Dialog title (required)
controlsContent JSX.Element Device-controls tab (sliders, buttons for the bound entities)
configSchema ZodType Enables the schema-driven config editor tab. Pair with config + onConfigSave.
config Record<string, unknown> Current config to seed the editor
onConfigSave (config) => void Called with the new config; typically ctx.updateConfig(config) then close
editContent JSX.Element Custom config UI, used instead of the schema editor when configSchema is absent
debugContent JSX.Element Debug tab body
debugData string | Record<string, unknown> Raw debug payload (rendered if no debugContent)
tabs WidgetDialogTab[] Fully custom tabs: { id, label, icon, content }[]
defaultTab / activeTab + onActiveTabChange string / controlled pair Initial or controlled active tab
headerActions JSX.Element Extra buttons in the header
onDelete () => void Renders a delete action
maxWidth "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" Dialog max width

Config-only widgets pass just configSchema + config + onConfigSave; control widgets add controlsContent. See the complete example for the wiring.

useWidgetGestures(config, orientation?)

Tap, hold, and slide gestures on any pointer type.

function useWidgetGestures(
  config: () => GestureConfig,
  orientation?: () => "horizontal" | "vertical" | "square"
): GestureHandlers

Both arguments are SolidJS accessors; orientation is optional and defaults to horizontal.

interface GestureConfig {
  tap?: () => void;
  hold?: { action: () => void; delay?: number }; // delay in ms, default 300
  slide?: {
    value: number;
    onChange: (value: number) => void;
    min?: number; max?: number;
    orientation?: "auto" | "horizontal" | "vertical";
    activationDelay?: number;
  };
}

GestureHandlers (returned value) carries onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onPointerEnter, bindElement, touchAction, and dispose. Pass the whole object to <Widget gestures={...} />. Call dispose() in onCleanup.

useReducedMotion()

Reactive prefers-reduced-motion accessor, () => boolean; false when matchMedia is unavailable (SSR-safe).

useIntersectionPause(elAccessor)

Returns () => boolean: true while the element (passed as an accessor) is off-screen; false when IntersectionObserver is unavailable (SSR-safe).

Home Assistant data & services

Import all Home Assistant hooks from @glasshome/widget-sdk, never from @glasshome/sync-layer directly (a direct import fails the build and would bundle a second, disconnected store). Read hooks are SolidJS accessors backed by the host’s live store, so UI stays reactive with no WebSocket state to manage. Reads and service calls are checked against your declared capabilities.

Reading entities

Hook Returns Purpose
useEntity(id) Accessor<EntityView | undefined> One entity by id (id may be a string or accessor)
useEntities(ids) Accessor<EntityView[]> Many entities; pass an accessor returning the id array
useArea(id) Accessor<AreaView | undefined> An area and its entities
useEntityHistory(id) Accessor<EntityHistoryData | undefined> Recent state history for an entity
useEntityStatistics(id, options) Resource<StatisticValue[]> Long-term statistics; the resource exposes .loading and .error
useForecast(id) Accessor<WeatherForecastsData | undefined> Weather forecast for a weather entity
useCamera(id) { stream, refresh } Reactive camera stream data
useStore(selector) Accessor<T> Escape hatch for direct store access when no specific hook fits
import { useEntity, useEntities } from "@glasshome/widget-sdk";

function Thermostat(props: { config: Config }) {
  const climate = useEntity(() => props.config.entityId);
  const temp = () => climate()?.attributes.current_temperature;
  return <Widget.Value>{temp() ?? "—"}</Widget.Value>;
}

The EntityView shape

Every read hook returns EntityView objects. The most-used fields:

Field Type Notes
id string Entity id, e.g. "light.kitchen"
domain string "light", "sensor", …
state string Current state value ("on", "22.5", …)
attributes Record<string, any> HA attributes, minus the four surfaced below. Read those from the canonical fields, not from here.
friendlyName string Display name
icon string | null Resolved icon (registry → attribute → domain default)
deviceClass string | null Resolved device class
unitOfMeasurement string | null Resolved unit
areaId / deviceId string | null Placement / device
lastChanged / lastUpdated Date Timestamps

EntityView is exported as a type from the SDK for your own annotations.

Connection & locale

Hook Returns
useConnection() { status, isConnected } accessors
useHassConfig() Accessor<HassConfig | null>
useUnitSystem() Accessor<HassUnitSystem | null>
useTemperatureUnit() Accessor<string> (e.g. "°C")
useLocale() Accessor<string> (BCP 47)
useCurrency() Accessor<string> (ISO 4217)

Calling services

useService() returns a capability-routed service caller plus shortcuts that take an entity id (or array) and optional service data:

import { useService } from "@glasshome/widget-sdk";

function LightToggle(props: { config: Config }) {
  const { toggle, turnOn, callService } = useService();

  return (
    <Widget gestures={useWidgetGestures(() => ({ tap: () => toggle(props.config.entityId) }))}>
      {/* turnOn("light.x", { brightness_pct: 80 }) */}
      {/* callService("light", "turn_on", { brightness_pct: 80 }, { entity_id: "light.x" }) */}
    </Widget>
  );
}
Hook Returns
useService() { callService, turnOn, turnOff, toggle }
useTurnOn() / useTurnOff() / useToggle() The single shortcut function

callService(domain, service, data?, target?) is the general form; it resolves to Promise<void>. The host validates the call against your granted capabilities before forwarding it.

Errors and empty states

  • Config schema parse failure. If the stored config fails Zod validation after migration, the dashboard falls back to configSchema.parse({}) (all-defaults), then to {}. No error is shown to the user; handle missing/default config gracefully.
  • Missing entities. Pass an emptyStateConfig to useWidgetEntityGroup. When hasEntities() is false, emptyState() returns the config object; pass it to <Widget emptyState={emptyState()}> to render the built-in empty state UI.
  • Component throw. A render throw is caught by an ErrorBoundary: after one crash, a retryable error card with a Retry button; after three crashes (configurable in the Dash host), a permanent “disabled” card until the page is refreshed.

Theming

Widgets inherit the dashboard theme automatically and render in an isolated shadow root with their own CSS bundle. The essentials:

  • Use the tone prop on <Widget> for semantic color (tone="accent"), or color/colorTo/gradient for a custom shell.
  • isDark() returns the current theme as a boolean for logic; dark: Tailwind variants work in markup.
  • The SDK also exports injectTokens, Tone, and ToneSchema.

The full styling model (Tailwind, your own CSS, theme variables, container queries, animation): Styling & Animation.

Entity utilities

Function Description
isEntityActive(entity) true for active states
getEntityAttribute(entity, key) Read a specific attribute
countActiveEntities(entities) Count of currently active entities
calculateLightGroup(entities, allEntitiesMode?) Aggregate brightness/color/state across a light group
calculateSensorGroup(entities, groupType?, ignoreNonNumeric?) Aggregate numeric sensors

Build tooling

The scaffolded vite.config.ts wires this up automatically. Two entrypoints for custom setups:

@glasshome/widget-sdk/vite

Export Purpose
glasshomeWidget(options?) Single-widget plugin: dev preview + library build
glasshomeWidgets(options?) Multi-widget project plugin: per-widget builds + registry.json
buildWidgets(options?) Programmatic build of all widgets (used by bun widget build)
discoverWidgets(srcDir) Scan src/ for subdirs containing index.tsx + manifest.json
generateRegistry(srcDir, outDir) Write a registry.json from discovered manifests
isWidgetExternal(id) true for host-provided packages (not bundled)

@glasshome/widget-sdk/schemas

Zod schemas for validating manifests and publish payloads. Used by the CLI and Hub.

Re-exported from the canonical @glasshome/widget-contract package shared by the SDK, CLI, Hub, and Dash, so the same schema validates a manifest everywhere.

Export Purpose
widgetManifestSchema (alias WidgetManifestSchema) Validate a complete widget manifest
publishManifestSchema Validate the manifest subset sent at publish time
capabilitiesSchema Validate a capabilities array
capabilityGrantSchema Validate a single { domain, access } grant
GridSizeSchema Validate { w, h }
parseGridSize / serializeGridSize Parse from / serialize to the stored JSON form
formatSchemaError Turn a Zod error into a readable message
PublishRequestSchema / PublishConfirmSchema / PublishBodySchema Publish-flow request bodies
CapabilityGrant (type) TypeScript type for one grant

formatSchemaError and WidgetManifestSchema are also available from the SDK main entry (@glasshome/widget-sdk) for convenience.