======================================================================== 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 = `
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" } }