04Bottom-docked Mini Bar That Collapses From a Scroll Value
The transition point between the full player and a bottom-docked mini bar is computed from the scroll-position value, so crossing it shrinks the album art and controls into a small pill fixed at the bottom.
Best forSticky footer on long articles and playlist pages
MORPH_RANGE_PXrecomputeprogrammatic

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-play" viewBox="0 0 256 256"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="mb is-demo" id="mb">
<p class="mb__hint"><span>Scroll down and the value is computed to collapse the player into the bottom mini bar.</span></p>
<div class="mb__viewport" id="viewport">
<div class="mb__scroll" id="scroll" tabindex="0" role="group" aria-label="Scroll demo area">
<div class="mb__full" id="full">
<div class="mb__art"></div>
<div class="mb__meta">
<div class="mb__title">Untitled Session</div>
<div class="mb__sub">Track 01</div>
</div>
</div>
<div class="mb__filler" aria-hidden="true">
<div class="mb__line"></div>
<div class="mb__line"></div>
<div class="mb__line"></div>
<div class="mb__line"></div>
<div class="mb__line"></div>
<div class="mb__line"></div>
</div>
</div>
<button class="mb__mini" id="mini" type="button" aria-label="Back to full player">
<div class="mb__mini-art"></div>
<div class="mb__mini-title">Untitled Session</div>
<svg class="mb__mini-ico" aria-hidden="true"><use href="#ico-play"></use></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(스크롤 값 → 전환 진행률)은 index.html 의 recompute() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.mb {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(400px, 92vw);
}
.mb__hint {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.mb__viewport {
position: relative;
width: 100%;
height: 120px;
border-radius: $r-card;
overflow: hidden;
box-shadow: 0 0 0 1px rgba($subject-ink, .12);
background: $subject-cream;
}
.mb__scroll {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.mb__scroll:focus-visible {
outline: 2px solid $color;
outline-offset: -2px;
}
.mb__full {
display: flex;
align-items: center;
gap: $sp-4;
padding: $sp-5;
}
.mb__art {
flex: 0 0 auto;
width: 52px;
height: 52px;
border-radius: $r-control;
background: $color;
}
.mb__meta { min-width: 0; }
.mb__title {
color: $subject-ink;
font-size: 13px;
font-weight: 700;
}
.mb__sub {
color: rgba($subject-ink, .6);
font-size: 11px;
font-weight: 500;
margin-top: $sp-1;
}
.mb__filler {
padding: 0 $sp-5 $sp-6;
display: flex;
flex-direction: column;
gap: $sp-4;
}
.mb__line {
height: 12px;
border-radius: $r-xs;
background: rgba($subject-ink, .08);
}
.mb__mini {
position: absolute;
left: $sp-3;
right: $sp-3;
bottom: $sp-3;
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-3;
border: none;
border-radius: $r-pill;
background: $subject-ink;
box-shadow: 0 8px 20px rgba($subject-ink, .28);
cursor: pointer;
text-align: left;
opacity: 0;
transform: translateY(16px) scale(.94);
transform-origin: bottom center; // 미니 바는 화면 아래에 붙는 것이 기준점 — 중심 기준이 아니다
}
.mb__mini:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.mb__mini-art {
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: $r-sm;
background: $color;
}
.mb__mini-title {
flex: 1 1 auto;
min-width: 0;
color: $subject-cream;
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb__mini-ico {
flex: 0 0 auto;
width: 14px;
height: 14px;
color: $subject-cream;
}
@media (max-width: 340px) {
.mb__viewport { height: 108px; }
.mb__art { width: 42px; height: 42px; }
.mb__full { padding: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.mb__mini { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mb {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(400px, 92vw);
}
.mb__hint {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: center;
}
.mb__viewport {
position: relative;
width: 100%;
height: 120px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(23, 20, 26, 0.12);
background: #fff7e6;
}
.mb__scroll {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
.mb__scroll:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.mb__full {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
}
.mb__art {
flex: 0 0 auto;
width: 52px;
height: 52px;
border-radius: 12px;
background: #2f6df6;
}
.mb__meta {
min-width: 0;
}
.mb__title {
color: #17141a;
font-size: 13px;
font-weight: 700;
}
.mb__sub {
color: rgba(23, 20, 26, 0.6);
font-size: 11px;
font-weight: 500;
margin-top: 4px;
}
.mb__filler {
padding: 0 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.mb__line {
height: 12px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.08);
}
.mb__mini {
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: none;
border-radius: 999px;
background: #17141a;
box-shadow: 0 8px 20px rgba(23, 20, 26, 0.28);
cursor: pointer;
text-align: left;
opacity: 0;
transform: translateY(16px) scale(0.94);
transform-origin: bottom center;
}
.mb__mini:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mb__mini-art {
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 8px;
background: #2f6df6;
}
.mb__mini-title {
flex: 1 1 auto;
min-width: 0;
color: #fff7e6;
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb__mini-ico {
flex: 0 0 auto;
width: 14px;
height: 14px;
color: #fff7e6;
}
@media (max-width: 340px) {
.mb__viewport {
height: 108px;
}
.mb__art {
width: 42px;
height: 42px;
}
.mb__full {
padding: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mb__mini {
transition: none;
}
}(function () {
var root = document.getElementById('mb');
var viewport = document.getElementById('viewport');
var scrollEl = document.getElementById('scroll');
var full = document.getElementById('full');
var mini = document.getElementById('mini');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
var MORPH_RANGE_PX = 56; // 완전 전환까지 걸리는 스크롤 거리
var SCROLL_STEP_PX = 48; // 방향키 한 번의 스크롤 단위
var programmatic = false; // 시연 루프가 scrollTop 을 밀 때 뜨는 scroll 이벤트를 실사용 스크롤과 구분한다
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
function recompute() {
var threshold = full.offsetHeight; // 실제 카드 높이를 기준으로 전환 시점을 계산한다
var y = scrollEl.scrollTop;
var p = Math.min(1, Math.max(0, (y - threshold) / MORPH_RANGE_PX));
mini.style.opacity = String(p);
mini.style.transform = 'translateY(' + ((1 - p) * 16) + 'px) scale(' + (0.94 + p * 0.06) + ')';
mini.style.pointerEvents = p > 0.5 ? 'auto' : 'none';
}
function onScroll() {
if (programmatic) { programmatic = false; recompute(); return; }
human(); recompute();
}
function onScrollKeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
human();
scrollEl.scrollTop += (e.key === 'ArrowDown' ? SCROLL_STEP_PX : -SCROLL_STEP_PX);
recompute();
}
}
function onMiniClick() {
human();
scrollEl.scrollTop = 0;
recompute();
}
var driveStart = 0;
function triangle(frac) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var max = scrollEl.scrollHeight - scrollEl.clientHeight;
programmatic = true;
scrollEl.scrollTop = triangle(frac) * max;
recompute();
}
requestAnimationFrame(driveTick);
}
window.addEventListener('resize', recompute);
if (reduce) {
root.classList.add('is-still');
recompute();
scrollEl.addEventListener('scroll', onScroll);
scrollEl.addEventListener('keydown', onScrollKeydown);
mini.addEventListener('click', onMiniClick);
} else {
scrollEl.addEventListener('scroll', onScroll);
scrollEl.addEventListener('keydown', onScrollKeydown);
mini.addEventListener('click', onMiniClick);
recompute();
requestAnimationFrame(driveTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./MiniSticky.module.scss";
export default function MiniSticky() {
const rootRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const fullRef = useRef<HTMLDivElement | null>(null);
const miniRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const scrollEl = scrollRef.current;
const full = fullRef.current;
const mini = miniRef.current;
if (!root || !scrollEl || !full || !mini) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
const MORPH_RANGE_PX = 56; // 완전 전환까지 걸리는 스크롤 거리
const SCROLL_STEP_PX = 48; // 방향키 한 번의 스크롤 단위
let programmatic = false; // 시연 루프가 scrollTop 을 밀 때 뜨는 scroll 이벤트를 실사용 스크롤과 구분한다
function human() { if (isDemo) { root!.classList.remove(styles["is-demo"]); isDemo = false; } }
function recompute() {
const threshold = full!.offsetHeight; // 실제 카드 높이를 기준으로 전환 시점을 계산한다
const y = scrollEl!.scrollTop;
const p = Math.min(1, Math.max(0, (y - threshold) / MORPH_RANGE_PX));
mini!.style.opacity = String(p);
mini!.style.transform = "translateY(" + (1 - p) * 16 + "px) scale(" + (0.94 + p * 0.06) + ")";
mini!.style.pointerEvents = p > 0.5 ? "auto" : "none";
}
function onScroll() {
if (programmatic) { programmatic = false; recompute(); return; }
human(); recompute();
}
function onScrollKeydown(e: KeyboardEvent) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
human();
scrollEl!.scrollTop += e.key === "ArrowDown" ? SCROLL_STEP_PX : -SCROLL_STEP_PX;
recompute();
}
}
function onMiniClick() {
human();
scrollEl!.scrollTop = 0;
recompute();
}
let driveStart = 0;
function triangle(frac: number) { return frac < 0.5 ? frac * 2 : 2 - frac * 2; }
let rafDrive = 0;
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const DRIVE_PERIOD_MS = 2000; // 렌더 캐처 창(설정 초=2.0)과 같은 주기 — 시연 전용
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const max = scrollEl!.scrollHeight - scrollEl!.clientHeight;
programmatic = true;
scrollEl!.scrollTop = triangle(frac) * max;
recompute();
}
rafDrive = requestAnimationFrame(driveTick);
}
window.addEventListener("resize", recompute);
if (reduce) {
root.classList.add(styles["is-still"]);
recompute();
scrollEl.addEventListener("scroll", onScroll);
scrollEl.addEventListener("keydown", onScrollKeydown);
mini.addEventListener("click", onMiniClick);
} else {
scrollEl.addEventListener("scroll", onScroll);
scrollEl.addEventListener("keydown", onScrollKeydown);
mini.addEventListener("click", onMiniClick);
recompute();
rafDrive = requestAnimationFrame(driveTick);
}
return () => {
window.removeEventListener("resize", recompute);
cancelAnimationFrame(rafDrive);
scrollEl.removeEventListener("scroll", onScroll);
scrollEl.removeEventListener("keydown", onScrollKeydown);
mini.removeEventListener("click", onMiniClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.mb + " " + styles["is-demo"]} ref={rootRef}>
<p className={styles.hint}><span>아래로 스크롤하면 값이 계산되어 플레이어가 하단 미니 바로 접힙니다</span></p>
<div className={styles.viewport}>
<div className={styles.scroll} ref={scrollRef} tabIndex={0} role="group" aria-label="스크롤 데모 영역">
<div className={styles.full} ref={fullRef}>
<div className={styles.art} />
<div className={styles.meta}>
<div className={styles.title}>Untitled Session</div>
<div className={styles.sub}>Track 01</div>
</div>
</div>
<div className={styles.filler} aria-hidden="true">
<div className={styles.line} />
<div className={styles.line} />
<div className={styles.line} />
<div className={styles.line} />
<div className={styles.line} />
<div className={styles.line} />
</div>
</div>
<button className={styles.mini} ref={miniRef} type="button" aria-label="전체 플레이어로 돌아가기">
<div className={styles.miniArt} />
<div className={styles.miniTitle}>Untitled Session</div>
<svg className={styles.miniIco} aria-hidden="true"><use href="#ico-play" /></svg>
</button>
</div>
</div>
</main>
);
}04 Bottom-docked Mini Bar That Collapses From a Scroll Value — Measured area 30.593% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Custom Audio Player CSS — 9 Computed UI Patterns
The post this demo lives in
Custom Audio Player CSS — 9 Computed UI PatternsAmong nine custom audio player css demos, one precomputes bar heights from sine waves, one decays angular velocity so th See all demos in this post
If you like this effect - similar demos
Creative UI06 Circular gauge bar — an angle that fills in proportion to a valueCSS Radial Menu: 9 Circular Control Patterns
Creative UI09 Top Glass Navbar Scene That Responds to the Scroll ValueLiquid Glass Effect CSS: 9 Refraction Patterns
Creative UI01 Bar spectrumAudio Visualizer JavaScript: 9 Parts, No Library