======================================================================== 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 = `0 / 00%