========================================================================
CONTOUR ALERTS · FREE StreamElements custom widget by LumentoStudio
The easy way: install it in one click from lumentostudio.com/free/.
By hand: create a Custom Widget and paste each block into the matching tab (HTML / CSS / JS / Fields).
Widget size in the overlay: 640 x 220 px (use the Size setting to scale).
========================================================================
=== HTML ===
=== CSS ===
/* ── Contour · free alert box by LumentoStudio ────────────────────────
A black plate that opens from a single white line, topographic
contours drawing across it, corner ticks. Event type shown by icon
shape; one quiet hairline counts the alert down.
Plate black 60 · line white 30 · grey 10 (+ one accent field).
Colours marked (field) are overwritten from the widget settings. */
html, body { margin: 0; padding: 0; background: transparent; overflow: hidden; }
.ca {
/* colour (field) */
--plate: #0c0c0d; --line: #f4f4f1; --accent: #f4f4f1;
/* fixed */
--dim: color-mix(in srgb, var(--line) 55%, var(--plate));
--font-display: "Red Hat Display", "Helvetica Neue", Arial, sans-serif; --font-label: "Red Hat Mono", Menlo, monospace;
--ease-open: cubic-bezier(.76, 0, .24, 1); --ease-text: cubic-bezier(.2, .9, .1, 1); --ease-close: cubic-bezier(.6, 0, .8, .4);
--dur: 6s; --size: 1;
zoom: var(--size); position: absolute; left: 0; top: 0; width: calc(100vw / var(--size)); height: calc(100vh / var(--size));
display: grid; place-items: center;
}
.ca * { box-sizing: border-box; }
.ca-al { position: relative; width: 560px; height: 150px; }
.ca-al.idle { visibility: hidden; }
.ca-plate { position: absolute; inset: 0; background: var(--plate); overflow: hidden;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--line) 12%, transparent) inset, 0 2px 4px #0008, 0 18px 40px #0007; }
.ca-plate svg { position: absolute; left: 140px; top: -65px; width: 420px; height: 280px; transition: none; }
.ca-plate svg path { fill: none; stroke: var(--line); stroke-width: 1; opacity: .22; stroke-dasharray: 1400; }
/* corner ticks: the signature, echoed by the credit's bracket */
.ca-plate i { position: absolute; width: 12px; height: 12px; border: 0 solid var(--line); opacity: .75; }
.ca-plate i:nth-of-type(1) { left: 10px; top: 10px; border-width: 1.5px 0 0 1.5px; }
.ca-plate i:nth-of-type(2) { right: 10px; top: 10px; border-width: 1.5px 1.5px 0 0; }
.ca-plate i:nth-of-type(3) { left: 10px; bottom: 10px; border-width: 0 0 1.5px 1.5px; }
.ca-plate i:nth-of-type(4) { right: 10px; bottom: 10px; border-width: 0 1.5px 1.5px 0; }
.ca-time { position: absolute; left: 0; bottom: 0; height: 2px; width: 100%; background: var(--accent); transform-origin: left; transform: scaleX(0); }
.ca-credit { position: absolute; right: 28px; bottom: 12px; font: 500 9px/1 var(--font-label); letter-spacing: .12em; text-transform: uppercase; color: var(--dim); opacity: .8; }
.ca.no-credit .ca-credit { display: none; }
.ca-body { position: absolute; inset: 0; display: flex; align-items: center; gap: 24px; padding: 0 34px; }
.ca-glyph { width: 72px; height: 72px; flex: none; border: 1.5px solid var(--accent); border-radius: 50%; display: grid; place-items: center; }
.ca-glyph svg { width: 30px; height: 30px; fill: none; stroke: var(--line); stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; }
.ca-text { min-width: 0; flex: 1; }
.ca-text small { display: block; font: 500 12px/1 var(--font-label); letter-spacing: .32em; text-transform: uppercase; color: var(--dim); }
.ca-text b { display: block; font: 900 40px/1.05 var(--font-display); letter-spacing: -.025em; color: var(--line); margin: 8px 0 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ca-text span { display: block; font: 500 15px/1.3 var(--font-display); color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ca-text span em { font-style: normal; color: var(--line); font-weight: 800; }
/* choreography: line → plate opens → contours draw → text rises.
The exit is its own move: text drops, plate snaps shut to a line.
Resting state (no class) is fully visible; classes only layer motion on top. */
.ca-al.in .ca-plate { animation: caOpen .7s var(--ease-open) both; }
.ca-al.in .ca-plate svg path { animation: caDraw 1.8s var(--ease-text) .35s both; }
.ca-al.in .ca-plate svg path:nth-child(2n) { animation-delay: .45s; }
.ca-al.in .ca-glyph { animation: caGlyph .7s var(--ease-text) .35s both; }
.ca-al.in .ca-text > * { animation: caRise .7s var(--ease-text) both; }
.ca-al.in .ca-text small { animation-delay: .42s; } .ca-al.in .ca-text b { animation-delay: .5s; } .ca-al.in .ca-text span { animation-delay: .58s; }
.ca-al.timing .ca-time { animation: caTime var(--dur) linear .6s both; }
.ca-al.out .ca-body { animation: caDrop .35s var(--ease-close) both; }
.ca-al.out .ca-plate { animation: caShut .5s var(--ease-open) .2s both; }
@keyframes caOpen { 0% { clip-path: inset(49% 50% 49% 50%) } 40% { clip-path: inset(49% 0 49% 0) } 100% { clip-path: inset(0) } }
@keyframes caShut { 0% { clip-path: inset(0) } 60% { clip-path: inset(49% 0 49% 0) } 100% { clip-path: inset(49% 0 49% 100%) } }
@keyframes caDraw { from { stroke-dashoffset: 1400 } to { stroke-dashoffset: 0 } }
@keyframes caGlyph { from { transform: scale(.6) rotate(-30deg); opacity: .5 } }
@keyframes caRise { from { transform: translateY(16px); opacity: .5; clip-path: inset(0 0 100% 0) } to { clip-path: inset(0 0 -20% 0) } }
@keyframes caDrop { to { transform: translateY(10px); opacity: 0 } }
@keyframes caTime { from { transform: scaleX(0) } to { transform: scaleX(1) } }
=== JS ===
/* Contour · free alert box by LumentoStudio — platform-free render engine.
ContourAlert.init(rootEl, { duration, size, messages, credit, sound, volume, money(n), colors: { plate, line, accent } })
ContourAlert.show({ type: "follow"|"sub"|"gift"|"giftbomb"|"cheer"|"raid"|"tip", name, months, tier, recipient, amount, message })
Alerts queue and play one at a time. ContourAlert.demo() loops sample events. */
(function (root) {
const G = {
follow: ' ',
sub: ' ',
gift: ' ',
cheer: ' ',
raid: ' ',
tip: ' ' };
G.giftbomb = G.gift;
const esc = (s) => String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
// nested wobbly loops around an off-plate summit; drawn once, shifted per alert
const TOPO = (() => { let d = ""; for (let k = 1; k <= 11; k++) { const r = 16 + k * 17; let p = "";
for (let a = 0; a <= 64; a++) { const t = a / 64 * Math.PI * 2, w = 1 + .16 * Math.sin(3 * t + k * .5) + .08 * Math.sin(5 * t - k);
p += (a ? "L" : "M") + (250 + Math.cos(t) * r * w * 1.25).toFixed(1) + " " + (150 + Math.sin(t) * r * w * .8).toFixed(1); }
d += ` `; } return d; })();
let rootEl, al, opt = { duration: 6, messages: true, money: (n) => "$" + Number(n).toFixed(2) }, queue = [], busy = false, audio = null, seq = 0;
function lines(e) {
const msg = opt.messages && e.message ? ` · “${esc(String(e.message).trim())}”` : "";
if (e.label) return [e.label, esc(e.detail || "")]; // plain notice (setup hint)
switch (e.type) {
case "follow": return ["New Follower", esc(opt.followText || "Thanks for the follow!")];
case "sub": return ["New Subscriber", `${e.tier === "prime" ? "Prime" : "Tier " + (e.tier || 1)} ${+e.months > 1 ? ` · ${plural(+e.months, "month")}` : ""}${msg}`];
case "gift": return ["Gift Sub", `gifted a sub to ${esc(e.recipient)} `];
case "giftbomb": return ["Gifted Subs", `gifted ${plural(+e.amount, "sub")} to the community!`];
case "cheer": return ["Cheer", `${plural(+e.amount, "bit")} ${msg}`];
case "raid": return ["Raid", `raiding with ${plural(+e.amount, "viewer")} `];
case "tip": return ["New Tip", `${esc(opt.money(+e.amount))} ${msg}`];
} return ["", ""];
}
function play() {
if (busy || !queue.length) return;
busy = true;
const e = queue.shift(), [label, detail] = lines(e), me = ++seq;
al.querySelector(".ca-glyph svg").innerHTML = G[e.type] || G.follow;
al.querySelector("small").textContent = label;
al.querySelector("b").textContent = e.name || "Someone";
al.querySelector("span").innerHTML = detail;
const svg = al.querySelector(".ca-plate svg");
svg.style.transform = `translate(${(me * 47) % 90 - 45}px, ${(me * 31) % 40 - 20}px) rotate(${(me * 23) % 30 - 15}deg)`;
if (audio) { try { audio.currentTime = 0; audio.play().catch(() => {}); } catch (x) {} }
al.classList.remove("idle", "out", "in", "timing"); void al.offsetWidth; al.classList.add("in", "timing");
// timers still run in a hidden OBS scene, so every state snaps back to rest on its own
setTimeout(() => al.classList.remove("in"), 2400);
setTimeout(() => { al.classList.remove("in", "timing"); al.classList.add("out"); }, opt.duration * 1000 + 600);
setTimeout(() => { al.classList.remove("out"); al.classList.add("idle"); busy = false; setTimeout(play, 500); }, opt.duration * 1000 + 1350);
}
const ContourAlert = {
init(el, o) {
opt = Object.assign(opt, o || {});
opt.duration = Math.max(2, +opt.duration || 6);
rootEl = el; el.classList.add("ca");
el.innerHTML = `${TOPO}
widget by LumentoStudio
`;
al = el.querySelector(".ca-al");
const c = opt.colors || {}, s = el.style;
if (c.plate) s.setProperty("--plate", c.plate);
if (c.line) s.setProperty("--line", c.line);
if (c.accent) s.setProperty("--accent", c.accent);
s.setProperty("--size", Math.min(3, Math.max(.4, +opt.size || 1)));
s.setProperty("--dur", opt.duration + "s");
el.classList.toggle("no-credit", opt.credit === false);
if (opt.sound) { audio = new Audio(opt.sound); audio.volume = Math.min(1, Math.max(0, opt.volume == null ? .5 : +opt.volume)); }
},
show(e) { if (!al || !e) return; queue.push(e); play(); },
demo() {
const D = [
{ type: "follow", name: "pixelwren" },
{ type: "sub", name: "NightOwlKai", tier: 1, months: 6 },
{ type: "giftbomb", name: "frostbyte_", amount: 10 },
{ type: "cheer", name: "mossgrove", amount: 500, message: "clean run, let's go" },
{ type: "raid", name: "SableStreams", amount: 84 },
{ type: "tip", name: "quietember", amount: 15, message: "for the coffee fund" } ];
let i = 0;
const next = () => { if (!queue.length && !busy) ContourAlert.show(D[i++ % D.length]); };
next(); return setInterval(next, 1000);
} };
root.ContourAlert = ContourAlert;
})(window);
/* ── StreamElements adapter ──
Gift subs: a community gift arrives as one "bulkGifted" event followed by one
"isCommunityGift" event per recipient. More than 5 at once = ONE grouped alert
(recipients skipped); 5 or fewer = the bulk event is skipped and each recipient shows. */
(function () {
let F = {};
const GROUP_OVER = 5, suppress = {};
const tierOf = (t) => (t === "prime" ? "prime" : t ? String(t).charAt(0) : 1);
const on = (k) => F[k] !== false;
window.addEventListener("onWidgetLoad", (obj) => {
F = obj.detail.fieldData || {};
const code = (obj.detail.currency && obj.detail.currency.code) || "USD";
let fmt; try { fmt = new Intl.NumberFormat("en-US", { style: "currency", currency: code }); } catch (e) { fmt = null; }
ContourAlert.init(document.getElementById("alerts"), {
duration: +F.duration || 6, size: +F.size || 1, messages: F.showMessages !== false, credit: F.showCredit !== false,
followText: F.followText, sound: F.sound || "", volume: (F.volume == null ? 50 : +F.volume) / 100,
money: (n) => (fmt ? fmt.format(n) : "$" + Number(n).toFixed(2)),
colors: { plate: F.plateColor, line: F.textColor, accent: F.accentColor } });
});
window.addEventListener("onEventReceived", (obj) => {
const { listener, event: e } = obj.detail || {};
if (!e) return;
switch (listener) {
case "follower-latest": if (on("showFollow")) ContourAlert.show({ type: "follow", name: e.name }); break;
case "subscriber-latest":
if (e.bulkGifted) {
if (on("showGifts") && +e.amount > GROUP_OVER) { suppress[e.sender] = (suppress[e.sender] || 0) + +e.amount; ContourAlert.show({ type: "giftbomb", name: e.sender || e.name, amount: +e.amount }); }
break;
}
if (e.isCommunityGift && suppress[e.sender] > 0) { suppress[e.sender]--; break; }
if (e.gifted) { if (on("showGifts")) ContourAlert.show({ type: "gift", name: e.sender || e.name, recipient: e.name }); break; }
if (on("showSubs")) ContourAlert.show({ type: "sub", name: e.name, months: +e.amount || 1, tier: tierOf(e.tier), message: e.message });
break;
case "cheer-latest": if (on("showCheers") && +e.amount >= (+F.minCheer || 1)) ContourAlert.show({ type: "cheer", name: e.name, amount: +e.amount, message: e.message }); break;
case "raid-latest": if (on("showRaids") && +e.amount >= (+F.minRaid || 1)) ContourAlert.show({ type: "raid", name: e.name, amount: +e.amount || 0 }); break;
case "tip-latest": if (on("showTips") && +e.amount >= (+F.minTip || 0)) ContourAlert.show({ type: "tip", name: e.name, amount: +e.amount, message: e.message }); break;
}
});
})();
=== FIELDS ===
{
"showFollow": {
"type": "checkbox",
"label": "Follows",
"value": true,
"group": "Alerts"
},
"showSubs": {
"type": "checkbox",
"label": "Subscriptions",
"value": true,
"group": "Alerts"
},
"showGifts": {
"type": "checkbox",
"label": "Gift subs (more than 5 at once = one grouped alert)",
"value": true,
"group": "Alerts"
},
"showCheers": {
"type": "checkbox",
"label": "Cheers",
"value": true,
"group": "Alerts"
},
"showRaids": {
"type": "checkbox",
"label": "Raids",
"value": true,
"group": "Alerts"
},
"showTips": {
"type": "checkbox",
"label": "Tips",
"value": true,
"group": "Alerts"
},
"minCheer": {
"type": "number",
"label": "Minimum bits for a cheer alert",
"value": 1,
"min": 1,
"step": 1,
"group": "Alerts"
},
"minRaid": {
"type": "number",
"label": "Minimum viewers for a raid alert",
"value": 1,
"min": 1,
"step": 1,
"group": "Alerts"
},
"minTip": {
"type": "number",
"label": "Minimum tip amount",
"value": 0,
"min": 0,
"step": 1,
"group": "Alerts"
},
"duration": {
"type": "number",
"label": "Alert duration (seconds)",
"value": 6,
"min": 2,
"max": 30,
"step": 1,
"group": "Alerts"
},
"showMessages": {
"type": "checkbox",
"label": "Show viewer messages (subs, cheers, tips)",
"value": true,
"group": "Alerts"
},
"followText": {
"type": "text",
"label": "Follow alert text",
"value": "Thanks for the follow!",
"group": "Alerts"
},
"sound": {
"type": "sound-input",
"label": "Alert sound (optional)",
"value": "",
"group": "Sound"
},
"volume": {
"type": "slider",
"label": "Sound volume",
"value": 50,
"min": 0,
"max": 100,
"step": 1,
"group": "Sound"
},
"plateColor": {
"type": "colorpicker",
"label": "Plate colour",
"value": "#0c0c0d",
"group": "Look"
},
"textColor": {
"type": "colorpicker",
"label": "Text and line colour",
"value": "#f4f4f1",
"group": "Look"
},
"accentColor": {
"type": "colorpicker",
"label": "Accent (icon ring and timer line)",
"value": "#f4f4f1",
"group": "Look"
},
"size": {
"type": "slider",
"label": "Size",
"value": 1,
"min": 0.6,
"max": 2,
"step": 0.05,
"group": "Look"
},
"showCredit": {
"type": "checkbox",
"label": "Show the small 'widget by LumentoStudio' credit",
"value": true,
"group": "Look"
}
}