Skip to content

Widget SDK

@glasshome/widget-sdk (latest: 1.2.0, published on npm) provides the defineWidget API, composable UI components, reactive hooks, the Home Assistant data layer, and theming utilities for SolidJS-based widgets.

The package also exports a runtime version constant, SDK_VERSION, which equals the installed package version. The host compares it against each widget’s manifest.sdkVersion range.

This page is the guide: the important API in context, with examples. For the exhaustive list of every public export across the three entry points, see the API Reference.

On this page

defineWidget

The entry point for every widget. Combines 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;
}

When you provide configSchema, defineWidget auto-populates manifest.schema (JSON Schema) and manifest.defaultConfig from it. TypeScript types are derived via z.infer. The dashboard renders the edit form from the schema automatically.

Manifest fields

The manifest object inside WidgetDefinition is authoritative. The CLI reads src/<name>/manifest.json for build and publish metadata; bun widget upgrade writes sdkVersion back to that file. Both the inline manifest (in defineWidget) and manifest.json are used: the inline manifest is what the widget runtime loads; manifest.json is what the CLI reads. Keep them in sync (the build step warns on schema drift).

FieldTypeRequiredDescription
namestringyesDisplay name and identifier
descriptionstringnoShort description shown in the widget picker
iconstringnoIconify icon, e.g. "mdi:lightbulb"
minSize{w, h}yesMinimum grid size
maxSize{w, h}yesMaximum grid size
defaultSize{w, h}noInitial size when added to a dashboard
sdkVersionstringyesRequired SDK version range, e.g. "^1.0.0"
capabilitiesCapabilityGrant[]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.
configVersionnumbernoBump when config shape changes; used with migrate
schemaobjectnoAuto-populated from configSchema. Do not set manually.
defaultConfigobjectnoAuto-populated from Zod .default() calls.
cssUrlstringnoSet by the build when the widget emits a stylesheet. Do not set manually.

widgetFields

Pre-built Zod field helpers. Each attaches metadata that the dashboard uses to render the right form control.

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

For other types, use Zod directly. z.boolean() renders a switch, z.enum([...]) a dropdown, z.number() a number input. Add .meta({ title: "Label" }) to set the display label.

Complete widget example

A working example that mirrors how the official @glasshome widgets are written: a config schema, entities bound from config via useEntities, group aggregation via useWidgetEntityGroup, a tap-to-toggle gesture, and a settings dialog with schema-driven config editing.

The key wiring to notice: you bind entities yourself from the ids the user picked in config (useEntities(() => props.config.entityIds)), then pass that accessor into useWidgetEntityGroup. There is no hidden injection: the entity ids come from your config, and the SDK keeps the resolved entities reactive.

// 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. It provides 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). Pass a built-in id (a string) or a full WidgetVariantConfig object. The SDK ships three:

variant idLook
"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.

For a fully custom look, pass a WidgetVariantConfig object instead of a built-in id (that type is exported from the SDK). Tone and color props (Theming, and Styling) layer on top of whichever variant you choose.

Slot subcomponents

SlotKey propsPurpose
Widget.Iconicon: JSX.Element, color?, dimmed?, entityCount?Entity icon with adaptive color
Widget.TitlechildrenPrimary label
Widget.StatuschildrenState text (On, Off, etc.)
Widget.ValuechildrenNumeric or formatted value
Widget.Contentchildren, class?Freeform content area
Widget.SliderFillvalue: number (0–100), color?, isDragging?Background fill for slider widgets

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

Component props

Every widget component receives one prop: { config: C } where C is the type you pass to defineWidget<C>. The host parses, migrates, and validates the saved config before passing it, 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:

FieldTypeDescription
entitiesAccessor<EntityView[]>Reactive entities array (required)
emptyStateConfigWidgetEmptyStateConfigShown when hasEntities is false (required)
aggregationModeAccessor<AggregationPreset | undefined>"light", "sensor", "switch", "binary-sensor", "none"
sensorGroupTypeAccessor<SensorGroupType>For "sensor" preset: "min", "max", "mean", "median", "sum", "last", etc.
calculateGroupData(entities: EntityView[]) => TDataCustom aggregation function (alternative to aggregationMode)
allEntitiesModebooleanAll entities must be on for group to be “on” (light/switch presets)
minEntitiesnumberMinimum entities required; default 1

Return value:

FieldTypeDescription
entitiesAccessor<EntityView[]>All entities
groupDataAccessor<TData | null>Result of calculateGroupData, or null
aggregatedDataAccessor<LightGroupResult | SensorGroupResult | undefined>Result of preset aggregation
emptyStateAccessor<WidgetEmptyStateConfig | undefined>Defined when entities are missing
hasEntitiesAccessor<boolean>Whether minimum entities are present
countAccessor<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. The dialog itself is documented next.

