const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const damp = (cur, t, l, dt) => cur + (t - cur) * (1 - Math.exp(-l * dt));
const C_REST = "#d5d5d5";
{ src: "https://carterogunsola.com/lab/music/deep-abstract-ambient_snowcap-401656.mp3", title: "D.A.A.S", artist: "Umbrella", cover: "https://carterogunsola.com/lab/covers/umbrella.jpg" },
{ src: "https://carterogunsola.com/lab/music/embrace-364091.mp3", title: "Embrace", artist: "Evgeny Bardyuzha", cover: "https://carterogunsola.com/lab/covers/embrace.jpg" },
{ src: "https://carterogunsola.com/lab/music/running-night-393139.mp3", title: "Running Night", artist: "Alex Make Music", cover: "https://carterogunsola.com/lab/covers/running-night.jpg" }
{ kind: "min", text: "Scroll the page — the bar on the right wakes up" },
{ kind: "min", text: "Pause, or hover it, and it blooms into a clock" },
{ kind: "min", text: "Normal scrolling still works — it just has a pulse" },
{ kind: "music", text: "A note appears. Tap it — the player is live" },
{ kind: "music", text: "Now playing. Hover to expand, drag the timeline to seek" },
{ kind: "min", text: "Back to default — clock only, no player" }
const clockSvg = `<svg viewBox="0 0 20 20" fill="none" class="sb__clock-face"><rect data-hour x="9.5" y="4" width="1" height="5.5" rx="0.5" fill="currentColor"/><rect data-min x="9.5" y="2" width="1" height="8" rx="0.5" fill="currentColor"/><circle cx="10" cy="10" r="1.25" fill="currentColor"/></svg>`;
const noteSvg = `<svg viewBox="0 0 20 20" fill="none"><path d="M19.5 14C19.5 15.933 17.933 17.5 16 17.5C14.067 17.5 12.5 15.933 12.5 14C12.5 12.067 14.067 10.5 16 10.5C16.98 10.5 17.865 10.903 18.5 11.552V5.59L7.5 7.423V16C7.5 17.933 5.933 19.5 4 19.5C2.067 19.5 0.5 17.933 0.5 16C0.5 14.067 2.067 12.5 4 12.5C4.98 12.5 5.865 12.903 6.5 13.552V2.576L19.5 0.41V14Z" fill="currentColor"/></svg>`;
const waveSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"><line x1="2" y1="15" x2="2" y2="9"/><line x1="7" y1="19.5" x2="7" y2="4.5"/><line x1="12" y1="22" x2="12" y2="2"/><line x1="17" y1="19.5" x2="17" y2="4.5"/><line x1="22" y1="15" x2="22" y2="9"/></svg>`;
const prevSvg = `<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="10 5 1 12 10 19"/><polygon points="22 5 13 12 22 19"/></svg>`;
const nextSvg = `<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="14 19 23 12 14 5"/><polygon points="2 19 11 12 2 5"/></svg>`;
const playSvg = `<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5.2 12 5.2 3 13 7.5 20.8 12 13 16.5 5.2 21"/></svg>`;
const pauseSvg = `<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6.5" y="5.5" width="3" height="13" rx="0.4"/><rect x="14.5" y="5.5" width="3" height="13" rx="0.4"/></svg>`;
const STYLE_ID = "exp-scrollbar-style";
font-family: var(--font-sans);
height: var(--sb-vh,100%);
font-size: clamp(1.1rem,2vw,1.6rem);
letter-spacing: var(--track);
background: var(--sb-rest);
justify-content: space-between;
transform: translateX(20%);
background: var(--sb-paper);
will-change: transform,filter,opacity;
font-family: var(--font-mono);
justify-content: space-between;
transform: translateX(100%);
justify-content: space-between;
background: var(--sb-track);
background: var(--sb-rest);
transition: opacity .2s var(--ease-out-quad),scale .15s var(--ease-out-quad);
text-decoration: underline;
text-underline-offset: 2px;
@media (prefers-reduced-motion: reduce) {
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
document.head.appendChild(el);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const ac = new AbortController();
const sb = document.createElement("div");
const scroll = document.createElement("div");
scroll.className = "sb__scroll";
const track = document.createElement("div");
track.className = "sb__track";
SECTIONS.forEach((s) => {
const sec = document.createElement("section");
sec.className = "sb__section";
sec.dataset.kind = s.kind;
sec.innerHTML = `<p class="sb__caption">${s.text}</p>`;
scroll.appendChild(track);
const bar = document.createElement("div");
bar.className = "sb__bar";
const thumb = document.createElement("div");
thumb.className = "sb__thumb";
<div class="sb__min-top">
<div class="sb__clock">${clockSvg}</div>
<div class="sb__note" role="button" aria-label="Open player">${noteSvg}</div>
<div class="sb__time"><span data-time>00:00</span><span class="ampm" data-ampm>AM</span></div>
<div class="sb__cover"><img alt="" decoding="async"/></div>
<div class="sb__meta"><span class="sb__title" data-title></span><span class="sb__artist" data-artist></span></div>
<div class="sb__wave">${waveSvg}</div>
<div class="sb__timeline" data-timeline><div class="sb__progress" data-progress></div></div>
<div class="sb__controls">
<div class="sb__ctrl sb__ctrl--sm" data-prev role="button" aria-label="Previous">${prevSvg}</div>
<div class="sb__ctrl sb__play" data-play role="button" aria-label="Play">${playSvg}</div>
<div class="sb__ctrl sb__pause" data-pause role="button" aria-label="Pause">${pauseSvg}</div>
<div class="sb__ctrl sb__ctrl--sm" data-next role="button" aria-label="Next">${nextSvg}</div>
<div class="sb__credits">
<p class="sb__credit">Icons :: <a href="https://angularicons.com/" target="_blank" rel="noreferrer">Angular Icons</a> / <a href="https://www.osmo.supply/" target="_blank" rel="noreferrer">Osmo</a></p>
<p class="sb__credit">Original /Motion :: Xavier Jac (<a href="https://x.com/KMkota0" target="_blank" rel="noreferrer">@KMkota0</a>)</p>
const q = (s) => thumb.querySelector(s);
const minEl = q(".sb__min");
const musicEl = q(".sb__music");
const hourHand = q("[data-hour]");
const minHand = q("[data-min]");
const timeEl = q("[data-time]");
const ampmEl = q("[data-ampm]");
const tickClock = () => {
const m = d.getMinutes();
gsap.to(hourHand, { rotation: h % 12 * 30 + m * 0.5, transformOrigin: "center bottom", duration: 0.5, ease: "power2.out", overwrite: "auto" });
gsap.to(minHand, { rotation: m * 6, transformOrigin: "center bottom", duration: 0.5, ease: "power2.out", overwrite: "auto" });
timeEl.textContent = `${String(h % 12 || 12).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
ampmEl.textContent = h >= 12 ? "PM" : "AM";
const clockInt = window.setInterval(tickClock, 1e3);
const audio = new Audio();
const coverImg = q(".sb__cover img");
const titleEl = q("[data-title]");
const artistEl = q("[data-artist]");
const progressEl = q("[data-progress]");
const timelineEl = q("[data-timeline]");
const playBtn = q("[data-play]");
const pauseBtn = q("[data-pause]");
const waveLines = [...thumb.querySelectorAll(".sb__wave line")];
const startWave = () => {
waveTl = gsap.timeline({ repeat: -1 });
waveLines.forEach((ln, i) => {
const y1 = +ln.getAttribute("y1");
const y2 = +ln.getAttribute("y2");
const h = Math.abs(y2 - y1) * 0.4;
waveTl.to(ln, { attr: { y1: y1 - h, y2: y2 + h }, duration: 0.4 + i * 0.05, ease: "sine.inOut", yoyo: true, repeat: -1 }, i * 0.08);
waveLines.forEach((ln) => {
const y1 = ln.dataset.y1;
const y2 = ln.dataset.y2;
gsap.to(ln, { attr: { y1, y2 }, duration: 0.3, ease: "power2.out" });
waveLines.forEach((ln) => {
ln.dataset.y1 = ln.getAttribute("y1");
ln.dataset.y2 = ln.getAttribute("y2");
const load = (i, play = false) => {
trackIdx = (i + TRACKS.length) % TRACKS.length;
const t = TRACKS[trackIdx];
titleEl.textContent = t.title;
artistEl.textContent = t.artist;
gsap.set(progressEl, { scaleX: 0 });
if (play) audio.play().catch(() => {
audio.addEventListener("play", () => {
gsap.to(playBtn, { opacity: 0, scale: 0.5, pointerEvents: "none", duration: 0.3, ease: "power2.out" });
gsap.to(pauseBtn, { opacity: 1, scale: 1, pointerEvents: "auto", duration: 0.3, ease: "power2.out" });
audio.addEventListener("pause", () => {
gsap.to(playBtn, { opacity: 1, scale: 1, pointerEvents: "auto", duration: 0.3, ease: "power2.out" });
gsap.to(pauseBtn, { opacity: 0, scale: 0.5, pointerEvents: "none", duration: 0.3, ease: "power2.out" });
audio.addEventListener("timeupdate", () => {
if (audio.duration) gsap.set(progressEl, { scaleX: audio.currentTime / audio.duration });
audio.addEventListener("ended", () => load(trackIdx + 1, true), { signal });
playBtn.addEventListener("click", (e) => {
audio.play().catch(() => {
pauseBtn.addEventListener("click", (e) => {
q("[data-prev]").addEventListener("click", (e) => {
load(trackIdx - 1, !audio.paused);
q("[data-next]").addEventListener("click", (e) => {
load(trackIdx + 1, !audio.paused);
timelineEl.addEventListener("click", (e) => {
const r = timelineEl.getBoundingClientRect();
if (audio.duration) audio.currentTime = (e.clientX - r.left) / r.width * audio.duration;
default: [THUMB_W, THUMB_H, RADIUS],
min: [MIN_W, MIN_H, RADIUS],
music: [MUSIC_W, MUSIC_H, RADIUS]
const dims = { w: THUMB_W, h: THUMB_H, r: RADIUS };
const applyDims = () => {
thumb.style.width = `${dims.w}px`;
thumb.style.height = `${dims.h}px`;
thumb.style.borderRadius = `${dims.r}px`;
const setState = (next) => {
if (next === state) return;
const showMin = next === "min";
const showMusic = next === "music";
const [w, h, r] = sizes[next];
const dur = reduced ? 0 : next === "default" ? P.morphOut : P.morphIn;
gsap.to(dims, { w, h, r, duration: dur, ease: "power3.inOut", overwrite: true, onUpdate: applyDims });
gsap.to(thumb, { backgroundColor: next === "default" ? C_REST : C_INK, duration: reduced ? 0 : BG_MORPH, ease: next === "default" ? "power3.inOut" : "power3.out", overwrite: "auto" });
gsap.to(minEl, { opacity: showMin ? 1 : 0, x: showMin ? "0%" : "20%", filter: `blur(${showMin ? 0 : 30}px)`, duration: reduced ? 0 : 0.6, ease: "power3.inOut", pointerEvents: showMin ? "auto" : "none", overwrite: "auto" });
gsap.to(musicEl, { opacity: showMusic ? 1 : 0, x: showMusic ? "0%" : "100%", filter: `blur(${showMusic ? 0 : 30}px)`, duration: reduced ? 0 : showMusic ? 0.65 : 0.6, ease: "power3.inOut", pointerEvents: showMusic ? "auto" : "none", overwrite: "auto" });
const measureScroll = () => {
sb.style.setProperty("--sb-vh", `${scroll.clientHeight}px`);
maxScroll = Math.max(0, track.scrollHeight - scroll.clientHeight);
target = clamp(target, 0, maxScroll);
const ro = new ResizeObserver(measureScroll);
sb.addEventListener("wheel", (e) => {
target = clamp(target + e.deltaY, 0, maxScroll);
}, { passive: false, signal });
sb.addEventListener("touchstart", (e) => {
touchY = e.touches[0].clientY;
}, { passive: true, signal });
sb.addEventListener("touchmove", (e) => {
const y = e.touches[0].clientY;
target = clamp(target + (touchY - y) * 1.5, 0, maxScroll);
}, { passive: false, signal });
thumb.addEventListener("pointerdown", (e) => {
if (state === "music") return;
if (e.target.closest(".sb__note")) return;
thumb.setPointerCapture?.(e.pointerId);
window.addEventListener("pointermove", (e) => {
const range = Math.max(1, bar.clientHeight - sizes[state][1]);
target = clamp(barStartTarget + (e.clientY - barStartY) / range * maxScroll, 0, maxScroll);
window.addEventListener("pointerup", () => barDrag = false, { signal });
const currentKind = () => {
const mid = cur + scroll.clientHeight / 2;
const idx = clamp(Math.floor(mid / scroll.clientHeight), 0, SECTIONS.length - 1);
return SECTIONS[idx].kind;
const note = q(".sb__note");
gsap.set(note, { y: "100%", opacity: 0, scale: 0.5, filter: "blur(20px)", pointerEvents: "none" });
const showNote = (on) => {
if (on === noteShown) return;
filter: on ? "blur(0px)" : "blur(20px)",
duration: on ? 0.6 : 0.45,
pointerEvents: on ? "auto" : "none"
let hoverLeaveTimer = null;
thumb.addEventListener("pointerenter", () => {
clearTimeout(hoverLeaveTimer);
if (state === "default") setState("min");
thumb.addEventListener("pointerleave", () => {
if (hoverLeaveTimer) clearTimeout(hoverLeaveTimer);
hoverLeaveTimer = setTimeout(() => {
if (!hovering && state === "min" && !barDrag) setState("default");
note.addEventListener("click", (e) => {
if (sectionKind === "music") setState("music");
musicEl.addEventListener("pointerleave", (e) => {
if (state !== "music") return;
if (thumb.contains(e.relatedTarget)) return;
const tick = (_t, dtMs) => {
const dt = Math.min(dtMs, 64) / 1e3 || 0.016;
cur = reduced ? target : damp(cur, target, 9, dt);
if (Math.abs(target - cur) < 0.05) cur = target;
track.style.transform = `translate3d(0, ${-cur}px, 0)`;
progress = maxScroll > 0 ? cur / maxScroll : 0;
sectionKind = currentKind();
showNote(sectionKind === "music");
const scrolling = Math.abs(vel) > 0.15 || Math.abs(target - cur) > 0.6;
if (state === "music" && !barDrag && (scrolling || sectionKind !== "music")) {
} else if (!hovering && !barDrag) {
if (scrolling || sectionKind === "music") {
if (state === "default") setState("min");
} else if (state === "min" && !restTimer) {
restTimer = setTimeout(() => {
if (!hovering && !barDrag && state === "min") setState("default");
const slimCentre = progress * (bar.clientHeight - THUMB_H) + THUMB_H / 2;
const barY = clamp(slimCentre - dims.h / 2, 0, Math.max(0, bar.clientHeight - dims.h));
thumb.style.transform = `translateY(${barY}px)`;
if (!reduced) gsap.from(thumb, { opacity: 0, duration: 0.6, ease: "power2.out", delay: 0.1 });
const set = (key, value) => {
sb.style.setProperty("--sb-accent", String(value));
gsap.ticker.remove(tick);
if (restTimer) clearTimeout(restTimer);
if (hoverLeaveTimer) clearTimeout(hoverLeaveTimer);
gsap.killTweensOf([thumb, dims, minEl, musicEl, note, playBtn, pauseBtn, progressEl, ...waveLines]);
experiment.mount(document.querySelector(".stage"));