======================================================================== BOOKSHELF GOAL · 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: 620 x 200 px (use the Size setting to scale). ======================================================================== === HTML ===
=== CSS === /* ── Bookshelf · free goal bar by LumentoStudio ─────────────────────── A small wall shelf in a cozy room. Every step toward the goal slides a book onto the shelf; a mug of tea sits at the progress front; the lamp at the end lights when the goal is reached. Plum wall 60 · walnut + book spines 30 · lamp amber 10. Colours marked (field) are overwritten from the widget settings. */ html, body { margin: 0; padding: 0; background: transparent; overflow: hidden; } .bs { /* colour (field) */ --wall: #2b2333; --lamp: #ffc36b; /* fixed */ --wall-2: color-mix(in srgb, var(--wall) 86%, #fff); --wood: #8a5a3b; --wood-hi: #b07a52; --wood-lo: #5e3c27; --cream: #f7ecd9; --font-title: Spectral, Georgia, serif; --font-num: Jost, "Avenir Next", sans-serif; --ease-slide: cubic-bezier(.22, 1.2, .36, 1); --ease-glow: cubic-bezier(.4, 0, .2, 1); --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; } .bs * { box-sizing: border-box; } .bs-card { width: 560px; position: relative; padding: 16px 20px 0; border-radius: 6px 6px 3px 3px; background: radial-gradient(120% 90% at 100% 0, color-mix(in srgb, var(--lamp) 8%, transparent), #0000 60%), repeating-linear-gradient(90deg, #ffffff07 0 14px, #0000 14px 15px, #ffffff03 15px 28px, #0000 28px 30px), linear-gradient(var(--wall-2), var(--wall)); box-shadow: 0 1px 0 #ffffff12 inset, 0 2px 4px #0007, 0 16px 34px #0006; } .bs-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; color: var(--cream); } .bs-head h1 { margin: 0; font: italic 600 24px/1.1 var(--font-title); letter-spacing: -.005em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .bs-head p { margin: 0; flex: none; font: 600 15px/1 var(--font-num); letter-spacing: .04em; font-variant-numeric: tabular-nums; } .bs-head p span { color: var(--lamp); } .bs-head p small { font-size: 12px; letter-spacing: .14em; opacity: .6; margin-left: 8px; } .bs-shelf { position: relative; height: 74px; margin-top: 12px; display: flex; align-items: flex-end; gap: 2px; padding: 0 64px 0 2px; } .bs-shelf::after { content: ""; position: absolute; left: -20px; right: -20px; bottom: -14px; height: 14px; border-radius: 0 0 3px 3px; background: linear-gradient(var(--wood-hi) 0 2px, var(--wood) 2px 10px, var(--wood-lo) 10px); box-shadow: 0 6px 10px #0006; } .bs-book { flex: none; height: var(--h); width: var(--w); background: var(--c); border-radius: 2px 2px 0 0; position: relative; box-shadow: inset -3px 0 0 #0002, inset 2px 0 0 #ffffff26; transform-origin: 0 100%; } .bs-book::before, .bs-book::after { content: ""; position: absolute; left: 0; right: 0; height: 3px; background: #0000002e; } .bs-book::before { top: 14%; } .bs-book::after { top: 22%; height: 1.5px; } .bs-book.tilt { transform: rotate(9deg); } .bs-book.new { animation: bsSlide .7s var(--ease-slide); } @keyframes bsSlide { from { transform: translate(40px, -10px) rotate(14deg); opacity: .6 } } .bs-mug { position: absolute; bottom: 0; width: 34px; height: 30px; transition: left .7s var(--ease-slide); } .bs-mug b { position: absolute; left: 0; bottom: 0; width: 26px; height: 26px; background: var(--cream); border-radius: 3px 3px 9px 9px; box-shadow: inset -4px 0 0 #0000001a; } .bs-mug b::after { content: ""; position: absolute; right: -8px; top: 6px; width: 9px; height: 11px; border: 3px solid var(--cream); border-left: 0; border-radius: 0 7px 7px 0; } .bs-mug i { position: absolute; bottom: 28px; width: 4px; height: 16px; border-radius: 4px; background: var(--cream); opacity: 0; animation: bsSteam 2.8s var(--ease-glow) infinite; } .bs-mug i:nth-child(2) { left: 7px; } .bs-mug i:nth-child(3) { left: 15px; animation-delay: 1.4s; } @keyframes bsSteam { 0% { transform: translateY(4px) scaleY(.5); opacity: 0 } 40% { opacity: .45 } 100% { transform: translateY(-12px) scaleY(1) skewX(-12deg); opacity: 0 } } .bs-lamp { position: absolute; right: 22px; bottom: 0; width: 30px; height: 64px; } .bs-lamp::before { content: ""; position: absolute; left: 13px; bottom: 0; width: 4px; height: 40px; background: var(--wood-lo); } .bs-lamp::after { content: ""; position: absolute; left: 4px; bottom: -1px; width: 22px; height: 5px; border-radius: 3px; background: var(--wood-lo); } .bs-lamp span { position: absolute; left: 0; top: 0; width: 30px; height: 22px; background: var(--cream); clip-path: polygon(22% 0, 78% 0, 100% 100%, 0 100%); transition: background .8s var(--ease-glow); } .bs-lamp em { position: absolute; left: -30px; top: 16px; width: 90px; height: 70px; background: radial-gradient(50% 60% at 50% 0, color-mix(in srgb, var(--lamp) 35%, transparent), #0000 70%); opacity: 0; transition: opacity .8s var(--ease-glow); pointer-events: none; } .bs.done .bs-lamp span { background: var(--lamp); } .bs.done .bs-lamp em { opacity: 1; } .bs-foot { height: 34px; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; color: var(--cream); font: 500 12px/1 var(--font-num); letter-spacing: .14em; text-transform: uppercase; white-space: nowrap; } .bs-foot > span:first-child { opacity: .65; overflow: hidden; text-overflow: ellipsis; } .bs.done .bs-foot .bs-st { color: var(--lamp); } .bs.no-latest .bs-latest { visibility: hidden; } .bs-credit { margin: -3px 0 0; padding-bottom: 8px; text-align: right; font: 500 9px/1 var(--font-num); letter-spacing: .1em; color: var(--cream); opacity: .45; } .bs.no-credit .bs-credit { display: none; } === JS === /* Bookshelf · free goal bar by LumentoStudio — platform-free render engine. ShelfGoal.init(rootEl, { title, target, size, latest, credit, format(n), colors: { wall, lamp } }) ShelfGoal.set(value, target?) · ShelfGoal.setLatest(name) · ShelfGoal.demo() */ (function (root) { const COLORS = ["#e6d5b8", "#9cb59a", "#c97c5d", "#7d93b8", "#d9a55b", "#6f8f86", "#b98fa8", "#e3b9a0", "#4f6b8a", "#a8603f"]; const SHELF = 400; // shelf pixels for books (card is 560 wide) const rnd = (n) => { const x = Math.sin(n * 91.7) * 43758.5; return x - Math.floor(x); }; // a fixed, seeded shelf: the same books always land in the same slots const BOOKS = []; { let x = 0, k = 0; while (x < SHELF) { const w = 12 + Math.round(rnd(k) * 10); BOOKS.push({ w, h: 46 + Math.round(rnd(k + 9) * 26), c: COLORS[(k * 3 + Math.floor(rnd(k + 3) * 2)) % COLORS.length] }); x += w + 2; k++; } } const esc = (s) => String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">"); let el, opt = { title: "Follower Goal", target: 50, format: (n) => String(Math.floor(n)) }, value = 0; function render(fresh) { if (!el) return; const target = Math.max(1, +opt.target || 1), p = Math.max(0, Math.min(1, value / target)), done = value >= target; const shelf = el.querySelector(".bs-shelf"), mug = el.querySelector(".bs-mug"); const want = done ? BOOKS.length : Math.min(BOOKS.length - 1, Math.round(p * BOOKS.length)); const have = shelf.querySelectorAll(".bs-book"); have.forEach((b) => b.classList.remove("tilt")); for (let k = have.length; k < want; k++) { const b = BOOKS[k], d = document.createElement("div"); d.className = "bs-book" + (fresh ? " new" : ""); d.style.cssText = `--w:${b.w}px;--h:${b.h}px;--c:${b.c}`; shelf.insertBefore(d, mug); if (fresh) setTimeout(() => d.classList.remove("new"), 750); } for (let k = have.length - 1; k >= want; k--) have[k].remove(); const books = shelf.querySelectorAll(".bs-book"), last = books[books.length - 1]; if (last && !done) last.classList.add("tilt"); mug.style.left = (last ? Math.min(last.offsetLeft + last.offsetWidth + (done ? 4 : 12), SHELF + 4) : 6) + "px"; el.querySelector(".bs-n").textContent = opt.format(value); el.querySelector(".bs-t").textContent = opt.format(target); el.querySelector(".bs-pct").textContent = Math.floor(p * 100) + "%"; el.querySelector(".bs-st").textContent = done ? "Goal reached" : `${opt.format(target - value)} to go`; el.classList.toggle("done", done); } const ShelfGoal = { init(rootEl, o) { opt = Object.assign(opt, o || {}); el = rootEl; el.classList.add("bs"); el.innerHTML = `

${esc(opt.title)}

0 / 00%

widget by LumentoStudio
`; const c = opt.colors || {}, s = el.style; if (c.wall) s.setProperty("--wall", c.wall); if (c.lamp) s.setProperty("--lamp", c.lamp); s.setProperty("--size", Math.min(3, Math.max(.4, +opt.size || 1))); el.classList.toggle("no-credit", opt.credit === false); el.classList.toggle("no-latest", opt.latest === false); render(false); }, set(v, target) { const prev = value; value = Math.max(0, +v || 0); if (target) opt.target = +target; render(value > prev); }, setLatest(name) { if (el && name) { el.querySelector(".bs-who").textContent = name; el.querySelector(".bs-latest").style.visibility = ""; } }, demo() { const N = ["mossy_reads", "teacup_tom", "rainyday_ivy", "lanternlou", "biscuit_owl", "pagefern", "slowsunday"]; let v = 18; ShelfGoal.set(v, 50); ShelfGoal.setLatest(N[0]); return setInterval(() => { v = v >= 54 ? 12 : v + 1; ShelfGoal.set(Math.min(v, 50)); ShelfGoal.setLatest(N[v % N.length]); }, 1600); } }; root.ShelfGoal = ShelfGoal; })(window); /* ── StreamElements adapter ── progress from the session counters ("This stream" = *-session, "This month" = *-month, "All time" = *-total) */ (function () { let F = {}; const KEY = { follower: "count", subscriber: "count", cheer: "amount", tip: "amount" }; const TITLE = { follower: "Follower Goal", subscriber: "Sub Goal", cheer: "Bits Goal", tip: "Tip Goal" }; const type = () => (KEY[F.goalType] ? F.goalType : "follower"); const value = (data) => { const d = (data || {})[`${type()}-${F.countFrom || "session"}`] || {}; return (+d[KEY[type()]] || 0) + (+F.startAt || 0); }; const latest = (data) => { const d = (data || {})[`${type()}-latest`]; return d && d.name; }; window.addEventListener("onWidgetLoad", (obj) => { F = obj.detail.fieldData || {}; const code = (obj.detail.currency && obj.detail.currency.code) || "USD"; let money; try { money = new Intl.NumberFormat("en-US", { style: "currency", currency: code, maximumFractionDigits: 0 }); } catch (e) { money = null; } const data = obj.detail.session && obj.detail.session.data; ShelfGoal.init(document.getElementById("goal"), { title: F.title || TITLE[type()], target: +F.target || 50, size: +F.size || 1, latest: F.showLatest !== false, credit: F.showCredit !== false, format: type() === "tip" && money ? (n) => money.format(Math.floor(n)) : (n) => String(Math.floor(n)), colors: { wall: F.wallColor, lamp: F.lampColor } }); ShelfGoal.set(value(data)); ShelfGoal.setLatest(latest(data)); }); window.addEventListener("onSessionUpdate", (obj) => { const data = obj.detail.session && obj.detail.session.data; ShelfGoal.set(value(data)); ShelfGoal.setLatest(latest(data)); }); })(); === FIELDS === { "goalType": { "type": "dropdown", "label": "Goal type", "value": "follower", "options": { "follower": "Followers", "subscriber": "Subscribers", "cheer": "Bits", "tip": "Tips" }, "group": "Goal" }, "countFrom": { "type": "dropdown", "label": "Count", "value": "session", "options": { "session": "This stream", "month": "This month", "total": "All time" }, "group": "Goal" }, "target": { "type": "number", "label": "Goal target", "value": 50, "min": 1, "step": 1, "group": "Goal" }, "startAt": { "type": "number", "label": "Add to the count (for a head start)", "value": 0, "min": 0, "step": 1, "group": "Goal" }, "title": { "type": "text", "label": "Title (leave empty for the default)", "value": "", "group": "Goal" }, "showLatest": { "type": "checkbox", "label": "Show the latest name", "value": true, "group": "Goal" }, "wallColor": { "type": "colorpicker", "label": "Wall colour", "value": "#2b2333", "group": "Look" }, "lampColor": { "type": "colorpicker", "label": "Lamp and number colour", "value": "#ffc36b", "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" } }