---
title: Custom widgets
description: Build your own overlay widgets with HTML, CSS, and JS - StreamElements custom widgets run as-is.
---

{/* Keep in sync with overlay-v2/src/widgets/custom-widget.js (runtime +
    SE_API shim), api/src/routes/tenant/custom-widgets.js (limits, roles)
    and the fields renderer in the overlay editor. Hand-maintained. */}

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](#streamelements-compatibility) below for the list.)

## Quick start

1. **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.

2. **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).

3. **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.

4. **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, body` inside 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.

```json
{
  "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:

```js
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.data` has the **right shape but zeroed totals** (latest/recent
  and week/month/total counters). Widgets render; running totals start at 0.
- `SE_API.counters` always returns 0 (no bot counters yet).
- `SE_API.resumeQueue()` and the `widgetDuration` hidden field don't hold
  TipPage's alert queue - alerts are played by the built-in alert widget.
- `sound-input` / `video-input` fields 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.apiToken` is 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 |
