StreamSync Overlays — Reference
🍼 ELI5
An overlay is the on-screen stuff viewers see over your gameplay — alert pop-ups, a chat box, a "now playing" label. Drag widgets onto a canvas in the Visual Builder, no code required, then point an OBS Browser Source at the overlay's link and it updates live as things happen on stream.
Overlays are HTML/CSS/JS pages served by a local HTTP server and loaded as Browser Sources in OBS Studio (or any other software that accepts a URL). StreamSync injects a WebSocket client into every overlay page so the page receives live stream events with no extra setup.
How it works
When StreamSync starts, it launches an HTTP + WebSocket server on port 3838. The port is fixed — if 3838 is already in use by another app, StreamSync will log an error rather than switching ports, so your OBS Browser Source URLs never change unexpectedly.
http://localhost:3838/overlays/<id>— serves the overlay HTML pagews://localhost:3838/ws— WebSocket endpoint; auto-injected into every overlay pagehttp://localhost:3838/— lists all saved overlays
The WebSocket client is injected into every overlay's <head> automatically. It:
- Connects to
ws://localhost:3838/wson page load - Auto-reconnects every 3 seconds if the connection drops
- Dispatches incoming events as
CustomEvents onwindow, so your JS useswindow.addEventListener('chat.message', ...)— no WebSocket boilerplate needed
Visual Builder
The Visual Builder is the default authoring mode for new overlays. It requires no HTML, CSS, or JavaScript knowledge — you pick widgets, position them on a 1920 × 1080 canvas, and configure their appearance through a properties panel. Generated code is produced automatically on save.
Widget types
| Widget | What it does |
|---|---|
| Text | Static label. Set font, size, color, weight, alignment, and optional drop shadow. |
| Chat Box | Live chat feed. Configurable direction (bottom-up or top-down), max message count, font size, and per-user name colors. |
| Alert | Event pop-up for follows, subs, raids, cheers, and redeems. Template text supports {name} interpolation. Has animation (fade, slide-down, slide-up, bounce) and auto-dismiss timer. |
| Latest Event | Persists the last follower/subscriber/raider/cheerer name on screen. Uses {name} template. Updates in real-time as events arrive. |
| Image | URL-sourced or local-file image. Supports contain/cover/stretch fit and opacity. Local files are copied to the assets folder and served over HTTP so OBS can load them. |
| Stream Info | Displays live stream title, game/category, or viewer count. Updates when StreamSync sends a stream.info event. |
Builder vs code mode
New overlays always open in builder mode. Existing overlays that were hand-coded open in code mode, unchanged.
To switch a builder overlay to raw code: click Edit code… in the action bar and confirm the prompt. This is one-way — the generated code becomes editable but the builder data is discarded.
The Generated code collapsible (bottom of the editor) lets you inspect or copy the HTML/CSS/JS output of a builder overlay without leaving builder mode.
Testing widgets
A Test: row of buttons sits below the builder canvas. Clicking any button immediately broadcasts a sample event to all connected overlay pages (OBS Browser Sources or Preview windows). Use these to trigger alert animations and verify chat box behaviour without real viewer activity.
| Button | Event fired |
|---|---|
| Follow | follow — displayName: TestUser |
| Subscribe | subscribe — tier 1000 |
| Raid (42) | raid — 42 viewers |
| Cheer 100 | cheer — 100 bits |
| Redeem | redeem — Test Reward |
| Chat | chat.message — "Hello! PogChamp" |
Note: Test events go to connected overlay pages, not the builder canvas preview. Open the overlay via Open or load it in OBS first.
Creating and editing overlays
Builder mode (default for new overlays)
- Open the app → click Overlays in the nav
- Click + New
- Type a name — the URL ID is auto-generated (e.g. "My Chat" →
my-chat) - Click widgets in the Blank or Library palette to add them to the canvas
- Drag elements to reposition; select one to edit its properties on the right
- Click Save — HTML/CSS/JS is generated automatically
- Click Open to preview, or Copy to get the OBS URL
Code mode
To write raw HTML/CSS/JS instead:
- Click + New, give the overlay a name
- Click Edit code… in the action bar (if in builder mode, confirm the prompt)
- Edit the HTML, CSS, and JS tabs directly
- Click Save
To edit an existing overlay, click its name in the sidebar.
Overlay files
Each overlay is stored as its own .json file inside the overlays/ folder:
apps/streamsync/overlays/
├── .gitignore ← root *.json files are gitignored (personal)
├── example-overlay/
│ └── example-overlay.json ← bundled starter overlay (tracked in git)
└── widgets/
└── ... ← widget library (see below)In development, overlays you create land as flat files in overlays/ (e.g. overlays/my-chat.json). These are gitignored by default so personal overlays don't appear in the repo.
In a packaged build, overlays are stored in the app's userData directory (%APPDATA%\streamsync\overlays\ on Windows).
Sharing an overlay
An overlay .json looks like this:
{
"id": "my-chat",
"name": "My Chat",
"builderElements": [ ... ],
"html": "...(generated)...",
"css": "...(generated)...",
"js": "...(generated)...",
"createdAt": 1750000000000,
"updatedAt": 1750000000000
}To share an overlay:
- Find the file in
overlays/(or export it from the sidebar — coming soon) - Send the
.jsonfile to someone - They drop it into their own
overlays/folder and it appears in the sidebar on next launch
Overlays placed in a sub-folder act as read-only bundled presets — deleting them from the app removes only the root copy, and the preset reappears automatically. This is how the example-overlay/ folder works.
Widget Library
The widget library gives the Visual Builder a palette of pre-configured, shareable widgets. Widgets are .json files in overlays/widgets/, grouped into sub-folders by category.
overlays/widgets/
├── alerts/
│ ├── follow-alert.json
│ ├── subscribe-alert.json
│ ├── raid-alert.json
│ └── cheer-alert.json
├── chat/
│ └── bottom-up-chat.json
├── stream-info/
│ ├── viewer-count.json
│ └── game-title.json
└── latest-events/
├── latest-follower.json
└── latest-subscriber.jsonDropping any .json into this tree (in the right sub-folder, or a new one) makes it appear in the Library section of the builder palette on next launch. No code changes required.
Built-in widgets
Alerts
| File | Description |
|---|---|
follow-alert.json | Purple slide-down — new follower |
subscribe-alert.json | Gold bounce-in — new subscriber |
raid-alert.json | Red slide-up — incoming raid |
cheer-alert.json | Teal fade-in — bit cheer |
Chat
| File | Description |
|---|---|
bottom-up-chat.json | Transparent feed, newest messages at the bottom |
Stream Info
| File | Description |
|---|---|
viewer-count.json | Current viewer count (updates on stream.info) |
game-title.json | Current game/category |
Latest Events
| File | Description |
|---|---|
latest-follower.json | Persists most recent follower name |
latest-subscriber.json | Persists most recent subscriber name |
Creating a custom widget
A widget file is a single .json that describes one pre-configured element. When a user clicks it in the palette, StreamSync assigns it a fresh ID and adds it to the canvas — so one widget file can be used many times without conflicts.
Minimal template
{
"id": "my-widget",
"name": "My Widget",
"description": "A short description shown in the palette",
"category": "My Category",
"element": {
"id": "_tpl",
"type": "alert",
"x": 25, "y": 35, "w": 50, "h": 16,
...element properties...
}
}Save the file anywhere inside overlays/widgets/ (sub-folders become the default category if you omit the category field). Restart the app and the widget appears in the Library palette.
The
idinsideelement("_tpl"here) is always replaced with a fresh random ID when the widget is dropped onto the canvas. Any placeholder value works.
Field reference
| Field | Required | Description |
|---|---|---|
id | Yes | Unique slug for the widget file (e.g. "follow-alert-red") |
name | Yes | Display name in the palette |
description | No | One-line description shown below the name |
category | No | Palette group heading. Defaults to the sub-folder name (capitalised). |
element | Yes | The full element definition (see element types below) |
Element types and their properties
All element types share these base position fields (values are percentages of the 1920 × 1080 canvas):
| Field | Type | Description |
|---|---|---|
x | number | Left edge (0–100) |
y | number | Top edge (0–100) |
w | number | Width (0–100) |
h | number | Height (0–100) |
rotation | number (optional) | Tilt in degrees, -180–180. Only Text, Image, and Video widgets expose a control for it in the builder — see below. |
Rotating an element
Select a Text, Image, or Video widget on the canvas and a small handle appears above it, connected by a thin line (below it instead, if the widget sits near the top edge of the canvas). Drag the handle to tilt the element around its center; hold Shift while dragging to snap to 15° steps. The properties panel also has a numeric Rotation ° field for setting an exact angle. Rotating an element that also has the Alert Add-on enabled keeps the tilt through the entrance animation — it doesn't reset when the alert plays.
"type": "text" — Static label
{
"id": "_tpl",
"type": "text",
"x": 5, "y": 5, "w": 30, "h": 6,
"content": "Now Live!",
"fontSize": 48,
"fontFamily": "sans-serif",
"fontWeight": "bold",
"color": "#ffffff",
"textAlign": "left",
"textShadow": true
}| Field | Type | Values |
|---|---|---|
content | string | Text to display |
fontSize | number | px |
fontFamily | string | CSS font family (e.g. "sans-serif", "Georgia") |
fontWeight | string | "normal" | "bold" |
color | string | CSS color |
textAlign | string | "left" | "center" | "right" |
textShadow | boolean | Adds a subtle drop shadow |
"type": "chatbox" — Live chat feed
{
"id": "_tpl",
"type": "chatbox",
"x": 2, "y": 40, "w": 28, "h": 55,
"maxMessages": 12,
"fontSize": 22,
"useNameColors": true,
"bgColor": "transparent",
"textColor": "#ffffff",
"direction": "bottom-up"
}| Field | Type | Values |
|---|---|---|
maxMessages | number | Messages kept visible before oldest is trimmed |
fontSize | number | px |
useNameColors | boolean | Use each viewer's Twitch name colour |
bgColor | string | CSS color ("transparent" = no background) |
textColor | string | CSS color |
direction | string | "bottom-up" (newest at bottom) | "top-down" (newest at top) |
"type": "alert" — Event pop-up
{
"id": "_tpl",
"type": "alert",
"x": 25, "y": 35, "w": 50, "h": 16,
"event": "follow",
"template": "New Follower! {name}",
"duration": 5,
"animation": "slide-down",
"bgColor": "rgba(145,70,255,0.9)",
"textColor": "#ffffff",
"fontSize": 42,
"borderRadius": 12
}| Field | Type | Values |
|---|---|---|
event | string | "follow" | "subscribe" | "raid" | "cheer" | "redeem" | "subscribe.gift" | "watch_streak.milestone" |
template | string | Display text. {name} is replaced with the relevant username. |
duration | number | Seconds the alert stays visible |
animation | string | "fade" | "slide-down" | "slide-up" | "bounce" |
bgColor | string | CSS color (supports rgba for transparency) |
textColor | string | CSS color |
fontSize | number | px |
borderRadius | number | px |
minTotal / maxTotal | number (optional) | Only used when event is "subscribe.gift" — only fires when the gift batch's total falls in this range. Leave a bound unset to skip it. |
minStreak / maxStreak | number (optional) | Only used when event is "watch_streak.milestone" — only fires when the viewer's streak count falls in this range. Leave a bound unset to skip it. |
{name} resolves to:
| Event | Value |
|---|---|
follow | displayName |
subscribe | displayName |
raid | fromUserName |
cheer | displayName |
redeem | displayName |
subscribe.gift | gifterName |
watch_streak.milestone | displayName |
"type": "latest-event" — Latest event display
{
"id": "_tpl",
"type": "latest-event",
"x": 2, "y": 92, "w": 32, "h": 6,
"event": "follow",
"label": "Latest Follower:",
"template": "{name}",
"labelFontSize": 20,
"valueFontSize": 24,
"labelColor": "#aaaaaa",
"valueColor": "#9146ff"
}| Field | Type | Values |
|---|---|---|
event | string | "follow" | "subscribe" | "raid" | "cheer" |
label | string | Prefix label text |
template | string | Value text. {name} is replaced with the username. |
labelFontSize | number | px |
valueFontSize | number | px |
labelColor | string | CSS color |
valueColor | string | CSS color |
"type": "image" — Image / logo
{
"id": "_tpl",
"type": "image",
"x": 80, "y": 3, "w": 16, "h": 16,
"src": "https://example.com/logo.png",
"opacity": 100,
"objectFit": "contain"
}| Field | Type | Values |
|---|---|---|
src | string | Full URL (https://…) or a local asset URL (http://localhost:3838/assets/…) |
opacity | number | 0–100 |
objectFit | string | "contain" | "cover" | "fill" |
To use a local file: open the widget's properties in the builder and click Browse local file…. StreamSync copies the file to the assets folder and fills in the URL automatically.
"type": "streaminfo" — Stream metadata
{
"id": "_tpl",
"type": "streaminfo",
"x": 2, "y": 2, "w": 20, "h": 8,
"field": "viewers",
"label": "Viewers:",
"labelFontSize": 24,
"valueFontSize": 28,
"labelColor": "#aaaaaa",
"valueColor": "#ffffff"
}| Field | Type | Values |
|---|---|---|
field | string | "title" | "game" | "viewers" |
label | string | Prefix label text |
labelFontSize | number | px |
valueFontSize | number | px |
labelColor | string | CSS color |
valueColor | string | CSS color |
Stream info widgets update when StreamSync broadcasts a stream.info event. This happens automatically from the Dashboard. You can also trigger it manually:
window.streamSync.overlay.sendEvent({
type: 'stream.info',
data: { title: 'My Stream', game: 'Minecraft', viewers: 42 }
});Full custom widget example
A red "Cheer" alert for cheers over 500 bits:
{
"id": "big-cheer-alert",
"name": "Big Cheer Alert",
"description": "Red slide-up for cheers (use with a 500-bit threshold in your bot)",
"category": "Alerts",
"element": {
"id": "_tpl",
"type": "alert",
"x": 20, "y": 30, "w": 60, "h": 20,
"event": "cheer",
"template": "{name} dropped a massive cheer!",
"duration": 7,
"animation": "slide-up",
"bgColor": "rgba(200,30,30,0.95)",
"textColor": "#ffff00",
"fontSize": 44,
"borderRadius": 8
}
}Save as overlays/widgets/alerts/big-cheer-alert.json, restart the app, and it appears under Library → Alerts in the builder palette.
OBS Browser Source setup
- In OBS: Sources → + → Browser
- Set URL to the overlay's URL (e.g.
http://localhost:3838/overlays/chat-box) - Set width/height to 1920 × 1080 (or match your canvas size)
- Enable "Shutdown source when not visible" and "Refresh browser when scene becomes active"
- For transparent background: add to your overlay CSS:cssThe overlay server already injects
body { background: transparent; }body { background: transparent; }by default, so this is handled automatically.
Note: The overlay server only listens on
127.0.0.1(localhost). It is not accessible from other machines on your network.
Receiving events in overlay JS
Every event type from the bot/EventSub system arrives as a CustomEvent on window. The event type matches the StreamSync event type string.
window.addEventListener('chat.message', function(e) {
const data = e.detail.data;
console.log(data.displayName, data.message);
});e.detail is the full event object:
{
platform: 'twitch',
type: 'chat.message',
timestamp: 1719500000000, // Unix ms
data: { /* event-specific payload — see below */ }
}platform is 'twitch', 'youtube', or 'streamhub' depending on what fired the event. Most event types below are Twitch-only; a few (noted per-event) also fire from a connected YouTube account on StreamSync Pro. Filter on e.detail.platform in your overlay JS if you need to treat them differently. Kick never fires overlay events — it has no live event feed.
Event reference
chat.message
Fires for every chat message the bot receives. Twitch only — the bot only reads Twitch IRC (see Chat Bot); YouTube/Kick chat is not wired into the bot engine.
window.addEventListener('chat.message', function(e) {
const { displayName, userName, message, color,
isMod, isSubscriber, isBroadcaster, bits } = e.detail.data;
});| Field | Type | Description |
|---|---|---|
messageId | string | Twitch message ID |
userId | string | Twitch user ID |
userName | string | Login name (lowercase) |
displayName | string | Display name (case-preserved) |
message | string | Raw message text |
color | string | Chat name color (hex, e.g. #9146FF) |
badges | Record<string,string> | Raw badge set/version pairs |
isMod | boolean | |
isSubscriber | boolean | |
isBroadcaster | boolean | |
bits | number | Cheer bits (0 if not a cheer) |
emotes | Array<{id,name,positions}> | Raw Twitch emote data |
parts | Array<Part> | Parsed message segments with emote image URLs |
parts — inline emote rendering
Each element is one of:
{ type: 'text', content: 'hello ' }
{ type: 'emote', id: '...', name: 'accousHi', url: 'https://...', source: 'bttv' }source is one of 'twitch', 'bttv', '7tv', or 'ffz'. The built-in Chat Box widget renders parts automatically so emotes appear as inline images. Use parts in custom overlay JS when you want the same behaviour:
window.addEventListener('chat.message', function(e) {
var d = e.detail.data;
var wrap = document.createElement('span');
(d.parts || [{ type: 'text', content: d.message }]).forEach(function(p) {
if (p.type === 'emote') {
var img = document.createElement('img');
img.src = p.url; img.alt = p.name; img.title = p.name;
img.style.height = '1.4em'; img.style.verticalAlign = 'middle';
wrap.appendChild(img);
} else {
wrap.appendChild(document.createTextNode(p.content));
}
});
});follow
window.addEventListener('follow', function(e) {
const { displayName, followedAt } = e.detail.data;
});| Field | Type |
|---|---|
userId | string |
userName | string |
displayName | string |
followedAt | string (ISO 8601) |
subscribe
window.addEventListener('subscribe', function(e) {
const { displayName, tier, isGift } = e.detail.data;
});| Field | Type | Values |
|---|---|---|
userId | string | |
userName | string | |
displayName | string | |
tier | string | '1000', '2000', '3000', 'prime' |
isGift | boolean |
subscribe.gift
window.addEventListener('subscribe.gift', function(e) {
const { gifterName, recipientName, tier, total } = e.detail.data;
});| Field | Type | Description |
|---|---|---|
gifterId | string | |
gifterName | string | |
recipientId | string | |
recipientName | string | |
tier | string | '1000', '2000', '3000' |
total | number | Cumulative gifts from this user |
subscribe.message
Fired when a resub message is posted in chat. Also fires from a connected YouTube account (Pro) on a new channel membership — platform will be 'youtube' and tier/months won't be populated since YouTube has no tier/month-count concept.
| Field | Type |
|---|---|
displayName | string |
tier | string |
months | number |
message | string |
cheer
Also fires from a connected YouTube account (Pro) for Super Chats and Super Stickers — platform will be 'youtube' and bits holds the equivalent amount reported by YouTube's API.
| Field | Type |
|---|---|
displayName | string |
bits | number |
message | string |
isAnonymous | boolean |
raid
Twitch only — YouTube and Kick have no raid equivalent.
| Field | Type |
|---|---|
fromUserName | string |
viewerCount | number |
redeem
Fired when a channel point reward is redeemed. Twitch only — YouTube and Kick have no channel points equivalent.
| Field | Type |
|---|---|
displayName | string |
rewardTitle | string |
userInput | string |
cost | number |
stream.online / stream.offline
Also fires from a connected YouTube account (Pro) — platform will be 'youtube' when an active live broadcast is found/ends. Kick has no online/offline event (the Dashboard polls Kick's live status separately instead — see Connecting Platforms).
window.addEventListener('stream.online', function(e) {
console.log('Stream started at', e.detail.data.startedAt);
});
window.addEventListener('stream.offline', function() {
console.log('Stream ended');
});Other events
hype_train.begin, hype_train.progress, hype_train.end, poll.begin, poll.end, prediction.begin, prediction.end, ban, timeout, chat.clear
All Twitch only, and all follow the same e.detail.data pattern. See electron/eventBus.ts for the full payload shapes.
Sending events manually
From the app, you can push a custom event to all connected overlays:
// In any future automation or from the DevTools console:
window.streamSync.overlay.sendEvent({ type: 'my-custom-event', data: { foo: 'bar' } });In the overlay:
window.addEventListener('my-custom-event', function(e) {
console.log(e.detail.data.foo); // 'bar'
});The Custom Overlay Event trigger action does the same thing from a rule — see Triggers & Actions — so you don't have to open DevTools every time; a raid, a follow, or any other stream event can drive it automatically.
Example: Chat Box
HTML
<div id="chat"></div>CSS
#chat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
height: 100vh;
justify-content: flex-end;
overflow: hidden;
font-family: 'Segoe UI', sans-serif;
font-size: 15px;
}
.msg { display: flex; gap: 6px; align-items: baseline; }
.msg-name { font-weight: 700; flex-shrink: 0; }
.msg-text { color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.8); }JS
window.addEventListener('chat.message', function(e) {
const { displayName, message, color } = e.detail.data;
const chat = document.getElementById('chat');
const div = document.createElement('div');
div.className = 'msg';
div.innerHTML = `
<span class="msg-name" style="color:${color || '#9146ff'}">${displayName}</span>
<span class="msg-text">${message}</span>
`;
chat.appendChild(div);
// Keep last 30 messages visible
while (chat.children.length > 30) chat.removeChild(chat.firstChild);
});Example: Task List (!addtask / !taskdone)
See the chat box example above. A full task list example:
JS (adapted from StreamElements — replace STREAMER_NAME with your display name)
const STREAMER_NAME = 'YourName';
let taskList = {};
function Task(user, text, number) {
this.user = user; this.text = text; this.number = number;
}
window.addEventListener('chat.message', function(e) {
const data = e.detail.data;
const parts = data.message.toLowerCase().split(' ');
const cmd = parts.shift();
if (cmd === '!addtask') {
if (!(data.displayName in taskList)) taskList[data.displayName] = [];
const n = taskList[data.displayName].length + 1;
const task = new Task(data.displayName, parts.join(' '), n);
taskList[data.displayName].push(task);
addTask((task.user === STREAMER_NAME) ? 'streamer' : 'chat', task.text, task.user, task.number);
}
if (cmd === '!taskdone') {
const idx = parseInt(parts[0]);
if (data.displayName in taskList && !isNaN(idx) && idx <= taskList[data.displayName].length) {
removeTask((data.displayName === STREAMER_NAME) ? 'streamer' : 'chat', data.displayName, idx);
}
}
});
function addTask(type, text, user, n) {
const el = document.createElement('div');
el.className = `task ${type}`;
el.id = `task-${type}-${user}-${n}`;
el.innerHTML = `<span class="num">${n}</span><span class="name">${user}</span><span class="text">${text}</span>`;
const container = document.querySelector(`.${type}-tasks`);
container.style.display = 'flex';
if (type === 'streamer') container.appendChild(el);
else container.prepend(el);
}
function removeTask(type, user, n) {
const el = document.getElementById(`task-${type}-${user}-${n}`);
if (!el) return;
el.classList.add('fade-out');
el.addEventListener('animationend', () => el.remove(), { once: true });
}Tips
- Transparent background is already set by default (
body { background: transparent; overflow: hidden; }). Add it explicitly in your CSS only if you overridebody. - Font loading — Google Fonts CDN links can be added in the HTML
<head>tag. - Multiple overlays can be open in OBS at once — each is an independent browser source.
- Event filtering — listen only for the events you care about. Unhandled events are silently discarded.
- Persistent state — overlay JS state resets whenever OBS refreshes the source. For state that survives refreshes, you'd need
localStorageor a server-side store. - Testing — click Open in the overlay editor to preview in your browser. Use browser DevTools to inspect events and debug JS errors.