Not a framework. Not a component library. Seven things you can own. Custom elements, no dependencies, no build step. Copy a tag and a script and it works.
<!-- all seven, ~20KB, no dependencies --> <script src="https://wgd.ai/1/instruments.js"></script> <!-- then anywhere on the page --> <wgd-switch label="POWER"></wgd-switch>
A switch should feel like a decision. The detent breaks over mid-drag, so once you're past centre you can't take it back — the same reason a real toggle has no undo.
<wgd-switch label="POWER" on></wgd-switch> // state power.on = true; // silent, reflects to attribute power.toggle(); // clicks + fires change // events power.addEventListener("change", e => console.log(e.detail.value)); // forms — submits like a real control <form><wgd-switch name="power" value="on"></wgd-switch></form>
| Attributes | ||
| on | bool | Presence = on. on="false" is deleted on parse so CSS and JS can't disagree. |
| label | string | Rendered beside it, and used as the accessible name |
| name / value | string | Form field name; value submitted when on (default "on") |
| disabled | bool | Untabbable, unclickable, silent |
| mute | bool | Silences this instance only |
| electric | bool | Arc while on |
| Properties · Events | ||
| .on | bool | Get/set. Setting does not fire change |
| .toggle() | → bool | Flips and fires change |
| change | event | detail.value — boolean |
| CSS | ||
| --switch-size | length | Width. Everything else scales off it |
| --switch-accent | color | Falls back to --wgd-accent |
Vertical drag, not circular chasing — your finger never has to orbit the knob. Detents tick only when you actually cross one. The arc fires at either endstop, so you can feel the range end without looking.
<wgd-dial label="GAIN" min="0" max="11" step="0.5" value="4" detent></wgd-dial> // input while turning, change on release gain.addEventListener("input", e => live(e.detail.value)); gain.addEventListener("change", e => save(e.detail.value)); gain.value = 7.5; // silent, quantized to step
| Attributes | ||
| min / max | number | Default 0 / 100 |
| step | number | Quantization. 0 for continuous. Sets readout decimals |
| value | number | Reflected — the attribute always shows the live value |
| units | string | Appended to the readout |
| detent | bool | Audible tick per step crossed (needs step) |
| Properties · Events | ||
| .value | number | Get/set. Setting is silent |
| input | event | Every change while dragging/wheeling |
| change | event | On release, or immediately on keypress |
| CSS | ||
| --dial-size | length | Diameter |
| --dial-accent | color | Indicator, lit ticks, readout |
The hairline is spring-damped, so it arrives before it stops. Over redline the arc
fires: on a readout electric means alarm, not decoration. That's the whole reason
the arc exists in a set of controls.
<wgd-meter label="LOAD" min="0" max="100" redline="85" units="%" electric></wgd-meter> // readouts take a value and emit nothing but alarm load.value = 92; load.addEventListener("alarm", e => { // fires once on entering AND leaving tolerance page.classList.toggle("critical", e.detail.alarm); });
| Attributes | ||
| value | number | Current reading |
| min / max | number | Scale ends. Default 0 / 100 |
| redline | number | Zone start. Reaching it raises alarm |
| units | string | Appended to the readout |
| Properties · Events | ||
| .value | number | Get/set |
| .alarm | bool | Read-only current state |
| alarm | event | detail.alarm — on both edges |
| CSS | ||
| --meter-size | length | Width; bar height derives from it |
| --meter-redline | color | Zone tint |
Latching keys, the way car radio presets worked: pressing one pops the others out, and you hear both events. The arc surges on latch and dies — current spikes when a contact closes, it doesn't sit there glowing.
<wgd-radio label="BAND" options="AM,FM,SW,AIR" value="FM"></wgd-radio> band.addEventListener("change", e => { e.detail.value; // "SW" e.detail.index; // 2 }); band.value = "AIR"; // or band.selectedIndex = 3
| Attributes | ||
| options | csv | Comma-separated labels. Rebuilds the keys when changed |
| value | string | Selected label. Reflected |
| name | string | Form field name |
| Properties · Events | ||
| .value | string | Get/set by label |
| .selectedIndex | number | Get/set by position |
| .options | string[] | Read-only, parsed |
| change | event | detail.value, detail.index |
| CSS | ||
| --radio-size | length | Drives key size and type scale |
| --radio-accent | color | Pressed key |
The card turns, the lubber line doesn't. That's how you read a real compass — you look at the fixed mark, not a spinning needle. It's deliberately underdamped, so it swings past and settles, and it always takes the short way across zero.
<wgd-compass heading="41" bearing="90" tolerance="12" electric></wgd-compass> hdg.heading = 352; // wraps, takes the short path // optional: live device heading + tilt. // must be called from a gesture — never auto-prompts. const stop = await WGD.attachOrientation(hdg, lvl);
| Attributes | ||
| heading | deg | 0–360, normalized |
| bearing | deg | Optional target mark. Omit for no alarm |
| tolerance | deg | Alarm past this from bearing. Default 10 |
| Properties · Events | ||
| .heading | deg | Get/set |
| alarm | event | Off-bearing edges |
| CSS | ||
| --compass-size | length | Diameter |
| --compass-accent | color | Lubber line, N, readout |
The bubble is overdamped on purpose — fluid is viscous, so unlike every needle in the set it must never overshoot. Offset tracks the sine of the angle, not the angle, which is why it slows as it nears the end of the vial.
<wgd-level label="TRIM" angle="1.8" range="6" tolerance="0.5" electric></wgd-level> trim.angle = -3.4; // degrees, signed trim.addEventListener("alarm", e => e.detail.alarm && warn("out of trim"));
| Attributes | ||
| angle | deg | Signed. Positive tilts right |
| range | deg | Angle at the end of the vial. Default 6 |
| tolerance | deg | Alarm past this from level. Default 0.5 |
| Properties · Events | ||
| .angle | deg | Get/set |
| alarm | event | Out-of-tolerance edges |
| CSS | ||
| --level-size | length | Vial width |
| --level-accent | color | Bubble |
Real ballistics: 300ms to 99% with about 1% overshoot. That one spec is the entire difference between a VU meter and a bar graph — it never quite catches the signal, which is correct. The peak dot holds, then falls back slowly.
<wgd-vu label="MIX L" value="0.35" electric></wgd-vu> // value is AMPLITUDE, not dB. 1.0 = 0 VU. vu.value = 0.9; // live from the mic. gesture only. mic writes an internal // level, not the attribute — 60 attribute writes a second // would run every aria update 60 times a second. await vu.enableMic(); vu.disableMic();
| Attributes | ||
| value | amp | 0–1.6 amplitude. 1.0 is 0 VU, 1.42 is +3 |
| Properties · Events | ||
| .value | amp | Get/set. Reads the live level while mic is on |
| .enableMic() | → Promise | Requires a user gesture. Never auto-prompts |
| .disableMic() | void | Stops tracks and resets |
| alarm | event | Crossing 0 VU |
| CSS | ||
| --vu-size | length | Width; 200:118 aspect |
| --vu-redline | color | Above 0 VU |
// CONTROLS — switch, dial, radio. Emit while moving, commit on release. el.addEventListener("input", e => e.detail.value); // continuous only el.addEventListener("change", e => e.detail.value); // all three // setting .value or .on programmatically is always SILENT // READOUTS — meter, vu, compass, level. Take a value, emit alarm. el.value = 92; el.addEventListener("alarm", e => e.detail.alarm); // both edges // THEME — one declaration restyles every instrument on the page :root { --wgd-accent: #ffc328; --wgd-size: 80px; --wgd-font: 'Inter'; } // per-instrument overrides win: --switch-accent, --dial-size, ... // SHADOW PARTS — for anything the vars don't reach wgd-switch::part(cap) { border-radius: 2px; } // PAGE LEVEL WGD.electrify(true); // arcs on, every instrument WGD.mute = true; // silence everything; or mute="" per element WGD.all(); // → every instrument in the document WGD.version; // "1.0.0"