The contract of a widget module. A widget is one folder, src/widgets/<type>/, that owns everything specific to that
kind: markup, actions, guards, confirmations, the services it calls, availability, timed refresh, card-level overlays,
editor options, validation, strings, CSS, fixtures, tests and its documentation page. The controller and core/
contain no widget names; adding a folder adds the widget everywhere (card element, picker entry, editor, docs index).
Read architecture first for where widgets sit. Overviews (full-screen views) have their own contract:
overview API. To scaffold a widget run npm run new:widget, see CONTRIBUTING.
src/widgets/<type>/
index.js required export default defineWidget({...})
strings.js required export default {en: {...}} every key starts with "<type>."
fixtures.js required export const variants = {...} entity states used by tests, previews and the contract suite
test.js required export default async function (h) {...} browser behaviour tests
README.md required front matter and option table; the source of docs/widgets/<type>.md
style.css required phase-tagged sections (section 8); may hold only a comment
<part>.js optional helpers (camera/snapshot.js, timer/alarm.js, clock/dial.js, ...)
src/widgets/index.js is generated by npm run gen; never edit it. src/widgets/_shared/ holds code several widgets
use (the icon-tile markup, the month-calendar tap).
Rules for index.js and its parts:
- Importable in plain Node: no
window,documentorHTMLElementat import time. Touch the DOM only in hooks. - Import only from
../../core/*,../_shared/*and your own folder. Never fromcontroller/,elements/,editor/or another widget (ESLintno-restricted-importsandnpm run check:widgetsenforce this). - Never call
hass.callService,window.confirm,localStorageorfetch. Usectx.calland the confirm seam. - The context is the whole API. There is no
ctx.card.
defineWidget(definition) fills defaults, checks the shape, deep-freezes the result and remembers that it did. The
registry refuses an object that did not come through it. In a browser a bad definition is skipped with a console error;
in Node (tests, CI, linters) it throws.
| Field | Default | Meaning |
|---|---|---|
type |
required | snake_case. Equals the folder name, the widget_type value and the card tag houseview-<type>-card |
label |
required | Picker and editor name (English; translated through the string key <type>.label) |
order |
required | Unique positive integer; picker and editor order. Take the next free multiple of 10 |
render(ctx) |
required | Pure. Returns one root element string that carries data-widget |
entity |
'required' |
'required': entity must start with <type>.. 'none': no entity (clock, date). 'custom': validate() owns every check (action) |
domains |
[type] ([] for none) |
Entity-picker domains and stub domains |
aliases |
[] |
Other type names folded into this widget (action: scene, script, button, input_button) |
defaultSize |
'normal' |
normal, wide, tall or large; used by layout, the size-* class and the editor |
availability |
'entity' |
How ctx.ok is computed: 'entity' (exists and not unavailable/unknown), 'always' (clock, date), 'allow-unknown' (blinds: unknown stays usable) |
unknownOkDomains |
[] |
Domains whose state unknown still counts as available (action: scene, button, input_button) |
layoutRoles |
[] |
Roles the controller uses to find a widget: clock, camera and timer pick the widgets whose afterRender hook refreshes the clock, camera snapshots and countdowns, and camera marks the widget whose extension owns the gallery. Page layout does not use them |
family |
null |
'icon-tile' joins the shared icon-widget proportions in CSS (light, switch, fan, lock, action, alarm panel, vacuum) |
standalone |
true |
Register houseview-<type>-card and a picker entry |
tag |
houseview-<type>-card |
Element tag override, ^houseview-[a-z0-9]+(-[a-z0-9]+)*-card$. The default stays legal with underscores (houseview-media_player-card) |
preview |
true |
The picker may preview the card; a render must never call services anyway |
description |
string <type>.description |
Picker description |
cardRows |
3 |
getCardSize() of the standalone card |
standaloneHeight |
240 |
Default height in px of the standalone card |
gridOptions |
{} |
Extra getGridOptions() keys (min_columns, min_rows, ...) |
stub(hass) |
none | Extra keys for the card's stub config; the entity is picked from domains |
options |
{} |
Declarative per-widget keys (below). They drive enum validation, editor fields and documentation |
validate(widget, config) |
none | Throw Error('<exact message>'). Sees the original widget.type (an alias) |
normalize(widget, config) |
none | Pure. Runs after validation and alias folding. Return the widget to keep |
watch(widget, config) |
none | Extra entity ids whose change re-renders the card (default: widget.entity) |
editor |
{name: true} |
name: false hides the label field; toForm(widget) maps stored values to form data; onKindChange(config) |
actions |
{} |
Map of data-action name to action definition (section 4). A trailing * makes a prefix action |
afterRender(ctx) |
none | Hook (section 5) |
tick |
none | {every, run, align?, when?} scheduled DOM work (section 5) |
onHass(ctx, prev, next) |
none | Hook (section 5) |
patch(ctx, tile, html) |
none | Custom in-place update. Return true when handled |
alwaysPatch |
false |
Skip the "markup unchanged" shortcut (the camera needs it) |
extension |
none | Card-level feature (section 6) |
overview |
none | Set only by defineOverview() for the derived card-mode widget of an overview |
Unknown fields are rejected.
options: {
icon: {type: 'icon'},
action: {type: 'enum', values: ['toggle', 'off'], default: 'toggle', error: 'Light action must be off or toggle.'}
}type is one of text, icon, enum, boolean, number, entity, enum_list. Allowed keys: default, values
(enum, enum_list), domains (entity: required, non-empty), min, max, step, error (exact message for an invalid
enum value), when(widget) (show the field only when true). Labels and helper text come from strings.js:
<type>.option.<name>.label and <type>.option.<name>.helper; icon and text fall back to editor.<name>.
Validation order, which decides which message a broken config gets: type known (Unsupported widget: <type>), option
enums, def.validate, the entity rule (<type> widget needs a <type> entity. when entity is 'required'), the size
rule (Widget size must be normal, wide, tall or large.), the action keys (tap_action and friends), then alias
folding and def.normalize.
render(ctx) and every hook receive a context from src/core/context.js. It is rebuilt per call and is cheap.
Base fields (also in action and extension contexts):
| Member | Meaning |
|---|---|
hass, config |
The Home Assistant object and the card config (read only) |
stateOf(id), attrsOf(id), availableOf(id) |
hass.states[id], its attributes, availability |
esc(v) |
HTML-escape. Everything not produced by a context helper must go through it |
num(v) |
True for a finite number |
t(key, vars) |
Translated string (hass.locale.language, base language, en, then the key) |
unit(), lang() |
Temperature unit; hass.locale.language |
icon(name) |
<ha-icon icon="mdi:name" aria-hidden="true">; a name containing : is used as it is |
isPending(id) |
A service call for this entity is running |
time(v) |
Epoch milliseconds of a timestamp string or number (NaN when unparsable) |
now() |
Date.now(), or config.preview_time when set. Use it instead of reading the clock |
call(domain, service, data) |
The only route to hass.callService |
info(id) |
Open the more-info dialog |
Render and hook context adds:
| Member | Meaning |
|---|---|
widget, index, type |
The normalised widget config, its position in the card, the canonical type |
entityId, state, attrs |
widget.entity, its state object, its attributes ({} when missing) |
ok |
Availability under the widget's policy (always true for entity: 'none') |
name, rawName |
widget.name, else the friendly name, else the type; escaped and raw |
size, cls, attr |
Size, root classes (widget <type> size-<size>), data-widget="<index>" |
tile(action, content, extra) |
The widget's root as a <button data-action> |
root(tag, extra, inner) |
A non-button root (panels with several buttons) |
btn(action, content, cls, enabled, {entity, attrs}) |
A button; disabled when not enabled or while the entity is pending |
feature(bit) |
ok and the supported_features bit is set |
iconName(fallback) |
widget.icon or the fallback; pass it to icon() |
stateText(stateObj) |
The raw state with _ replaced by a space (localised when localized_states is on) |
Hook context (afterRender, tick.run, onHass, patch) also has el (the mounted root, null when the widget is
not on the page that is showing), requestRender(), store (a Map that lives as long as the widget instance),
and ext (the state of the widget's own extension).
A render must return exactly one root element carrying data-widget; escape everything with ctx.esc; render a sensible
disabled state when !ctx.ok; and be pure: no clock (ctx.now(); the date and calendar widgets read the clock on
purpose and are the documented exception), no window, document or random values.
actions: {
'cover-open': {
availability: 'allow-unknown', // optional: 'entity' (default) | 'allow-unknown'
guard: (ctx, id) => true, // optional: true runs, false ignores, 'info' opens more-info instead
confirm: (ctx, id) => 'Open all?', // optional: a message to confirm first, or null
run: (ctx, id) => ctx.call('cover', 'open_cover', {entity_id: id}),
immediate: false // optional: bypass the pending and availability gates
}
}The action context is the base context plus action (the full name), param (the part after a prefix, '15' for
timer-start-15 under timer-start-*), widget (the first configured widget of that owner with this entity; may be
undefined) and fail(message).
- Action names form one global namespace; a duplicate or overlapping prefix throws at startup. Names are
<type>-<verb>, with a short list of un-prefixed names kept for the built-in widgets (seeGRANDFATHERED_ACTIONSintests/fixtures/contract-rules.mjs). Core ownsinfoandtoggle. - Pipeline (
src/core/dispatch.js): immediate actions run at once; then the pending and availability gates; thenguard; thenconfirm(the nativewindow.confirm, or the inline sheet withconfirm_mode: inline); then the entity is marked pending and the feedback line cleared; thenrun; a thrown error becomesCould not complete action: <message>; finally pending is cleared.pendingis per entity, so pressing one device never disables another. - A
guarddoes not apply to animmediateaction. - Service payloads are
ctx.call(domain, service, {entity_id: id, ...}). - Card-level
immediateactions that belong to no widget (dim,page-prev,page-next,page-select,calendar-view,overview-open,goto-widget) are core features registered withregisterFeature, not widget code.
A press on a widget's primary button can be replaced by the user's tap_action, hold_action and double_tap_action
(see actions); inner buttons of panel widgets always run their own action.
| Hook | When | Contract |
|---|---|---|
afterRender(ctx) |
After the widget is created, patched or re-laid out | Touch ctx.el only; idempotent and cheap (clock hands, camera image, countdown text) |
tick: {every, run, align?, when?} |
The shared one-second ticker, throttled to every ms; align: 'minute' runs it when the minute changes |
Only for mounted widgets; skipped while the document is hidden or an overview covers the card |
onHass(ctx, prev, next) |
Every hass update, before the render decision, for every configured widget (also on other pages) | prev/next are the entity's old and new state objects; keep memory in ctx.store |
patch(ctx, tile, html) |
Instead of the generic DOM morph | Return true when handled. With alwaysPatch it also runs when the markup is unchanged |
watch(widget, config) |
The change detector | Extra entity ids that must trigger a render |
Hooks must be safe when the element is missing and must not throw; the controller isolates each hook in a try/catch and logs once.
For behaviour bigger than one widget: the camera grid overlay, the timer alarm banner and its audio, and the
full-screen music player. Hosted by src/controller/extensions.js; the controller never names the widget.
| Field | Meaning |
|---|---|
configKeys |
Top-level config keys the widget owns (camera_gallery) |
validateConfig(config) |
Throw an Error; runs after cards are converted, before widget validation |
entities(config) |
Extra watched entity ids |
state() |
Initial state, kept at card._ext[type] |
compat |
Property accessors on the card, {name: {get, set?}} |
shellClasses(card) |
Classes for the shell (camera-view-open) |
overlay(ctx) |
HTML inserted when the overlay opens or the shell is rebuilt. A state update leaves a mounted overlay in place, and removes it when this returns nothing |
retain(ctx) |
Optional. On a state update, true keeps the mounted overlay and skips overlay() (afterRender syncs it); false removes it |
afterRender(ctx), tick |
As the widget hooks, card-level |
blocksNavigation(ext) |
True stops page changes and swipes (the grid is open) |
onKeydown(ctx, event), onPointerDown(ctx, event) |
Key and pointer handling; onKeydown returns true when handled |
dispose(ctx) |
Called on every re-render and on disconnect (ctx.reason is 'render' or 'disconnect') |
actions |
Immediate actions the extension owns (camera-grid, dismiss-alarm) |
The extension context is the base context plus type, ext, root (the card's shadow root), q(selector), render()
and focus(selector).
strings.js exports {en: {...}, <lang>: {...}}. Every key starts with <type>.. Interpolation is {name}.
export default {
en: {
'cover.label': 'Blinds',
'cover.description': 'Open, stop and close blinds or shades.',
'cover.percent_open': '{n}% open'
}
};npm run gen writes src/widgets/index.js so that each folder's strings.js reaches the translator through
registerWidget(def, strings). A test that imports a widget's index.js directly must also register its strings or
ctx.t() returns the key. Core keys any widget may use: state.* (state.unavailable, state.offline), common.on,
common.off, editor.widget_type, editor.entity, editor.name, editor.size, editor.icon, error.action,
error.try_again. English is the only language shipped for the widgets; add others as siblings of en.
- Every section of
style.cssstarts with/* @phase <name> */. Phases are listed insrc/styles/phases.json, and sections must appear in phase order. A file without any marker lands incarousel-widgets, the right default for a new widget./* @owns a b */lists the classes the file owns. - Rules are rooted at a class you own; specificity at most
(0,3,0); no!importantwithout a/* @important reason */comment; no.surface-*,.night-modeor.layout-*as the subject; no@import,@layerorurl(). - Use the card's custom properties (
--hv-glass,--hv-edge,--hv-text) and--mdc-icon-size, so surfaces, dark mode and high contrast keep working. - A widget that looks like the icon widgets sets
family: 'icon-tile'; the shared selector lists are generated. - Weak devices: avoid
filter, largebackdrop-filterareas and layered animation. See performance.
See architecture for how the sections become one stylesheet.
// fixtures.js: no imports (the browser harness loads it unbundled)
export const variants = {
ok: {widget: {entity: 'cover.test'}, states: {'cover.test': {state: 'open', attributes: {current_position: 40, supported_features: 15}}}},
unavailable: {widget: {entity: 'cover.test'}, states: {'cover.test': {state: 'unavailable', attributes: {}}}},
missing: {widget: {entity: 'cover.test'}, states: {}}
};A widget with an entity provides at least ok, unavailable and missing (and unknown when it matters), plus one
variant per feature bit or state branch its render or actions read.
// test.js: runs in a real browser against the built bundle with a mock hass (no network, no Home Assistant)
export default async function ({mount, check, variants}) {
const t = await mount(variants.ok);
await t.click('cover-open');
check('open calls cover.open_cover', t.calls.length === 1 && t.calls[0].s === 'open_cover');
}mount(variant, {as, size, config, hass}) returns a handle with q, qa, text, click(action, entity?), act,
calls (every callService, as {d, s, data}), infos (entities whose more-info was opened), setState, remove.
click resets calls first, so read calls after each press.
The contract suite (tests/contract/) holds every registered widget to this document: required files, a frozen and
well-formed definition, exactly one root with data-widget for every variant, size and pending state, purity,
every rendered data-action has an owner, hostile names create no elements, strings keys exist and are prefixed,
guards that return false cause no service call, and payloads have the form {entity_id: id, ...}.
npm run check:widgets fails on: missing required files; a type that differs from the folder; duplicate order, tag,
alias or action name; an action name that is neither grandfathered nor <type>-<verb>; render referencing
callService, window, document, localStorage, fetch, confirm, timers, Date.now, Math.random or ctx.card; imports across widget
boundaries or from controller/, elements/, editor/; ctx.t keys missing from strings.js; style.css rules
outside @owns, specificity above (0,3,0), !important without a reason, url(; and src/widgets/index.js out of
date.
import {defineWidget} from '../../core/define-widget.js';
import {controlTile} from '../_shared/control-tile.js';
export default defineWidget({
type: 'light',
label: 'Light',
order: 60,
family: 'icon-tile',
options: {
icon: {type: 'icon'},
action: {type: 'enum', values: ['toggle', 'off'], default: 'toggle', error: 'Light action must be off or toggle.'}
},
// YAML reads an unquoted `action: off` as false; keep it meaning "turn off".
normalize: (widget) => (widget.action === false ? {...widget, action: 'off'} : widget),
render: (ctx) =>
controlTile(ctx, {action: ctx.widget.action === 'off' ? 'light-off' : 'light', icon: 'lightbulb-outline'}),
actions: {
light: {run: (ctx, id) => ctx.call('light', 'toggle', {entity_id: id})},
'light-off': {run: (ctx, id) => ctx.call('light', 'turn_off', {entity_id: id})}
}
});controlTile (in src/widgets/_shared/control-tile.js) renders the icon, the name and the state text as one
<button>, marks it active while the entity is on, and disables it when the entity is unavailable.
npm run new:widget -- --type humidifier --label "Humidifier" --domains humidifiercreates the folder from the templates, picks the next freeorderand refreshes the generated files.- Fill in
index.js:render,actions,options,validateornormalizeif needed. - Write
strings.js(all keys prefixed with<type>., includinglabelanddescription). - Write
fixtures.jswith the required variants. - Write
test.js: every control, every guard, every service payload. - Fill
style.css(phase markers and@owns) and, for an icon widget, setfamily: 'icon-tile'. - Write
README.md: front matter (type,label,entity,since), a one-paragraph description, an option table that matchesoptions, and a YAML example. Runnpm run gen-docs. - Run
npm run gen,npm run check:widgets,npm run test:unit,npm run test:widgetsandnpm run lint.
A widget can provide editor.loadOptions(hass, config, signal) to read choices such as a speaker’s favourites. The editor runs it once per entity and connection, cancels it when those change or the editor disconnects, and ignores stale responses. It must not call device services.
editor.schema(hass, config, schema, result) can add native HA form selectors using those choices; editor.helper(config, result, item) provides status/help text for a field. Saved config remains authoritative while choices load or fail. The Music card uses these hooks for an ordered native multi-select without putting media-specific logic in the shared editor.