05Scroll-direction hide-reveal toggle — when the scroll-direction value is downward the whole bar hides offscreen, and upward brings it back
The moment the scroll-direction value changes, the bar's vertical position value moves between offscreen and its resting spot.
Best forA screen that wants to show content full-width, a mobile screen for scanning a long list
VEL_HIDELERP_RATEtranslateY

<main class="stage">
<div class="sh is-demo" id="sh">
<div class="sh__phone">
<div class="sh__scrollArea" id="shScroll">
<div class="sh__row" data-n="1"></div>
<div class="sh__row" data-n="2"></div>
<div class="sh__row" data-n="3"></div>
<div class="sh__row" data-n="4"></div>
<div class="sh__row" data-n="5"></div>
<div class="sh__row" data-n="6"></div>
<div class="sh__row" data-n="7"></div>
<div class="sh__row" data-n="8"></div>
<div class="sh__row" data-n="9"></div>
</div>
<div class="sh__track" id="shTrack">
<button type="button" class="sh__tab is-active" data-idx="0">
<svg class="sh__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span class="sh__label">Home</span>
</button>
<button type="button" class="sh__tab" data-idx="1">
<svg class="sh__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 80v120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 64h-40a48 48 0 0 0-96 0H40a16 16 0 0 0-16 16v120a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-88-32a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m88 168H40V80h176Z"/></g></svg>
<span class="sh__label">Shopping</span>
</button>
<button type="button" class="sh__tab" data-idx="2">
<svg class="sh__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
<span class="sh__label">Profile</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 자기 완결 스크롤 상자(shScroll) 의 실제 scrollTop 변화량으로 속도(px/s)를 구하고,
// 비대칭 임계(VEL_HIDE·VEL_SHOW)와 비교해 목표를 정한 뒤 지수 평활(LERP_RATE)로 translateY 를 따라간다.
@include stage($stage-ink);
// .sh 는 is-demo 클래스만 들고 있는 래퍼 — 상자를 만들면 .stage 의 grid
// place-items:center 밑에서 .sh__phone 의 width:100% 가 부모(래퍼)의
// 그리드-아이템 자동 크기(고유 크기)에 걸려 0으로 무너진다. display:contents
// 로 래퍼를 트리에서만 지워 .sh__phone 이 다시 직계 그리드 아이템이 되게 한다.
.sh { display: contents; }
.sh__phone {
width: 100%; max-width: 300px; position: relative; height: 200px;
border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; background: $subject-cream;
}
.sh__scrollArea {
position: absolute; inset: 0; overflow-y: auto; padding: $sp-2; box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.sh__row {
flex: 0 0 auto; height: 34px; border-radius: $r-sm; background: rgba($subject-blue, .16);
&[data-n="3"], &[data-n="6"] { background: rgba($subject-pink, .22); }
}
.sh__track {
position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-around;
background: $subject-cream; border-top: 1px solid rgba($subject-ink, .12);
padding: $sp-1 0; will-change: transform; box-shadow: $shadow-raised;
}
.sh__tab {
flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border: none; background: transparent; cursor: pointer; padding: $sp-1 $sp-3;
color: rgba($subject-ink, .72);
}
.sh__tab.is-active { color: $subject-blue-deep; }
.sh__icon { width: 18px; height: 18px; }
.sh__label { font-size: 10px; font-weight: 700; line-height: 1; }
@media (max-width: 340px) {
// 320px 뷰포트 예산(174.4px = 200 - stage 패딩 2×12.8) 안에 들어오도록 축소 (BD9 실측)
.sh__phone { max-width: 230px; height: 160px; }
}
@media (prefers-reduced-motion: reduce) {
.sh__track { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sh {
display: contents;
}
.sh__phone {
width: 100%;
max-width: 300px;
position: relative;
height: 200px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.sh__scrollArea {
position: absolute;
inset: 0;
overflow-y: auto;
padding: 8px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
scrollbar-width: none;
}
.sh__scrollArea::-webkit-scrollbar {
display: none;
}
.sh__row {
flex: 0 0 auto;
height: 34px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.16);
}
.sh__row[data-n="3"], .sh__row[data-n="6"] {
background: rgba(255, 158, 187, 0.22);
}
.sh__track {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-around;
background: #fff7e6;
border-top: 1px solid rgba(23, 20, 26, 0.12);
padding: 4px 0;
will-change: transform;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sh__tab {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 4px 12px;
color: rgba(23, 20, 26, 0.72);
}
.sh__tab.is-active {
color: #0a4fe8;
}
.sh__icon {
width: 18px;
height: 18px;
}
.sh__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
@media (max-width: 340px) {
.sh__phone {
max-width: 230px;
height: 160px;
}
}
@media (prefers-reduced-motion: reduce) {
.sh__track {
transition: none;
}
}(function () {
var BAR_H = 58; // px, 숨을 때 내려가는 거리(바 자신의 높이)
var VEL_HIDE = 90; // px/s, 이 이상 빠르게 내려 스크롤하면 숨긴다
var VEL_SHOW = 40; // px/s, 이 이상 빠르게 올려 스크롤하면 보인다(임계가 비대칭 — 덜 민감하게 다시 나타남)
var LERP_RATE = 14; // 계산 겹: navY 목표 추종 지수 평활 상수(1/s)
var CYCLE_MS = 2200; // 계산 겹: 자동 루프 왕복 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var sh = document.getElementById('sh');
var scrollArea = document.getElementById('shScroll');
var track = document.getElementById('shTrack');
var tabs = Array.prototype.slice.call(document.querySelectorAll('.sh__tab'));
var isDemo = true;
var hiddenTarget = 0;
var navY = 0;
var lastScrollTop = 0;
var lastTs = 0;
var cycleStart = 0;
var maxScroll = 1;
function measure() {
maxScroll = Math.max(1, scrollArea.scrollHeight - scrollArea.clientHeight);
}
function onScrollFrame(scrollTop, dtSec) {
// 계산 겹: 실제 scrollTop 변화량으로 속도(px/s)를 구해 비대칭 임계와 비교한다
if (dtSec <= 0) return;
var delta = scrollTop - lastScrollTop;
lastScrollTop = scrollTop;
var vel = delta / dtSec;
if (scrollTop <= 2) {
hiddenTarget = 0;
} else if (vel > VEL_HIDE) {
hiddenTarget = 1;
} else if (vel < -VEL_SHOW) {
hiddenTarget = 0;
}
}
function render(dtSec) {
navY += (hiddenTarget * BAR_H - navY) * (1 - Math.exp(-LERP_RATE * dtSec));
track.style.transform = 'translateY(' + navY.toFixed(2) + 'px)';
}
function human() {
if (isDemo) { sh.classList.remove('is-demo'); isDemo = false; }
}
['wheel', 'touchstart', 'pointerdown'].forEach(function (evt) {
scrollArea.addEventListener(evt, human, { passive: true });
});
tabs.forEach(function (t) {
t.addEventListener('click', function () {
human();
tabs.forEach(function (o) { o.classList.remove('is-active'); });
t.classList.add('is-active');
});
});
function triangle(t) {
// 0..1..0 삼각파 — 내려갔다 다시 올라오는 스크롤을 흉내낸다
var x = (t % CYCLE_MS) / CYCLE_MS;
return x < 0.5 ? x * 2 : 2 - x * 2;
}
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dtSec = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
var st = triangle(ts - cycleStart) * maxScroll;
scrollArea.scrollTop = st;
}
onScrollFrame(scrollArea.scrollTop, dtSec);
render(dtSec);
requestAnimationFrame(loop);
}
measure();
window.addEventListener('resize', measure);
render(0);
if (!reduce) {
requestAnimationFrame(loop);
}
})();// ScrollHideReveal — vanilla 판(run/367/demo/05-bottom-nav-bar-scroll-hide-reveal/index.html) 의 React 포트.
// 자기 완결 스크롤 상자의 실제 scrollTop 변화량으로 속도(px/s)를 구하고, 비대칭 임계
// (VEL_HIDE·VEL_SHOW)와 비교해 목표를 정한 뒤 지수 평활(LERP_RATE)로 translateY 를 따라간다.
import { useEffect, useRef } from "react";
import styles from "./ScrollHideReveal.module.scss";
type Box<T> = { current: T };
const BAR_H = 58; // px, 숨을 때 내려가는 거리(바 자신의 높이)
const VEL_HIDE = 90; // px/s, 이 이상 빠르게 내려 스크롤하면 숨긴다
const VEL_SHOW = 40; // px/s, 이 이상 빠르게 올려 스크롤하면 보인다(임계가 비대칭 — 덜 민감하게 다시 나타남)
const LERP_RATE = 14; // 계산 겹: navY 목표 추종 지수 평활 상수(1/s)
const CYCLE_MS = 2200; // 계산 겹: 자동 루프 왕복 주기
const TABS = [
{ label: "홈", d: "M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120|.2#m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" },
{ label: "쇼핑", d: "M224 80v120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8|.2#M216 64h-40a48 48 0 0 0-96 0H40a16 16 0 0 0-16 16v120a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-88-32a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m88 168H40V80h176Z" },
{ label: "프로필", d: "M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64|.2#M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" },
];
function Icon({ spec }: { spec: string }) {
const [a, b] = spec.split("#");
const [pathA, opacity] = a.split("|");
return (
<svg className={styles.icon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d={pathA} opacity={opacity} />
<path d={b} />
</g>
</svg>
);
}
function triangle(t: number): number {
const x = (t % CYCLE_MS) / CYCLE_MS;
return x < 0.5 ? x * 2 : 2 - x * 2;
}
export default function ScrollHideReveal() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const scrollRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tabRefs = useRef([null, null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
let hiddenTarget = 0;
let navY = 0;
let lastScrollTop = 0;
let lastTs = 0, cycleStart = 0;
let maxScroll = 1;
function measure() {
const el = scrollRef.current;
if (!el) return;
maxScroll = Math.max(1, el.scrollHeight - el.clientHeight);
}
function onScrollFrame(scrollTop: number, dtSec: number) {
if (dtSec <= 0) return;
const delta = scrollTop - lastScrollTop;
lastScrollTop = scrollTop;
const vel = delta / dtSec;
if (scrollTop <= 2) {
hiddenTarget = 0;
} else if (vel > VEL_HIDE) {
hiddenTarget = 1;
} else if (vel < -VEL_SHOW) {
hiddenTarget = 0;
}
}
function render(dtSec: number) {
const track = trackRef.current;
if (!track) return;
navY += (hiddenTarget * BAR_H - navY) * (1 - Math.exp(-LERP_RATE * dtSec));
track.style.transform = "translateY(" + navY.toFixed(2) + "px)";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
const scrollEl = scrollRef.current;
["wheel", "touchstart", "pointerdown"].forEach((evt) => {
scrollEl?.addEventListener(evt, human, { passive: true });
});
const tabHandlers = tabRefs.current.map((_, i) => () => {
human();
tabRefs.current.forEach((o) => o?.classList.remove(styles.isActive));
tabRefs.current[i]?.classList.add(styles.isActive);
});
tabRefs.current.forEach((t, i) => t?.addEventListener("click", tabHandlers[i]));
function loop(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dtSec = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
const el = scrollRef.current;
if (isDemo && el) {
const st = triangle(ts - cycleStart) * maxScroll;
el.scrollTop = st;
}
if (el) onScrollFrame(el.scrollTop, dtSec);
render(dtSec);
rafRef.current = requestAnimationFrame(loop);
}
function onResize() { measure(); }
measure();
window.addEventListener("resize", onResize);
render(0);
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
["wheel", "touchstart", "pointerdown"].forEach((evt) => {
scrollEl?.removeEventListener(evt, human);
});
tabRefs.current.forEach((t, i) => t?.removeEventListener("click", tabHandlers[i]));
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + " " + styles.isDemo} ref={rootRef}>
<div className={styles.phone}>
<div className={styles.scrollArea} ref={scrollRef}>
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row + " " + styles.rowPink} />
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row + " " + styles.rowPink} />
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
</div>
<div className={styles.track} ref={trackRef}>
{TABS.map((t, i) => (
<button key={t.label} type="button"
className={styles.tab + (i === 0 ? " " + styles.isActive : "")}
aria-current={i === 0 ? "true" : undefined}
ref={(el: HTMLButtonElement | null) => { tabRefs.current[i] = el; }}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
</div>
</div>
</main>
);
}05 Scroll-direction hide-reveal toggle — when the scroll-direction value is downward the whole bar hides offscreen, and upward brings it back — Measured area 39.533% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Bottom Navigation Bar: 9 Transition Patterns
The post this demo lives in
CSS Bottom Navigation Bar: 9 Transition PatternsCSS Bottom Navigation Bar: One of nine slides the highlight to the new tab as selection changes, one grows a badge brief See all demos in this post
If you like this effect - similar demos
Transitions07 Checkout bar lift — a bottom bar whose shadow and height values grow with scroll positionCart Drawer UI Patterns: 9 Motion Techniques
Transitions03 Selection-value highlight toggle — when a row's selection value turns on, its highlight-color value deepens, and it returns to the original value when offCollapsible Table Row: 9 UI Patterns
Transitions07 Sticky apply-bar reveal — a bottom bar repositioned by a selection-state value9 Filter UI Patterns (Panel Slide to Chip Remove)