Widget Creation Guide
A Widgey widget is a folder containing an HTML file and a manifest. No SDK, no build step, no compiler. If you can write a web page you already have what you need. This guide covers the rest.
Quick start
The fastest route is inside the app: Settings → Layout → + Create scaffolds a working widget from a template, in the right folder, ready to edit. To start from scratch:
- Create a folder named for your widget in
%APPDATA%\WidgetDashboard\UserWidgets\, named reverse-DNS style likeyourname.server-ping. - Add
manifest.json. You do not need anidfield, because the folder name is the ID. - Add
widget.htmlwith your markup and script. - Screenshot it and save that as
preview.png. - Open Widgey settings and your widget is in the catalog.
widget.html updates the running widget without restarting the app. If your widget
doesn't appear at all, the Layout tab tells you exactly which rule it failed.
File structure
manifest.json Required. Metadata and configuration.
widget.html Required. Your UI and logic.
preview.png Required to publish. A screenshot of it running.
(anything else) Optional. CSS, JS, images, fonts, audio, video.
Your folder name is your widget ID
Letters, numbers, dots, hyphens and underscores; 100 characters maximum. It can't start or end
with . or -, and can't contain ... If you do include an
id field in the manifest it must match the folder name exactly, or the widget won't
load.
Prefixing with your own name avoids a practical collision: if a user downloads two widgets as
plain folders and both are called clock, the second overwrites the first.
id stay completely separate: different catalog entries, different settings,
different browser storage.
Manifest reference
Every field manifest.json accepts:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | No | folder name | Omit it, the folder name is used |
name | string | Yes | n/a | Display name, max 100 chars |
description | string | Yes | n/a | Short description, max 500 chars |
version | string | Yes | n/a | Semantic version: major.minor.patch |
author | string | Yes | n/a | Author name, max 50 chars |
glyph | string | Yes | n/a | A Segoe MDL2 Assets icon character |
category | string | Yes | n/a | Audio, Finance, Fun, Games, News, Sports, System, Time, Utilities or Weather |
accentColor | string | No | #3DA5FF | Hex colour for the card border and glyph |
defaultColSpan | int | No | 2 | Default width in grid columns (1–8) |
defaultRowSpan | int | No | 2 | Default height in grid rows (1–8) |
minColSpan | int | No | 1 | Minimum width when resized |
maxColSpan | int | No | 4 | Maximum width when resized |
minRowSpan | int | No | 1 | Minimum height when resized |
maxRowSpan | int | No | 4 | Maximum height when resized |
permissions | string[] | No | [] | See Permissions |
networkHosts | string[] | With network | [] | Hosts this widget may reach, max 20 |
hasSettings | bool | No | false | Show a gear icon in the widget header |
transparent | bool | No | false | Transparent container background |
{
"name": "Server Ping",
"description": "Round-trip latency to my game servers.",
"version": "1.0.0",
"author": "Your Name",
"glyph": "",
"category": "Utilities",
"accentColor": "#39E28B",
"defaultColSpan": 2,
"defaultRowSpan": 2,
"hasSettings": true,
"permissions": ["network"],
"networkHosts": ["api.example.com"]
}
resizable field. A widget is resizable when
maxColSpan is greater than minColSpan, or maxRowSpan is
greater than minRowSpan, on either axis. To make one a fixed size,
set min, max and default to the same value on both axes:
{
// A 2x2 widget the user cannot resize
"minColSpan": 2, "maxColSpan": 2, "defaultColSpan": 2,
"minRowSpan": 2, "maxRowSpan": 2, "defaultRowSpan": 2
}
Worth doing when a layout only works at one size. A fixed-size widget shows no resize grip, so nobody can drag it into a shape it can't render. The playground has a Resizable toggle that writes these six fields for you.
Writing the widget
Rules that matter
- Set
bodytoheight: 100vhandoverflow: hidden, because the widget is sized by the grid, not by its content. - Use
background: transparentonbodyand paint your own container, so the user's theme and transparency settings still work. - Add
user-select: noneunless your widget is genuinely about selecting text. - Everything is local: no CDN scripts, no remote fonts, unless the host is on your
networkHostslist.
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: transparent;
font-family: 'Segoe UI', sans-serif;
color: #F2F2F5;
height: 100vh;
overflow: hidden;
user-select: none;
}
.container {
background: #0E0A18;
height: 100%;
border-radius: 0 0 12px 12px;
padding: 16px;
}
</style>
</head>
<body>
<div class="container">
<!-- your widget -->
</div>
<script>
// your logic
</script>
</body>
</html>
Bridge API
Widgey injects a widgey global into your page before any of your scripts
run. There is no plumbing to write and nothing to import.
// Settings. All async, all return promises.
const name = await widgey.settings.get('userName'); // string or null
widgey.settings.set('userName', 'Alice'); // fire and forget
const all = await widgey.settings.getAll(); // { key: value }
widgey.settings.delete('userName');
// Hardware sensors. Requires the "system" permission.
const cpu = await widgey.system.get('cpu_temp'); // "52"
const every = await widgey.system.getAll();
// Audio spectrum. Requires the "audio" permission.
const bands = await widgey.audio.getBands(); // int[64], 0–100
// Theme changes
widgey.onTheme(theme => {
document.documentElement.style.setProperty('--accent', theme.accentColor);
});
null rather than hanging, so you never need a timeout of your own.
Testing in a normal browser
The widgey global only exists inside the host, so guard for it if you also open the
page in Chrome while developing:
const hasBridge = typeof widgey !== 'undefined';
const temp = hasBridge
? await widgey.system.get('cpu_temp')
: '42'; // stub value for browser testing
Hardware sensors
Requires the system permission. All values come back as integer strings, or
null if the sensor service isn't running.
| Key | Unit | Description |
|---|---|---|
cpu_temp | °C | CPU package temperature |
gpu_temp | °C | GPU core temperature |
gpu_hotspot_temp | °C | GPU hotspot temperature |
cpu_load | % | CPU total utilization |
gpu_load | % | GPU core utilization |
gpu_mem_load | % | GPU memory utilization |
ram_used / ram_total | GB | RAM in use, total RAM |
ram_load | % | RAM utilization |
vram_used_mb / vram_total_mb | MB | GPU VRAM in use, total |
cpu_clock / gpu_clock | MHz | Clock speeds |
cpu_power / gpu_power | W | Power draw |
gpu_fan | RPM | GPU fan speed (0 = fans stopped) |
mobo_temp | °C | Motherboard temperature |
storage_temp | °C | Storage device temperature |
net_up_kbps / net_down_kbps | Kbps | Network throughput |
// One bridge call instead of twenty
const s = await widgey.system.getAll();
// { cpu_temp: "52", gpu_temp: "41", cpu_load: "23", ... }
Audio spectrum
Requires the audio permission. getBands() returns 64 magnitudes from
0–100, mapped logarithmically from low to high frequency, and all zeros when nothing is playing.
setInterval(async () => {
const bands = await widgey.audio.getBands();
drawVisualizer(bands);
}, 33); // ~30fps is smooth; faster buys you nothing
Permissions
Permissions are declared in the manifest and shown to users before they install. Ask for less and more people will install your widget.
| Permission | Grants | Typical use |
|---|---|---|
system | Read hardware sensor data | Monitors, temperature gauges, performance dashboards |
audio | Read the 64-band audio spectrum | Visualizers, music-reactive widgets |
network | Outbound requests to declared hosts only | Weather, stocks, any API-driven widget |
The network permission
network on its own does nothing. Pair it with the hosts you actually use:
{
"permissions": ["network"],
"networkHosts": ["api.open-meteo.com", "*.githubusercontent.com"]
}
- Exact host:
api.example.commatches only that host. - Subdomain wildcard:
*.example.commatchesa.example.comanda.b.example.com, but notexample.comitself, so list the apex separately if you need it. A leading*.is the only wildcard allowed, and*.comis rejected as too broad. - HTTPS is required for everything except loopback
(
localhost,127.0.0.1,::1), which may use plain HTTP for local companion apps. - No schemes, paths or ports.
https://api.example.com/v1andapi.example.com:8443are both rejected. - Maximum 20 entries.
fetch, XMLHttpRequest, EventSource and ordinary resource
loads. WebSockets are checked against the same list, and RTCPeerConnection throws for
every widget.
network at all.
file:/// inside your own folder, plus data: and blob: URIs,
always work and need no permission. A widget that declares no permissions earns the
Sandboxed badge and appears under the "Sandboxed only" filter.
Theme integration
Widgets are told when the user changes theme. Honouring it costs a few lines and is the difference between a widget that belongs on the dashboard and one that's visibly bolted on.
// Called once on load and again on every theme change
widgey.onTheme(theme => {
const root = document.documentElement.style;
root.setProperty('--accent', theme.accentColor);
root.setProperty('--radius', theme.cornerRadius + 'px');
});
Publishing to the Workshop
Publishing happens inside Widgey. There is no zip to upload and no website account to create.
- Put your widget folder in
%APPDATA%\WidgetDashboard\UserWidgets\. - Add a
preview.png, at least 200×200. - Open Settings → Layout, select your widget, click Publish to Workshop.
Widgey validates the widget, shows exactly what will be published, including the permissions
subscribers will see, then uploads it. The Workshop file ID is written back into your
manifest.json, so the button becomes Update on Workshop from then on.
Preview images
- Minimum 200×200. Anything larger is fine.
- Widgey pads it to a square and scales it to 512×512 for the Workshop thumbnail. It
never crops, so the store shows the same picture the dashboard does. Your own
preview.pngis left untouched. - Show the widget actually running, not a logo. A dark background matches the dashboard.
Updating
Bump version in the manifest and click Update on Workshop. Steam
pushes it to every subscriber automatically. There is no update badge to chase.
network, subscribers see that on the item's page. Adding permissions in an
update is never silent.
Troubleshooting
The widget doesn't appear in the catalog
It failed validation. The Layout tab lists rejected widgets and the reason for each, which is
usually a missing required manifest field, an id that doesn't match the folder name,
or an invalid category.
The widget shows blank
Almost always a JavaScript error before anything renders, or content sized outside a
height: 100vh body. Open the page in a normal browser with a stubbed
widgey object and check the console.
Sensor calls return null
Either the widget is missing the system permission, or the sensor service isn't
installed and running. Check Settings → Hardware Monitoring. Unknown sensor keys
also return null rather than throwing, so check your spelling against the table
above.
Network requests fail with 403
The host isn't on your networkHosts list. Remember that
*.example.com does not cover example.com itself, and that every
subresource — images, fonts, scripts — is checked, not just fetch.
Settings don't persist
settings.set() is fire-and-forget; if you write and immediately read back in the
same tick you may race it. Use await on a get when you need the
round trip.
Ready to build one?
Widgey scaffolds a working widget for you. Settings → Layout → + Create.