Custom widgets
Build your own overlay widgets with HTML, CSS, and JS - StreamElements custom widgets run as-is.
A custom widget is a widget you write yourself: an HTML pane, a CSS pane, a JS pane, and a JSON schema describing the settings it exposes. It runs on your overlay like any built-in widget - drag it, resize it, stack it, test it from the editor’s emulator - and it receives your tips, follows, subs, cheers, raids, and chat as events the moment they happen.
The runtime is StreamElements-compatible: widgets written for SE’s
custom widget system - onWidgetLoad, onEventReceived, SE_API,
{{field}} templates, jQuery - run here without changes. If you’ve got a
widget from an SE gallery or a commission, paste its four panes in and it
works. (A few SE surfaces are stubbed for now - see
compatibility below for the list.)
Quick start
Add a Custom widget
In the overlay editor, Add widget → Custom widget. You can have as many custom widgets as you like, each with its own settings.
Create or pick the code
With the widget selected, the inspector shows a Widget picker. Choose an existing widget from your library, or hit New widget to open the code editor: name, HTML, CSS, JS, and Fields (JSON).
Fill in the fields
Whatever the widget declares in its fields schema appears as a normal settings form in the inspector - text boxes, sliders, colour pickers, image uploads. Each placed instance keeps its own values, so the same widget can run twice with different settings.
Test it
Open Test alerts in the bottom bar and fire tips, follows, subs, cheers, raids, and chat messages at it. What you see in the editor is exactly what OBS renders.
The four panes
- HTML - the widget’s markup.
{{fieldName}}(or{fieldName}) placeholders are replaced with field values before it renders. - CSS - styles, same templating. The widget’s box is the rectangle you
draw in the editor;
html, bodyinside it are transparent and sized to fill it. - JS - your logic. jQuery is preloaded (like StreamElements), and
{{fieldName}}templates are substituted here too (single-brace{name}is left alone in JS - that’s code, not a template). - Fields - a JSON object describing your settings form. See below.
Fields
The fields schema is the SE format: each key is a field, each value describes it.
{
"titleText": { "type": "text", "label": "Title", "value": "Hello chat" },
"accent": { "type": "colorpicker", "label": "Accent colour", "value": "#4ade80" },
"speed": { "type": "slider", "label": "Speed", "value": 5, "min": 1, "max": 20, "step": 1 },
"showIcon": { "type": "checkbox", "label": "Show icon", "value": true },
"mode": { "type": "dropdown", "label": "Mode", "value": "bar", "options": { "bar": "Bar", "ring": "Ring" } },
"logo": { "type": "image-input", "label": "Logo" },
"font": { "type": "googleFont", "label": "Font", "value": "Poppins" },
"reset": { "type": "button", "label": "Reset counter", "value": "reset" },
"widgetName": { "type": "hidden", "value": "My Counter" }
}
Supported types and how the inspector renders them:
| Type | Inspector control |
|---|---|
text, sound-input, video-input |
Text input |
number, slider |
Number field (with min / max / step) |
checkbox |
Toggle |
colorpicker |
Colour picker |
dropdown |
Select built from options |
googleFont |
Font picker |
image-input |
Upload button - files go to TipPage’s CDN, same as image widgets |
button |
A button; clicking it fires a widget-button event into the widget |
hidden |
Not shown; the default value is passed straight through |
Add a "group" property to any field to section the form under a heading.
Field values reach your JS as obj.detail.fieldData in onWidgetLoad, and
as {{templates}} in all three panes.
The runtime
onWidgetLoad
Fires once when the widget mounts:
window.addEventListener('onWidgetLoad', function (obj) {
const fieldData = obj.detail.fieldData; // your fields, typed (numbers, booleans, strings)
const currency = obj.detail.currency; // { code, name, symbol }
const channel = obj.detail.channel; // { username, apiToken: "" }
const session = obj.detail.session.data; // SE-shaped session snapshot
});
onEventReceived
Fires on every live event. obj.detail.listener tells you what it is:
| Listener | Fired when | Event payload |
|---|---|---|
tip-latest |
A tip alert plays | { name, amount, message } |
follower-latest |
New follow | { name } |
subscriber-latest |
Sub, resub, or gift | { name, amount, tier, message, gifted?, sender?, bulkGifted? } - amount is months (or gift count), tier is 1/2/3 |
cheer-latest |
Bits cheered | { name, amount, message } |
raid-latest |
Incoming raid | { name, amount } - amount is viewers |
message |
A chat message | { data: { displayName, displayColor, userId, badges, emotes, text, msgId, ... } } |
delete-message |
A mod deletes one message | { msgId } |
delete-messages |
A user is banned/timed out | { userId } |
kvstore:update |
Any widget writes to the store | { data: { key: "customWidget.<key>", value } } |
widget-button |
A button field is clicked in the editor |
{ field, value } |
Every overlay keeps one shared Twitch chat connection open, whether or
not the chat widget is on the layout - so message events always arrive.
A pasted SE chat box works on an otherwise-empty overlay.
SE_API
| Call | What it does here |
|---|---|
SE_API.store.set(key, value) |
Persists to a per-widget key/value store on TipPage; fans out kvstore:update to every custom widget |
SE_API.store.get(key) |
Promise of the stored value (null if unset) |
SE_API.getOverlayStatus() |
{ isEditorMode, muted } - live mute state in the editor |
SE_API.sanitize({ message }) |
Resolves the message unchanged (skip: false) |
SE_API.cheerFilter(message) |
Strips cheermote tokens |
SE_API.setField(key, value) |
Updates local fieldData (not persisted) |
SE_API.counters.get(name) |
Resolves { counter, value: 0 } - TipPage has no bot counters yet |
SE_API.resumeQueue() |
Accepted, currently a no-op |
Store limits: 100 keys per widget, 10KB per value.
StreamElements compatibility
Identical today: the event and load lifecycle, listener names, payload
shapes, SE_API.store, fields schema and form rendering, {{templates}},
jQuery, per-widget sandboxing.
Known gaps, so you’re never surprised:
session.datahas the right shape but zeroed totals (latest/recent and week/month/total counters). Widgets render; running totals start at 0.SE_API.countersalways returns 0 (no bot counters yet).SE_API.resumeQueue()and thewidgetDurationhidden field don’t hold TipPage’s alert queue - alerts are played by the built-in alert widget.sound-input/video-inputfields are plain URL inputs for now.
Security
Custom widget code runs inside a sandboxed iframe (sandbox="allow-scripts",
no same-origin). Concretely, that means pasted code cannot:
- read or touch the rest of the overlay (other widgets, the page DOM);
- see your overlay URL/key or the overlay session token;
- call the TipPage API as you - it has no cookies, no tokens, and
channel.apiTokenis deliberately empty; - break other widgets - worst case it wedges its own box.
Its only communication is a fixed set of messages the overlay defines (events in, store calls out). Two things to still be deliberate about:
- Only the overlay owner (and super admins) can create or edit widget code. Team members with overlay access can place widgets and change their fields, but never the code - because the code ultimately executes inside your OBS.
- Sandboxed code can still load external resources and make its own network requests (images, fonts, its own APIs - many SE widgets rely on this). Treat pasting a widget like installing software: use code you trust or can read. The sandbox limits the blast radius to the widget’s own box and whatever events you feed it.
Privacy
What widget code can observe is exactly what your overlay already shows on stream: donor display names, tip amounts and messages, follower/sub/cheer/ raid names, and public chat messages. It never receives emails, payment details, real names, session tokens, or anything from your dashboard.
The code itself runs locally - in your OBS browser source and in the editor preview - not on TipPage’s servers. The only widget data TipPage stores is what you put in it: the code you save, the field values on your layout, and whatever the widget writes to its own key/value store. All of it is scoped to your account, and deleting a widget deletes its store.
Because pasted code can call external services, a malicious widget could send the events it sees (donor names, chat) to a third party. That’s the same trust model as any browser-source widget on any platform - it’s why code editing is owner-only, and why you should paste from sources you trust.
Limits
| Thing | Limit |
|---|---|
| Widgets per account | 100 |
| Each code pane (HTML / CSS / JS) | 200KB |
| Fields schema | 50KB |
| Store keys per widget | 100, 10KB per value |
| Field value on a layout | 2,000 characters |