Build a widget
Widget authoring · updated September 2026
A widget is a folder with a manifest.json and an entry HTML file. Vardek serves the folder in a sandboxed iframe on the dashboard grid — no build step, no framework, no packaging.
Vardek's format traces back to iCUE's original manifest.json + index.html widgets, but Corsair has since moved the Xeneon Edge widget layer onto a CLI-packaged .icuewidget pipeline (icuewidget init/validate/package), a different manifest schema (author, description, preview_icon, min_framework_version, os, supported_devices), HTML config via <meta name="x-icue-property"> tags, and its own onICUEInitialized/onDataUpdated lifecycle — see Elgato's current widget docs. A widget written for one no longer drops straight into the other; porting either direction means adapting the manifest and swapping the bridge calls (Vardek.* here, the iCUE global object there) for the target's own API.
Folder layout
com.yourname.mywidget/
manifest.json # required
index.html # entry (name it whatever manifest.entry says)
... # any JS/CSS/assets, loaded with relative paths
README.md # optional
Use a reverse-DNS id you control (com.yourname.mywidget). It must match the folder name.
manifest.json
{
"id": "com.yourname.mywidget",
"name": "My Widget",
"version": "1.0.0",
"entry": "index.html",
"subscriptions": [],
"sizes": [ { "cols": 2, "rows": 1 }, { "cols": 4, "rows": 2 } ],
"canvas": { "width": 640, "height": 320 },
"settingsSchema": {
"label": { "type": "string", "label": "Label", "default": "Hello" },
"big": { "type": "boolean", "label": "Large text", "default": false },
"mode": { "type": "enum", "label": "Mode", "default": "a", "values": ["a","b"] }
}
}
| Field | Required | Notes |
|---|---|---|
id | Yes | Reverse-DNS, equals folder name. |
name | Yes | Shown in Admin. |
icon | Optional | An emoji shown as the widget's tile icon in Admin (e.g. "🛰️"). Omit for a neutral default. |
version | Yes | Semver string. |
entry | Yes | HTML file to load. |
sizes | Yes | Allowed footprints on the 8×2 grid. cols 1–8, rows 1–2. |
canvas | Optional | Logical px the entry renders at; scaled to the slot. |
subscriptions | Optional | Data channels (sensors, config, …). [] for none. |
settingsSchema | Optional | User-editable settings; Admin builds a form from it. Types: boolean, string, number, enum (with values), color (native color picker, value is a #rrggbb hex string). |
permissions | Optional | { "proxy": ["https://api.example.com/**"], "secrets": ["MY_KEY"] } — see Network below. |
refreshInterval | Optional | Seconds; fires an onRefresh tick. |
The grid is 8 columns × 2 rows. Full panel = { "cols": 8, "rows": 2 } at canvas 2560×720 (the Xeneon Edge). Invalid manifests are skipped with a reason shown in Admin — never fatal.
Sizes in pixels
On the 2560×720 Edge with the default 8 px gutter, a slot is: 1×1 311×348, 2×1 630×348, 3×1 949×348, 4×1 1268×348, 6×1 1906×348, 8×1 2544×348, and double those heights (704) for two rows, so 8×2 is 2544×704. Vardek.size reports the real values at run time. Porting from iCUE: Corsair's Small slot (840×344) is closest to 3×1, and its Extra Large (2536×696) to 8×2. Write widgets responsively, with a canvas that scales to the slot or a re-layout in Vardek.onResize.
Quality bar
- Glanceable: one main reading, large type, legible at your smallest declared size.
- Design the loading, error and offline states. A blank widget is a bug, and an unset API key should say “enter your key”.
- Touch targets of at least about 44 px, and nothing that needs pinch, hover or two-finger gestures, because the touch driver is single-finger.
- Stop timers and polling you don't need; widgets on other pages are throttled.
- Test on the panel or in a 2560×720 window at every size you declare, and declare only the hosts and secrets you use.
Runtime bridge
Vardek injects a bridge script at serve time. In your entry file:
<script>
const defaults = { label: "Hello", big: false };
let cfg = defaults;
document.addEventListener("vardek:ready", () => {
cfg = Object.assign({}, defaults, Vardek.settings); // merge user settings
render();
});
render(); // draw immediately too; re-draw on ready with real settings
</script>
Bridge surface (Vardek global): Vardek.settings, Vardek.size, Vardek.subscribe(channel, cb), Vardek.onResize(cb), and Vardek.escapeHtml(str) (escape text before inserting it as HTML). The vardek:ready event fires once the bridge is live. Vardek.sendCommand is a no-op since 1.0.22.
Hard constraint — no ES modules
Widgets run in a sandbox="allow-scripts" iframe → opaque origin. An external <script type="module" src="..."> is fetched in CORS mode, and widget assets are served from an opaque native vardek-widget scheme with no CORS headers, so it is blocked and your widget renders blank. Use classic scripts:
<script src="helpers.js"></script> <!-- sets globals -->
<script> /* uses those globals */ </script>
Classic scripts run in document order, so a later <script> sees globals set by earlier ones. Expose shared helpers on globalThis. (Inline <script> is fine; just avoid type="module".)
Network
No ambient network. To fetch, declare hosts in permissions.proxy (glob allowlist) and call through the injected proxy; requests to other hosts are blocked. API keys go in permissions.secrets — the user enters them in Admin, they're stored in the macOS Keychain, and injected server-side (never exposed to widget JS). A CSP restricts the widget document. Keep allowlists tight.
Gotcha: the API host and its CDN host can be different domains. The widget document's CSP (img-src/connect-src/…) is derived straight from permissions.proxy, so every host your widget's markup loads from must be listed — not just the one you call for JSON. A widget calling *.wikipedia.org for data but loading images from upload.wikimedia.org is a real example of this — two different subdomains, easy to miss one. Forgetting the second host doesn't error on the fetch; the <img> just gets silently blocked by CSP, which looks exactly like a network/proxy failure. Check every host your rendered HTML actually loads from (fonts, images, iframes), not just the ones your JS calls directly.
Validate
Run the validator before you install or submit a widget. It needs only Node 18+ and lives in the add-on repo:
node validate.mjs com.yourname.mywidget
node validate.mjs --strict app.vardek.* # warnings fail too
Errors are things Vardek refuses to load or the docs require: a bad or reserved id, bad sizes, a missing or escaping entry file, a malformed proxy host or secret key, a bad settings field, links, and oversized files. Warnings are things that usually break a widget at run time: a host your markup loads from that is not in permissions.proxy, browser storage, audio, eval, and a data widget with no visible error state. It enforces the same id, secret-key and proxy rules as the app.
Test locally
./install-addon.sh com.yourname.mywidget # copy to user dir, then Rescan in Admin → Widgets
Edit files, re-run to reinstall, then reload the widget in Admin (toggle it off/on or refresh the dashboard) to repaint. After editing a widget already installed, the daemon needs a rescan to pick up manifest changes, and Admin will ask you to approve the widget again because its content changed.
Other hard constraints (1.0.18+)
- No inline event handler attributes (
onclick,onerror, …) — useaddEventListener. Scripts are hash-pinned. - No navigation inside the widget document, including to secondary local pages. One
index.html. - The
com.vardek.*andinstallation.*ID prefixes are reserved; Vardek will not load a user widget that uses them. Official add-ons useapp.vardek.*(since 1.0.19); pick your own prefix, such ascom.yourname.*. - User widgets must be approved once in Admin before the proxy or a stored key works; changed files or permissions require approval again.
Contributing
Open a PR against vardekapp/vardek-widgets adding your com.yourname.widget/ folder and a row in the README table. Keep widgets self-contained and dependency-free where possible.