WidgetDialog component

<WidgetDialog> is the settings/detail dialog for a widget: a tabbed panel that can host device controls, a schema-driven config editor, and a debug view. It renders no chrome of its own so it can match your theme. You inject the dialog/button/form components as props (the official widgets import them from @glasshome/ui/solid). Spread dialogProps from useWidgetDialog() to wire its open/tab state.

Injected UI components (required)

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

PropComponent
ResponsiveDialogDialog/drawer wrapper
ResponsiveDialogContentBody
ResponsiveDialogHeaderHeader region
ResponsiveDialogTitleTitle
ResponsiveDialogDescriptionSubtitle/description
ButtonButtons (save, delete, tab controls)
SchemaFormForm 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

PropTypePurpose
titlestringDialog title (required)
controlsContentJSX.ElementDevice-controls tab (sliders, buttons for the bound entities)
configSchemaZodTypeEnables the schema-driven config editor tab. Pair with config + onConfigSave.
configRecord<string, unknown>Current config to seed the editor
onConfigSave(config) => voidCalled with the new config; typically ctx.updateConfig(config) then close
editContentJSX.ElementCustom config UI, used instead of the schema editor when configSchema is absent
debugContentJSX.ElementDebug tab body
debugDatastring | Record<string, unknown>Raw debug payload (rendered if no debugContent)
tabsWidgetDialogTab[]Fully custom tabs: { id, label, icon, content }[]
defaultTab / activeTab + onActiveTabChangestring / controlled pairInitial or controlled active tab
headerActionsJSX.ElementExtra buttons in the header
onDelete() => voidRenders a delete action
maxWidth"sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"Dialog max width

A widget that only needs to edit config can pass configSchema + config + onConfigSave and nothing else; a control widget adds controlsContent. See the complete example for the full 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. The orientation arg is optional (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()

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

useIntersectionPause(elAccessor)

Returns () => boolean: true while the given element is off-screen. Pass an accessor returning the element to observe. Returns false when IntersectionObserver is unavailable (SSR-safe).

Home Assistant data & services

All Home Assistant access goes through hooks re-exported from the SDK. Import them from @glasshome/widget-sdk, never from @glasshome/sync-layer directly (a direct import fails the build, and would bundle a second, disconnected store). Every read hook is a SolidJS accessor backed by the host’s live store, so your UI stays reactive without managing any WebSocket state. Reads and service calls are checked against the capabilities your manifest declares.

Reading entities

HookReturnsPurpose
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 one or more EntityView objects. The fields you reach for most:

FieldTypeNotes
idstringEntity id, e.g. "light.kitchen"
domainstring"light", "sensor", …
statestringCurrent state value ("on", "22.5", …)
attributesRecord<string, any>HA attributes, minus the four surfaced below. Read those from the canonical fields, not from here.
friendlyNamestringDisplay name
iconstring | nullResolved icon (registry → attribute → domain default)
deviceClassstring | nullResolved device class
unitOfMeasurementstring | nullResolved unit
areaId / deviceIdstring | nullPlacement / device
lastChanged / lastUpdatedDateTimestamps

deviceClass, unitOfMeasurement, friendlyName, and icon are lifted out of attributes into canonical fields, so always read them there. EntityView is exported as a type from the SDK for your own annotations.

Connection & locale

HookReturns
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 the common shortcuts. The shortcuts 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>
  );
}
HookReturns
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 to Home Assistant.

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). If that also fails, it passes {} to the component. No error is shown to the user; your component should 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. If your component throws during render, the dashboard catches it with an ErrorBoundary. After one crash it shows a retryable error card with a Retry button. After three crashes (configurable in the Dash host) it shows 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 files, theme variables, container queries, and animation) is covered in Styling & Animation.

Entity utilities

FunctionDescription
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

ExportPurpose
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.

These are re-exported from the canonical @glasshome/widget-contract package (the single source of truth shared by the SDK, the CLI, the Hub, and Dash), so the same schema validates a manifest everywhere.

ExportPurpose
widgetManifestSchema (alias WidgetManifestSchema)Validate a complete widget manifest
publishManifestSchemaValidate the manifest subset sent at publish time
capabilitiesSchemaValidate a capabilities array
capabilityGrantSchemaValidate a single { domain, access } grant
GridSizeSchemaValidate { w, h }
parseGridSize / serializeGridSizeParse from / serialize to the stored JSON form
formatSchemaErrorTurn a Zod error into a readable message
PublishRequestSchema / PublishConfirmSchema / PublishBodySchemaPublish-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.