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
defineWidgetand the manifest fieldswidgetFieldsform helpers and a complete widget example- The
<Widget>container, its variants, and slot subcomponents - The
<WidgetDialog>settings dialog - Hooks, the Home Assistant data & service layer, and the
EntityViewshape - Errors and empty states, theming, entity utilities, and build tooling
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).
| 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
Pre-built Zod field helpers. Each attaches metadata that the dashboard uses to render the right form control.
| Helper | Renders 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 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.
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
| 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; 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:
| 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. 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:
| 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 |
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
| 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 one or more EntityView objects. The fields you reach for most:
| 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 |
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
| 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 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>
);
}
| 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 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
emptyStateConfigtouseWidgetEntityGroup. WhenhasEntities()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
toneprop on<Widget>for semantic color (tone="accent"), orcolor/colorTo/gradientfor 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, andToneSchema.
The full styling model (Tailwind, your own CSS files, theme variables, container queries, and animation) is covered in 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.
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.
| 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.