Custom elements that every ioBroker UI can show alike: vis-2, devices, aura, admin. A component describes its own settings, the host renders that description as its own settings UI and hands the component two things - a connection to ioBroker and the values of the settings.
This package is the contract between the two sides, plus what both would otherwise write themselves:
| Entry | For | What |
|---|---|---|
@iobroker/web-components |
hosts | types, checking a manifest, settings values, finding, loading |
@iobroker/web-components/element |
component authors | base class IobWebComponent, defineWebComponent, dialogs |
@iobroker/web-components/esm/react-19.js |
web, admin | React 19 as one ES module, served as /lib/esm/react-19.js |
@iobroker/web-components/esm/react-18.js |
web, admin | React 18 as one ES module, served as /lib/esm/react-18.js |
@iobroker/web-components/schemas/manifest.json |
authors, checkers | JSON schema of a manifest file |
The main entry touches no DOM while it is imported, so a host can use it in Node as well (tests, a backend
that reads or checks manifests). element defines a class that extends HTMLElement and needs a browser.
A component is two files side by side in the admin folder of its adapter:
| File | What |
|---|---|
my-tile.json |
the manifest: tag, name, icon, size, settings - and the module |
my-tile.js |
the ES module that registers the custom element |
The manifest is plain JSON, so everyone can read it without running code of the component: a host for its palette and its settings, a backend, the repochecker, an editor with the schema. A host imports the module only when it shows the element.
{
"$schema": "https://raw.githubusercontent.com/ioBroker/web-components/main/schemas/manifest.json",
"manifestVersion": 1,
"tag": "iob-state-tile",
"name": { "en": "State tile", "de": "Zustandskachel" },
"description": { "en": "...", "de": "..." },
"icon": "iob-state-tile.svg",
"module": "iob-state-tile.js",
"size": { "width": 180, "height": 110 },
"settings": {
"type": "panel",
"items": {
"oid": { "type": "objectId", "label": { "en": "State", "de": "Datenpunkt" } },
"decimals": { "type": "number", "label": "Decimals", "min": 0, "max": 6, "default": 1 },
"toggle": { "type": "checkbox", "label": "Toggle on click", "default": true },
"textTrue": { "type": "text", "label": "Text for true", "hidden": "!data.toggle" }
}
}
}| Key | |
|---|---|
tag |
name of the custom element: lower case, with a - |
description |
optional |
icon |
optional: relative to the manifest file, or a data: URL |
module |
the ES module that registers the element, relative to the manifest file |
size |
optional: the size the element looks best in, in px |
settings |
JSON-Config: a panel, or tabs of panels |
The schema is @iobroker/web-components/schemas/manifest.json; checkManifest() checks the same in code.
The module registers its element - once only, defineWebComponent guards against a second import:
import { defineWebComponent } from '@iobroker/web-components/element';
defineWebComponent('iob-state-tile', StateTile);A component whose manifest is made in its source - e.g. with the translations of its adapter - lets its build write
the JSON file. It may still export the manifest from the module as manifest.
Up to 0.2.0 the module was the only file and exported its manifest. Such a module still works: a host imports it
at once to read the manifest.
A host gives a new instance the size, and the user may resize it. A component that works in exactly this size
only says so with noResize: the host then shows it so and lets nobody resize it.
size: { width: 400, height: 300, noResize: true }, // noResize is optional, false if missingsettings is JSON-Config,
so admin and devices (@iobroker/json-config) render it unchanged. A component may only use the types every
host can show:
text (minRows), password, number and slider (min, max, step), checkbox, color, select
(options: [{ value, label }]), objectId (types, root, e.g. types: 'config', root: 'flow.0.diagrams'),
instance (adapter), and the decorations without a value header, staticText (text) and divider.
Every text (name, label, help, text, the labels of options) is a string or { en, de, ... }. hidden
and disabled are JS expressions over data, the values of all settings.
The host sets two properties, not attributes, and optional ones if it has them:
| Property | What |
|---|---|
socket |
Connection of @iobroker/socket-client - every host already has one |
settings |
the values of the settings, keyed by the names in items; a new object on every change |
theme |
optional: light or dark, the theme of the host; light if the host does not set it |
editMode |
optional: true while the user arranges the page in the editor of the host |
dialogs |
optional: dialogs in the look of the host, see Dialogs |
The values are in one object, not in a property each, because many names a setting would have are taken by
HTMLElement already: title, lang, hidden, id, style, ...
The rules for a component:
- It works in any order:
settingsbeforesocket,socketbeforesettings, all of them changing at any time. Without a socket it waits for one; it never opens a connection of its own. - It subscribes while it is in the document and unsubscribes when it leaves. Of the socket it uses only
subscribeState,unsubscribeState,getState,setState,getObject,subscribeObject,unsubscribeObject,getHistory,getObjectViewSystem,sendToandsystemLang(WebComponentSocket). WithgetObjectViewSystem(type, start, end)it finds objects it cannot name, like the players below the instance of a setting - read once, in a range as narrow as possible. WithsendTo(instance, command, data)it asks its own adapter for what states and objects do not carry. - A value has the type its setting declares: a number for
numberandslider, a boolean forcheckbox, a string otherwise. A setting without a value is missing from the object. - It fills the box the host gives it (
:host { display: block; width: 100%; height: 100% }) and never measures itself - it scales with container queries (container-type: size,cqmin). - It has a transparent background and inherits
colorandfontfrom the host, so it follows the theme of the host. Shadow DOM keeps its own styles in and the styles of the host out;themeis for what CSS cannot carry, like the colors of a chart. - In
editModeit writes no states, sends no command that changes something, and reacts to no clicks - the host needs them to select and move it. Where settings are still missing, it may show sample data. - It brings everything it needs; a component built with Lit or Svelte bundles its framework, one built with React may take React from the host (see Frameworks in components).
The base class takes care of the first two rules:
// my-value.js - its manifest my-value.json names it as "module"
import { IobWebComponent, defineWebComponent } from '@iobroker/web-components/element';
class MyValue extends IobWebComponent {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
// the states to subscribe for these settings - re-asked after every change
getStateIds(settings) {
return [settings.oid];
}
// called after every change of the socket, the settings, a state or an object; once per task
render() {
const oid = this.settings.oid;
const name = this.text(this.objects[oid]?.common.name) || oid;
this.shadowRoot.textContent = `${name}: ${this.states[oid]?.val ?? '-'}`;
}
}
defineWebComponent('my-value', MyValue);this.states and this.objects hold what the subscribed IDs have, this.setState(id, val) writes through the
socket (not in edit mode), this.text(...) translates into this.systemLang. The objects of the states are read
once; getObjectIds(settings) names objects to keep up to date, like a diagram that is edited elsewhere.
getStateIds may read those objects - the states a diagram names - and is asked again whenever one of them arrives
or changes. this.theme and this.editMode are what the host set, and a change of them draws again as well.
To tell the host something - a click on a part of it, a value the user picked - a component dispatches a
CustomEvent that bubbles, is composed (so it leaves the shadow DOM) and is cancelable. A host that answers
it itself, e.g. with a dialog of its own, calls preventDefault(), and the component does nothing more:
// in the component - with the base class: this.emit(type, detail)
if (this.emit('flow-node-click', { node: 'pv' })) {
this.showPopup('pv'); // nobody answered it
}
// in the host
el.addEventListener('flow-node-click', e => {
e.preventDefault();
showDialog(e.detail);
});The name of an event contains a -, like a tag, so it never clashes with an event of the DOM. A host often clips
what leaves the box of a component, so a popup is usually better shown by the host.
Events every host understands:
| Event (constant) | detail |
The host |
|---|---|---|
iob-navigate (NAVIGATE_EVENT) |
{ view: 'Heating' } |
shows its view of that name if it has views, ignores the event else |
iob-dialog-close (DIALOG_CLOSE_EVENT) |
the result | closes the element dialog, see below |
A component brings no dialog of its own - no MUI only for that. A MUI dialog would not even work: its portal leaves the shadow DOM, where its styles are, and inside the shadow DOM the box of the component clips it. The host frames the dialog instead, and the component asks for one of two kinds:
// a form: the host renders the fields - the types of the settings, a JSON-Config panel - and gives the values
const values = await this.openDialog({
title: { en: 'Limits', de: 'Grenzen' },
form: { type: 'panel', items: { max: { type: 'number', label: 'Maximum', min: 0, max: 100 } } },
data: { max: 80 },
});
// { max: 80 } typed as the form declares it, or null if the user cancelled
// an element: the component renders the content, the host only frames it (title, close button, backdrop)
const content = document.createElement('div');
content.attachShadow({ mode: 'open' }).innerHTML = '<svg>...</svg><button>Done</button>';
content.shadowRoot.querySelector('button').onclick = () =>
content.dispatchEvent(new CustomEvent('iob-dialog-close', { detail: 'done', bubbles: true, composed: true }));
const result = await this.openDialog({ title: 'Battery', element: content, width: 600 });
// 'done', or null if the user closed itopenDialog() of the base class asks the host if it set dialogs, and takes the <dialog> of the browser
otherwise - opened with showModal(), it lies above everything of the page and is clipped by nothing. So a
component works in every host; in a host with dialogs it looks like the host. In edit mode it shows nothing and
gives null. Without the base class, openNativeDialog(request, { lang, theme }) of
@iobroker/web-components/element is the same fallback.
The host moves the element of an element dialog into its own dialog, so the element brings its own styles in a
shadow root, and closes the dialog with the event iob-dialog-close (DIALOG_CLOSE_EVENT).
A host implements dialogs.open(request): isFormDialog(request) tells the two kinds apart; for a form it renders
request.form like the settings and resolves with the values (toSettings(getPanelSettingTypes(request.form), values) types them), for an element it puts request.element into its dialog and resolves with the detail of
iob-dialog-close; null when the user cancels.
The order for a click is: the event first (the host may answer it completely), then openDialog().
A component may be written with anything that ends up as a custom element: plain DOM, Lit, Preact, Svelte, React. The host neither knows nor cares.
The contract carries no React object from the host. It would not help:
- A module that uses React calls it while it is evaluated (
createContext,forwardRef,memoof every library it imports) - long before a host could hand anything to an element. import { useState } from 'react'cannot be rebound to an object passed in later. A component would have to callthis.React.useStateeverywhere, and no library (MUI, ...) is written like that.- Every component would be tied to the React version of every host it runs in.
What to do instead:
- Lit or Preact for new components: a few KB per component, and no question of sharing.
- One build per adapter: the components of one adapter are one Vite build with an entry each, so they share their chunks - and one React - with each other.
- React from the host: web and admin serve React 19 as one ES module at
/lib/esm/react-19.js(REACT_19_URL), and React 18 at/lib/esm/react-18.js(REACT_18_URL):react,react-dom,react-dom/clientandreact/jsx-runtimetogether, so there can never be two copies of React in one. A component maps these imports (REACT_SPECIFIERS) onto that module instead of bundling them, and every React component of that major on the page uses the same instance.
The component imports React by a path relative to itself, made by getReactImportPath(modulePath) from where its
module lies in the admin folder. A host loads the module from <base>/adapter/<adapter>/ and has <base>/lib/esm/
beside it, so the browser finds React under the same <base> - also behind a reverse proxy that serves ioBroker
under a path (https://example.com/iob/). A property of the element could not give the path: the imports are
resolved while the module is evaluated, before a host can set anything.
// vite.config.mjs - the module is built to admin/webcomponents/my-tile.js
import { getReactImportPath, REACT_SPECIFIERS } from '@iobroker/web-components';
const react = getReactImportPath('webcomponents/my-tile.js'); // '../../../lib/esm/react-19.js'
export default {
build: {
lib: { entry: 'src/my-tile.jsx', formats: ['es'], fileName: () => 'my-tile.js' },
rollupOptions: {
external: REACT_SPECIFIERS,
output: { paths: Object.fromEntries(REACT_SPECIFIERS.map(name => [name, react])) },
},
},
};A component on React 18 takes getReactImportPath(modulePath, 18). The built module then starts with
import { createRoot, jsx, jsxs } from "../../../lib/esm/react-19.js"; the example below is 3 KB, with the base
class bundled. It renders into its shadow root:
import { createRoot } from 'react-dom/client';
import { IobWebComponent, defineWebComponent } from '@iobroker/web-components/element';
function Tile({ name, value, onToggle }) {
return (
<button onClick={onToggle}>
{name}: {String(value ?? '-')}
</button>
);
}
class MyTile extends IobWebComponent {
#root = createRoot(this.attachShadow({ mode: 'open' }));
getStateIds(settings) {
return [settings.oid];
}
render() {
const oid = this.settings.oid;
const value = this.states[oid]?.val;
const name = this.text(this.objects[oid]?.common.name) || oid;
this.#root.render(
<Tile
name={name}
value={value}
onToggle={() => this.setState(oid, !value)}
/>,
);
}
}
defineWebComponent('my-tile', MyTile);What to know about the shared React:
- web and admin copy
build/esm/react-19.jsandreact-18.jsof this package into theirlib/esm/when they are built, so the React minor is the one of the package version they ship. A component needs a major, nothing more specific; React 20 would come asreact-20.jsbeside them. - The size, minified:
react-19.js221 KB (69 KB gzip),react-18.js142 KB (45 KB gzip). A browser loads a file only when a component on the page imports it, and once for all of them. - Both majors work side by side on one page, each with its own instance: a React 18 component next to a React 19 one.
- The host's own React (vis-2 bundles one) is another copy. That does no harm: a component renders into a root of its own and shares no elements with the host.
- A host serves
lib/esm/besideadapter/, under the same base: web and admin do.REACT_19_URLandREACT_18_URLare where that is without a path prefix; a component does not import them, they miss behind a reverse proxy with a path.
An adapter lists its components in common.webComponents of its io-package.json: the manifest files, relative to
its admin folder. Admin and web both serve that folder as /adapter/<adapter>/, so every host loads them from the
same place; the file storage of ioBroker has it as <adapter>.admin.
"common": {
"webComponents": ["webcomponents/my-tile.json"]
}import {
getSettingTypes,
getWebComponentSources,
importWebComponent,
loadWebComponents,
toSettings,
} from '@iobroker/web-components';
const instances = Object.values(await socket.getObjectViewSystem('instance', 'system.adapter.', 'system.adapter.\u9999'));
// the manifests only - for the palette and the settings; no code of a component runs
const components = await loadWebComponents(getWebComponentSources(instances, document.baseURI));
// show one: its module first, so that the element has its class when it gets its properties
const component = components[0];
await importWebComponent(component);
const el = document.createElement(component.manifest.tag);
el.socket = socket;
el.settings = toSettings(getSettingTypes(component.manifest), storedValues);
container.appendChild(el);importWebComponent imports a module once, however often it is asked, and rejects if the module does not define
the tag of the manifest. A host creates the element only after that; the base class would take over properties
set earlier, a component without it might not.
getWebComponentSources finds /adapter/ from the address of the host: next to its folder (/vis-2/), at the
root, or - for a host that is an admin tab like /adapter/devices/tab.html - next to its own adapter folder. A path
prefix of a reverse proxy stays.
getDefaultSettings(manifest) gives the values for a new instance, getSettingsGroups(manifest) the panels
to render, resolveManifestUrl(manifest.icon, component.url) the icon, checkManifest(manifest) what is wrong
with one.
A host that is served neither by web nor by admin, and a backend, read the manifests from the file storage:
import { getWebComponentFiles, readWebComponentManifests } from '@iobroker/web-components';
// [{ adapter: 'flow', version, id: 'flow.admin', fileName: 'webcomponents/flow-diagram.json' }]
const files = getWebComponentFiles(instances);
const manifests = await readWebComponentManifests(files, async (id, fileName) =>
(await adapter.readFileAsync(id, fileName)).file.toString(),
);That is enough for a palette and the settings. To show an element, a browser still needs the module at a URL: the host serves the admin folder of the adapter itself, or its page is served by web.
- Size in a grid (sections of vis-2, cards of devices), and whether a component can ask its host for another size.
- Which other events a host can bind to actions of its own, and whether the manifest lists them.
- Versioning: two adapters bringing the same tag, an older module still cached by the browser.
- Colors of the host beyond
theme(primary color, paper), so that the content of a dialog matches the host.
- (@GermanBluefox) Added
getObjectViewSystemtoWebComponentSocket: a component finds the objects of one type in a range of IDs, e.g. the players of an instance.Connectionof@iobroker/socket-clienthas it already, so vis-2, devices and admin pass it on unchanged - (@GermanBluefox) Added
getReactImportPath(modulePath, major): a component imports the shared React relative to its own module (../../../lib/esm/react-19.js), so it finds it behind a reverse proxy with a path prefix too. Components built withREACT_19_URLkeep working without a prefix; build them again to work with one
- (@GermanBluefox) (breaking) A component is described by a manifest file (
.json) next to its module, named incommon.webComponents; the manifest gives the module asmodule. Hosts, backends and the repochecker read it without running code of the component. A module that exports its manifest still works - (@GermanBluefox)
loadWebComponentsreads the manifests only;importWebComponent()imports the module of a component once, when the host shows it.LoadedWebComponentgotmoduleUrl - (@GermanBluefox) Added
getWebComponentFiles()andreadWebComponentManifests(): the manifests from the file storage of ioBroker, for a host without web or admin and for a backend - (@GermanBluefox) Added the JSON schema of a manifest file:
@iobroker/web-components/schemas/manifest.json - (@GermanBluefox)
defineWebComponent()takes the tag alone, and the base class takes over properties a host set before the element was defined
- (@GermanBluefox) Added dialogs: the optional host property
dialogs,openDialog()in the base class with the<dialog>of the browser as fallback (openNativeDialog()), andisFormDialog(),getPanelSettingTypes()andDIALOG_CLOSE_EVENTfor hosts - (@GermanBluefox) The base class asks
getStateIdsagain when an object ofgetObjectIdsarrives or changes, so the states may come from an object (the nodes of a diagram) - (@GermanBluefox) Added the event
iob-navigate(NAVIGATE_EVENT) to show a view of the host - (@GermanBluefox) Added the filters
typesandrootofobjectIdsettings - (@GermanBluefox)
getWebComponentSourcesfinds/adapter/from a host that is an admin tab too
- (@GermanBluefox) Initial release: the contract, helpers for hosts and the base class
IobWebComponent - (@GermanBluefox) Added
build/esm/react-19.js- React 19 as one ES module for web and admin to serve as/lib/esm/react-19.js- withREACT_19_URLandREACT_SPECIFIERSfor the build of a React component - (@GermanBluefox) Added
build/esm/react-18.jsandREACT_18_URL- the same for React 18 - (@GermanBluefox) Added
subscribeObject,unsubscribeObject,getHistoryandsendTotoWebComponentSocket, andgetObjectIds()to the base class for objects that are kept up to date - (@GermanBluefox) Added the optional host properties
themeandeditMode,size.noResize, and events to the host (emit()in the base class)
MIT License
Copyright (c) 2026 Denis Haev dogafox@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.