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 オリジナル · SCSS と React は zip に同梱 · 音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI
このデモが属する記事
音楽 プレイヤー デザイン 9種 — 計算で動くカスタムUI音楽 プレイヤー デザイン 9種のうち、1つは複数のサイン波を重ねてバーの高さを先に計算しイコライザーのように揺らし、1つは角速度の値を徐々に落としてレコード盤を慣性で止め、1つはタイムライン値で歌詞のハイライト行を探して中央へスクロールし この記事のデモをすべて見る


