04스크롤 값으로 접히는 하단 고정 미니 바
페이지 스크롤 위치 값을 기준으로 전체 플레이어와 하단 고정 미니 바 사이의 전환 시점을 계산해, 그 지점을 지나면 앨범 아트와 컨트롤이 작은 칩 형태로 줄어들며 화면 아래에 붙는다.
쓰임긴 기사·플레이리스트 페이지 하단 고정
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>아래로 스크롤하면 값이 계산되어 플레이어가 하단 미니 바로 접힙니다</span></p>
<div class="mb__viewport" id="viewport">
<div class="mb__scroll" id="scroll" tabindex="0" role="group" aria-label="스크롤 데모 영역">
<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="전체 플레이어로 돌아가기">
<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 스크롤 값으로 접히는 하단 고정 미니 바 — 측정 누적면적 30.593% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UI
이 데모가 속한 글
css 오디오 플레이어 디자인 9종 — 계산으로 움직이는 플레이어 UIcss 오디오 플레이어 디자인 아홉 가지 가운데 하나는 여러 사인 곡선을 겹쳐 막대 높이를 미리 계산해 이퀄라이저처럼 흔들고, 하나는 각속도 값을 서서히 줄여 앨범판이 관성으로 멈추게 하며, 하나는 타임라인 값으로 이 글의 데모 전부 보기


