모바일 하단 탭바 9종 — 상태 값으로 바뀌는 아홉 가지 전환
모바일 하단 탭바는 화면 아래 고정된 채 상태 값 하나로 다시 계산해 그리는 자리인데, 아홉 가지 중 하나는 선택 값이 바뀌면 강조 표시의 위치와 너비 값이 새 자리로 함께 옮겨가고, 하나는 알림 수량 값이 올라가면 그 표시의 크기 값이 잠깐 커졌다가 가라앉으며, 또 하나는 스크롤
아래 아홉 개는 전부 앱 이름이나 브랜드 요소 없이 활성 인덱스·배지 수·스크롤 방향·안전영역 인셋 같은 일반적인 상태 값 하나가 바뀌는 순간을 다룬 것입니다. 실제 서비스 화면을 베낀 게 아니라, 앱 하단 탭바가 값 하나에 반응해 자리·크기·여백을 다시 계산하는 방식 자체를 아홉 갈래로 나눠 본 것에 가깝습니다.
01 bottom-nav-bar-indicator-slide-morph
03 bottom-nav-bar-badge-count-bump
04 bottom-nav-bar-center-lift-scale
05 bottom-nav-bar-scroll-hide-reveal
06 bottom-nav-bar-longpress-reveal
07 bottom-nav-bar-swipe-follow
08 bottom-nav-bar-safe-area-inset-resize
09 bottom-nav-bar-reduced-motion-instant
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 활성 표시 이동 전환 — 선택된 탭이 바뀌면 강조 표시의 위치와 너비 값이 이전 자리에서 새 자리로 함께 바뀌는 방식
- 02 아이콘 채움 전환 — 선택된 탭이 바뀌면 그 자리 아이콘의 채움 정도 값이 낮은 상태에서 가득 찬 상태로 바뀌는 방식
- 03 배지 수량 튐 전환 — 알림 수량 값이 올라갈 때마다 그 표시의 크기 값이 한 번 커졌다가 되돌아오는 방식
- 04 중앙 융기 전환 — 가운데 탭의 선택 값이 켜지면 그 자리만 배경보다 높이 값이 커지며 위로 떠오르는 방식
- 05 스크롤 방향 숨김 전환 — 스크롤 방향 값이 아래쪽이면 바 전체가 화면 밖으로 숨고 위쪽이면 다시 나타나는 방식
- 06 길게 눌러 펼치기 전환 — 누름 지속 시간 값이 기준을 넘으면 그 자리 위로 펼침 정도 값이 커지며 추가 자리가 드러나는 방식
- 07 스와이프 추종 전환 — 화면을 옆으로 미는 거리 값이 커지는 동안 강조 표시의 위치 값이 그 비율만큼 옆 탭 쪽으로 따라가는 방식
- 08 여백 값 대응 전환 — 화면 아래쪽 여백 값이 커지면 바의 전체 높이와 안쪽 여백 값이 그 차이만큼 함께 늘어나는 방식
- 09 모션 감소 즉시 전환 — 모션 감소 설정이 켜지면 앞선 여덟 방식의 전환이 전부 중간 단계 없이 목표 값으로 바뀌는 방식
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
아홉 개 전부 자동 재생 중이던 .is-demo 클래스는 사람이 클릭·키보드(Enter·Space)·포인터 입력을 하는 순간 human() 함수에서 지워지고, 그 뒤로는 실제 입력 이벤트(click·resize·keydown·pointerdown·pointermove·pointerup·pointercancel)에 반응하는 requestAnimationFrame 루프만 값을 그립니다. 09를 뺀 여덟 개는 자동 클릭·자동 순번 같은 정해 둔 순서로만 재생을 시작하고, 그 안에 무작위 값은 하나도 없습니다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 활성 표시 이동 전환 | 6.096 | 32.0 | 17/23 |
| 02 아이콘 채움 전환 | 1.267 | 56.3 | 14/23 |
| 03 배지 수량 튐 전환 | 0.217 | 97.3 | 20/23 |
| 04 중앙 융기 전환 | 2.599 | 116.0 | 12/23 |
| 05 스크롤 방향 숨김 전환 | 39.533 | 30.5 | 23/23 |
| 06 길게 눌러 펼치기 전환 | 3.297 | 74.2 | 16/23 |
| 07 스와이프 추종 전환 | 5.814 | 141.6 | 10/23 |
| 08 여백 값 대응 전환 | 22.792 | 89.8 | 17/23 |
| 09 모션 감소 즉시 전환 | 5.072 | 133.8 | 12/23 |
05가 39.533%로 아홉 개 중 누적면적이 가장 큰 건 정상입니다 — 05는 바 전체가 화면 밖과 원래 자리 사이를 오가는 구조라 다른 여덟 개(자리 하나의 요소만 바뀌는 구조)보다 매 프레임 바뀌는 화면 영역 자체가 큽니다. 반대로 03은 0.217%로 가장 작은데, 배지 하나의 스케일 값만 짧게 튀는 구성이라 원래 면적이 작은 게 맞습니다.
목표 값에서 가속도를 계산해 당겨가는 값 — 01·03·04·07
01·03·04·07은 전부 acc = (target - pos) * SPRING_K - vel * SPRING_C 형태로 목표까지의 거리에서 가속도를 구하고, 그 가속도로 속도(vel)를, 속도로 위치(pos)를 매 프레임 다시 쌓아 올립니다. 01은 SPRING_K(210)·SPRING_C(19)로 인디케이터의 x좌표와 너비를 함께 당기고, 03은 SPRING_K(260)·SPRING_C(22)로 숫자 롤(strip)의 세로 위치만 당깁니다. 04는 SPRING_K(190)·SPRING_C(18)로 중앙 탭의 들림 정도(liftT)를 당기고, 07은 SPRING_K(240)·SPRING_C(20)로 손을 뗀 뒤 인디케이터가 가장 가까운 탭 좌표로 정착하는 값을 당깁니다.
목표 값과의 차이를 지수 평활로 서서히 좁히는 값 — 02·05·06·08
02·05·06·08은 가속도 계산 없이 값 += (목표 - 값) * (1 - Math.exp(-RATE * dt)) 형태로 차이를 서서히 좁힙니다. 02는 FILL_RATE(0.012)로 아이콘 채움 정도를, 05는 LERP_RATE(14)로 트랙의 세로 위치(navY)를 좁히고, 06은 MENU_RATE(20)로 펼침 메뉴의 등장 비율을, 08은 LERP_RATE(10)로 인셋 값(insetPx)을 좁힙니다. 같은 형태의 계산이지만 05와 08의 LERP_RATE는 서로 다른 항목의 서로 다른 값이라, 하나를 바꿔도 다른 하나에는 영향이 없습니다.
감쇠 코사인 진동으로 목표를 지나쳤다 되돌아오는 값 — 02·03
02와 03은 위 두 계산과 별도로 Math.exp(-ZETA * (t / DUR)) * (1 - Math.cos(FREQ * t)) 형태의 펄스를 하나 더 가지고 있습니다. 02는 PULSE_ZETA(4.4)로 방금 선택된 아이콘의 스케일을 한 번 튀웠다가 가라앉히고, 03은 BUMP_ZETA(4.8)로 수량이 오른 배지의 스케일을 튀웁니다. 둘 다 지수 평활이나 스프링과는 다른, 순간적인 강조 한 번을 위한 별도 계산입니다.
경과 시간을 정해둔 구간 길이로 나눈 비율(선형)로 값을 만드는 값 — 06·07
06과 07은 위의 스프링·평활 계산과 별개로 선형 비율 계산도 하나씩 갖습니다. 06은 누르고 있는 시간(heldMs)을 HOLD_MS로 나눈 progress 값으로 링의 stroke-dashoffset을 채우고, 07은 데모 자동재생에서 경과 시간(demoElapsed)을 DRAG_MS로 나눈 비율로 손가락이 미는 거리를 대신 시뮬레이션합니다. 06의 HOLD_MS(650ms)는 실제로 눌러야 하는 기준 시간이고, 04에도 이름이 같은 HOLD_MS(900ms)가 있지만 그건 자동 루프가 중앙 융기 상태를 얼마나 유지할지 정하는 데모 재생 값일 뿐 눌러야 하는 시간과는 무관합니다.
실측 좌표와 각도로 자리를 계산하는 값 — 01·06·07
01·06·07은 값 하나만이 아니라 화면에서 실측한 좌표·각도도 계산에 씁니다. 01과 07은 각각 measure()가 getBoundingClientRect()로 탭들의 실제 좌표를 재서 인디케이터의 목표 위치·정착 위치를 정하고, 06은 FAN_ANGLES([-150, -90, -30])라는 세 각도를 사인·코사인으로 좌표로 바꿔 부채꼴 액션 버튼 세 개를 펼칩니다. 셋 다 짐작한 px가 아니라 실제 화면에서 잰 값이나 정해둔 각도를 계산에 그대로 씁니다.
09는 위 다섯 갈래와 성격이 다릅니다. 앞선 여덟 개 각각의 계산을 스스로 가진 게 아니라, 왼쪽 판(A)은 01처럼 값이 서서히 바뀌는 일반 판이고 오른쪽 판(B)은 계산 없이 목표 값을 즉시 대입하는 판이라, 계산이 있는 전환과 없는 전환을 한 화면에 나란히 두고 대조해 보여주는 역할만 합니다.
패턴별 코드
01활성 표시 이동 전환 — 선택된 탭이 바뀌면 강조 표시의 위치와 너비 값이 이전 자리에서 새 자리로 함께 바뀌는 방식
탭을 클릭해 activeIdx 값이 바뀌면 springTo()가 SPRING_K(210)·SPRING_C(19) 스프링 계산으로 인디케이터의 목표 좌표를 따라가게 해, translateX 값과 width 값이 이전 탭 자리에서 새 탭 자리로 함께 옮겨갑니다. 탭 폭은 짐작 px가 아니라 measure()가 getBoundingClientRect()로 실측한 값이라 resize 리스너가 창 크기 변화마다 다시 잽니다. 여러 탭을 오가며 지금 어디 있는지 강조하는 탭 내비게이션에 씁니다.
function springTo(pos, vel, target, dt) {
var acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
return [pos, vel];
}
function render() {
indicator.style.transform = 'translateX(' + posX.toFixed(2) + 'px)';
indicator.style.width = posW.toFixed(2) + 'px';
}

<main class="stage">
<div class="bn is-demo" id="bn">
<div class="bn__phone">
<div class="bn__track" id="bnTrack">
<span class="bn__indicator" id="bnIndicator"></span>
<button type="button" class="bn__tab is-active" data-idx="0" id="bnTab0" aria-current="true">
<svg class="bn__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="bn__label">홈</span>
</button>
<button type="button" class="bn__tab" data-idx="1" id="bnTab1">
<svg class="bn__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
<span class="bn__label">검색</span>
</button>
<button type="button" class="bn__tab" data-idx="2" id="bnTab2">
<svg class="bn__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="bn__label">쇼핑</span>
</button>
<button type="button" class="bn__tab" data-idx="3" id="bnTab3">
<svg class="bn__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="bn__label">프로필</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html measure() 가 Playwright/브라우저 실측(getBoundingClientRect)으로 탭 폭·좌표를
// 얻고, springTo() 가 감쇠 조화진동자로 인디케이터 위치·너비를 매 프레임 목표로 당긴다(오버슛 포함).
@include stage($stage-ink);
.bn__phone { width: 100%; max-width: 300px; }
.bn__track {
position: relative; display: flex; background: $subject-cream; border-radius: $r-pill;
box-shadow: $shadow-raised; padding: $sp-1; box-sizing: border-box;
}
.bn__indicator {
position: absolute; top: $sp-1; left: 0; height: calc(100% - #{$sp-1} * 2);
border-radius: $r-pill; background: rgba($subject-blue, .16);
will-change: transform, width;
}
.bn__tab {
position: relative; flex: 0 0 auto; display: flex; flex-direction: column; align-items: center;
gap: $sp-1; border: none; background: transparent; cursor: pointer;
padding: $sp-2 $sp-3; color: rgba($subject-ink, .72); z-index: 1;
}
.bn__icon { width: 20px; height: 20px; }
.bn__label { font-size: 10px; font-weight: 700; line-height: 1; }
.bn__tab.is-active { color: $subject-blue-deep; }
@media (max-width: 340px) {
.bn__phone { max-width: 230px; }
.bn__tab { padding: $sp-1 $sp-2; }
.bn__icon { width: 18px; height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.bn__indicator { 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);
}
.bn__phone {
width: 100%;
max-width: 300px;
}
.bn__track {
position: relative;
display: flex;
background: #fff7e6;
border-radius: 999px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
box-sizing: border-box;
}
.bn__indicator {
position: absolute;
top: 4px;
left: 0;
height: calc(100% - 4px * 2);
border-radius: 999px;
background: rgba(47, 109, 246, 0.16);
will-change: transform, width;
}
.bn__tab {
position: relative;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 8px 12px;
color: rgba(23, 20, 26, 0.72);
z-index: 1;
}
.bn__icon {
width: 20px;
height: 20px;
}
.bn__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.bn__tab.is-active {
color: #0a4fe8;
}
@media (max-width: 340px) {
.bn__phone {
max-width: 230px;
}
.bn__tab {
padding: 4px 8px;
}
.bn__icon {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.bn__indicator {
transition: none;
}
}(function () {
var HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
var SPRING_K = 210; // 계산 겹: 스프링 상수(위치·너비 공용) — 값이 클수록 빨리 목표에 붙는다
var SPRING_C = 19; // 계산 겹: 감쇠 상수 — 낮을수록 목표를 지나쳤다 되돌아오는 정도가 크다
var TAB_COUNT = 4;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var bn = document.getElementById('bn');
var track = document.getElementById('bnTrack');
var indicator = document.getElementById('bnIndicator');
var tabs = [0, 1, 2, 3].map(function (i) { return document.getElementById('bnTab' + i); });
var isDemo = true;
var activeIdx = 0;
var rects = [];
var posX = 0, velX = 0, posW = 0, velW = 0;
var lastTs = 0;
var cycleStart = 0;
function measure() {
// 계산 겹: 탭 폭·위치를 실측해 인디케이터 목표 좌표를 만든다 — 짐작 px 를 쓰지 않는다
var tRect = track.getBoundingClientRect();
rects = tabs.map(function (t) {
var r = t.getBoundingClientRect();
return { x: r.left - tRect.left, w: r.width };
});
if (!posW) { posX = rects[0].x; posW = rects[0].w; }
}
function setActive(idx) {
activeIdx = idx;
tabs.forEach(function (t, i) {
t.classList.toggle('is-active', i === idx);
t.setAttribute('aria-current', i === idx ? 'true' : 'false');
});
}
function springTo(pos, vel, target, dt) {
var acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
return [pos, vel];
}
function render() {
indicator.style.transform = 'translateX(' + posX.toFixed(2) + 'px)';
indicator.style.width = posW.toFixed(2) + 'px';
}
function human() {
if (isDemo) { bn.classList.remove('is-demo'); isDemo = false; }
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { human(); setActive(i); });
});
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
var elapsed = ts - cycleStart;
var idx = Math.floor(elapsed / HOLD_MS) % TAB_COUNT;
if (idx !== activeIdx) setActive(idx);
}
var target = rects[activeIdx] || rects[0] || { x: posX, w: posW };
var rx = springTo(posX, velX, target.x, dt);
posX = rx[0]; velX = rx[1];
var rw = springTo(posW, velW, target.w, dt);
posW = rw[0]; velW = rw[1];
render();
requestAnimationFrame(loop);
}
window.addEventListener('resize', measure);
measure();
render();
if (!reduce) {
requestAnimationFrame(loop);
}
})();// IndicatorSlideMorph — vanilla 판(run/367/demo/01-bottom-nav-bar-indicator-slide-morph/index.html) 의 React 포트.
// 탭 폭·좌표를 getBoundingClientRect 로 실측하고, springTo() 감쇠 조화진동자가 인디케이터의
// x·width 를 매 프레임 목표로 당긴다(오버슛 포함) — CSS transition 이 아니라 rAF 계산이 위치를 만든다.
import { useEffect, useRef } from "react";
import styles from "./IndicatorSlideMorph.module.scss";
type Box<T> = { current: T };
type ClickEvt = { preventDefault(): void };
const HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
const SPRING_K = 210; // 계산 겹: 스프링 상수(위치·너비 공용)
const SPRING_C = 19; // 계산 겹: 감쇠 상수
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: "M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80|.2#m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" },
{ 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>
);
}
export default function IndicatorSlideMorph() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const tabRefs = useRef([null, 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 activeIdx = 0;
let rects: { x: number; w: number }[] = [];
let posX = 0, velX = 0, posW = 0, velW = 0;
let lastTs = 0, cycleStart = 0;
function measure() {
const track = trackRef.current;
if (!track) return;
const tRect = track.getBoundingClientRect();
rects = tabRefs.current.map((t) => {
if (!t) return { x: 0, w: 0 };
const r = t.getBoundingClientRect();
return { x: r.left - tRect.left, w: r.width };
});
if (!posW) { posX = rects[0].x; posW = rects[0].w; }
}
function setActive(idx: number) {
activeIdx = idx;
tabRefs.current.forEach((t, i) => {
if (!t) return;
t.classList.toggle(styles.isActive, i === idx);
t.setAttribute("aria-current", i === idx ? "true" : "false");
});
}
function springTo(pos: number, vel: number, target: number, dt: number): [number, number] {
const acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
return [pos, vel];
}
function render() {
const ind = indicatorRef.current;
if (!ind) return;
ind.style.transform = "translateX(" + posX.toFixed(2) + "px)";
ind.style.width = posW.toFixed(2) + "px";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function onTabClick(i: number) { return () => { human(); setActive(i); }; }
const handlers = TABS.map((_, i) => onTabClick(i));
tabRefs.current.forEach((t, i) => t?.addEventListener("click", handlers[i]));
function loop(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
const elapsed = ts - cycleStart;
const idx = Math.floor(elapsed / HOLD_MS) % TABS.length;
if (idx !== activeIdx) setActive(idx);
}
const target = rects[activeIdx] || rects[0] || { x: posX, w: posW };
const rx = springTo(posX, velX, target.x, dt); posX = rx[0]; velX = rx[1];
const rw = springTo(posW, velW, target.w, dt); posW = rw[0]; velW = rw[1];
render();
rafRef.current = requestAnimationFrame(loop);
}
function onResize() { measure(); }
window.addEventListener("resize", onResize);
measure();
render();
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
tabRefs.current.forEach((t, i) => t?.removeEventListener("click", handlers[i]));
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + " " + styles.isDemo} ref={rootRef}>
<div className={styles.phone}>
<div className={styles.track} ref={trackRef}>
<span className={styles.indicator} ref={indicatorRef} />
{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>
);
}01 · 활성 표시 이동 전환 — 선택된 탭이 바뀌면 강조 표시의 위치와 너비 값이 이전 자리에서 새 자리로 함께 바뀌는 방식 — 측정 누적면적 6.096% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02아이콘 채움 전환 — 선택된 탭이 바뀌면 그 자리 아이콘의 채움 정도 값이 낮은 상태에서 가득 찬 상태로 바뀌는 방식
선택 값이 켜지는 순간 그 자리 아이콘의 채움 정도(fillAmt) 값이 FILL_RATE(0.012)로 서서히 1까지 오르고, 나머지 자리는 반대로 0까지 내려갑니다. 동시에 pulseScale()이 PULSE_ZETA(4.4)로 감쇠하는 코사인 값을 곱해, 막 선택된 아이콘만 스케일이 잠깐 튀었다가 가라앉습니다. 아이콘만으로 선택 상태를 뚜렷이 보여주려는 탭 구성에 어울립니다.
var HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
var FILL_RATE = 0.012; // 계산 겹: 채움 정도 지수 평활 상수(ms 기준)
var PULSE_DUR = 360; // ms, 활성화 순간 스케일 펄스 지속
var PULSE_FREQ = 2 * Math.PI * 1.6 / PULSE_DUR; // 계산 겹: 펄스 각속도
var PULSE_ZETA = 4.4; // 계산 겹: 펄스 감쇠 상수
var TAB_COUNT = 4;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var bf = document.getElementById('bf');
var tabs = [0, 1, 2, 3].map(function (i) { return document.getElementById('bfTab' + i); });
var wraps = [0, 1, 2, 3].map(function (i) { return document.getElementById('bfIcon' + i); });
var isDemo = true;
var activeIdx = 0;
var fillAmt = [1, 0, 0, 0];
var pulseStart = [-9999, -9999, -9999, -9999];
var cycleStart = 0;
var lastTs = 0;
function pulseScale(t) {
// 계산 겹: 활성화 순간의 스케일 펄스 — 감쇠 코사인 진동으로 목표를 넘어섰다 되돌아온다
if (t < 0 || t > PULSE_DUR) return 1;
var amp = Math.exp(-PULSE_ZETA * (t / PULSE_DUR));
return 1 + amp * (1 - Math.cos(PULSE_FREQ * t)) * 0.16;
}
<main class="stage">
<div class="bf is-demo" id="bf">
<div class="bf__phone">
<div class="bf__track">
<button type="button" class="bf__tab is-active" data-idx="0" id="bfTab0" aria-current="true">
<span class="bf__iconWrap" id="bfIcon0">
<svg class="bf__icon bf__icon--outline" 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>
<svg class="bf__icon bf__icon--fill" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M224 120v96a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8v-52a4 4 0 0 0-4-4h-40a4 4 0 0 0-4 4v52a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-96a16 16 0 0 1 4.69-11.31l80-80a16 16 0 0 1 22.62 0l80 80A16 16 0 0 1 224 120"/></svg>
</span>
<span class="bf__label">홈</span>
</button>
<button type="button" class="bf__tab" data-idx="1" id="bfTab1">
<span class="bf__iconWrap" id="bfIcon1">
<svg class="bf__icon bf__icon--outline" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
<svg class="bf__icon bf__icon--fill" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M168 112a56 56 0 1 1-56-56a56 56 0 0 1 56 56m61.66 117.66a8 8 0 0 1-11.32 0l-50.06-50.07a88 88 0 1 1 11.32-11.31l50.06 50.06a8 8 0 0 1 0 11.32M112 184a72 72 0 1 0-72-72a72.08 72.08 0 0 0 72 72"/></svg>
</span>
<span class="bf__label">검색</span>
</button>
<button type="button" class="bf__tab" data-idx="2" id="bfTab2">
<span class="bf__iconWrap" id="bfIcon2">
<svg class="bf__icon bf__icon--outline" 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>
<svg class="bf__icon bf__icon--fill" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" 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-32"/></svg>
</span>
<span class="bf__label">쇼핑</span>
</button>
<button type="button" class="bf__tab" data-idx="3" id="bfTab3">
<span class="bf__iconWrap" id="bfIcon3">
<svg class="bf__icon bf__icon--outline" 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>
<svg class="bf__icon bf__icon--fill" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M230.93 220a8 8 0 0 1-6.93 4H32a8 8 0 0 1-6.92-12c15.23-26.33 38.7-45.21 66.09-54.16a72 72 0 1 1 73.66 0c27.39 8.95 50.86 27.83 66.09 54.16a8 8 0 0 1 .01 8"/></svg>
</span>
<span class="bf__label">프로필</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 채움 정도(fillAmt)를 지수 평활로 목표(0/1)에 붙이고, 활성화 순간엔
// pulseScale() 감쇠 코사인 진동으로 아이콘이 살짝 튀어올랐다 가라앉는 스케일 펄스를 얹는다.
@include stage($stage-orange);
.bf__phone { width: 100%; max-width: 300px; }
.bf__track {
display: flex; justify-content: space-between; background: $subject-cream; border-radius: $r-pill;
box-shadow: $shadow-raised; padding: $sp-1 $sp-2; box-sizing: border-box;
}
.bf__tab {
flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border: none; background: transparent; cursor: pointer; padding: $sp-2 $sp-3; color: rgba($subject-ink, .72);
}
.bf__iconWrap { position: relative; width: 22px; height: 22px; transform-origin: 50% 50%; }
.bf__icon { position: absolute; inset: 0; width: 22px; height: 22px; will-change: opacity; }
.bf__icon--fill { opacity: 0; color: $subject-orange-deep; }
.bf__icon--outline { opacity: 1; }
.bf__label { font-size: 10px; font-weight: 700; line-height: 1; }
.bf__tab.is-active { color: $subject-orange-deep; }
.bf__tab.is-active .bf__label { font-weight: 800; }
@media (max-width: 340px) {
.bf__phone { max-width: 230px; }
.bf__tab { padding: $sp-1 $sp-2; }
.bf__iconWrap, .bf__icon { width: 19px; height: 19px; }
}
@media (prefers-reduced-motion: reduce) {
.bf__icon, .bf__iconWrap { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bf__phone {
width: 100%;
max-width: 300px;
}
.bf__track {
display: flex;
justify-content: space-between;
background: #fff7e6;
border-radius: 999px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px 8px;
box-sizing: border-box;
}
.bf__tab {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 8px 12px;
color: rgba(23, 20, 26, 0.72);
}
.bf__iconWrap {
position: relative;
width: 22px;
height: 22px;
transform-origin: 50% 50%;
}
.bf__icon {
position: absolute;
inset: 0;
width: 22px;
height: 22px;
will-change: opacity;
}
.bf__icon--fill {
opacity: 0;
color: #c93000;
}
.bf__icon--outline {
opacity: 1;
}
.bf__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.bf__tab.is-active {
color: #c93000;
}
.bf__tab.is-active .bf__label {
font-weight: 800;
}
@media (max-width: 340px) {
.bf__phone {
max-width: 230px;
}
.bf__tab {
padding: 4px 8px;
}
.bf__iconWrap, .bf__icon {
width: 19px;
height: 19px;
}
}
@media (prefers-reduced-motion: reduce) {
.bf__icon, .bf__iconWrap {
transition: none;
}
}(function () {
var HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
var FILL_RATE = 0.012; // 계산 겹: 채움 정도 지수 평활 상수(ms 기준)
var PULSE_DUR = 360; // ms, 활성화 순간 스케일 펄스 지속
var PULSE_FREQ = 2 * Math.PI * 1.6 / PULSE_DUR; // 계산 겹: 펄스 각속도
var PULSE_ZETA = 4.4; // 계산 겹: 펄스 감쇠 상수
var TAB_COUNT = 4;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var bf = document.getElementById('bf');
var tabs = [0, 1, 2, 3].map(function (i) { return document.getElementById('bfTab' + i); });
var wraps = [0, 1, 2, 3].map(function (i) { return document.getElementById('bfIcon' + i); });
var isDemo = true;
var activeIdx = 0;
var fillAmt = [1, 0, 0, 0];
var pulseStart = [-9999, -9999, -9999, -9999];
var cycleStart = 0;
var lastTs = 0;
function pulseScale(t) {
// 계산 겹: 활성화 순간의 스케일 펄스 — 감쇠 코사인 진동으로 목표를 넘어섰다 되돌아온다
if (t < 0 || t > PULSE_DUR) return 1;
var amp = Math.exp(-PULSE_ZETA * (t / PULSE_DUR));
return 1 + amp * (1 - Math.cos(PULSE_FREQ * t)) * 0.16;
}
function setActive(idx, ts) {
if (idx === activeIdx) return;
activeIdx = idx;
pulseStart[idx] = ts;
tabs.forEach(function (t, i) {
t.classList.toggle('is-active', i === idx);
t.setAttribute('aria-current', i === idx ? 'true' : 'false');
});
}
function render(ts) {
wraps.forEach(function (w, i) {
var outline = w.children[0], fill = w.children[1];
var f = fillAmt[i];
fill.style.opacity = f.toFixed(3);
outline.style.opacity = (1 - f * 0.85).toFixed(3);
var s = pulseScale(ts - pulseStart[i]);
w.style.transform = 'scale(' + s.toFixed(3) + ')';
});
}
function human() {
if (isDemo) { bf.classList.remove('is-demo'); isDemo = false; }
}
tabs.forEach(function (t, i) {
t.addEventListener('click', function () { human(); setActive(i, performance.now()); });
});
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = ts - lastTs;
lastTs = ts;
if (isDemo) {
var elapsed = ts - cycleStart;
var idx = Math.floor(elapsed / HOLD_MS) % TAB_COUNT;
if (idx !== activeIdx) setActive(idx, ts);
}
for (var i = 0; i < TAB_COUNT; i++) {
var target = i === activeIdx ? 1 : 0;
fillAmt[i] += (target - fillAmt[i]) * (1 - Math.exp(-FILL_RATE * dt));
}
render(ts);
requestAnimationFrame(loop);
}
render(0);
if (!reduce) {
requestAnimationFrame(loop);
}
})();// IconFillSwap — vanilla 판(run/367/demo/02-bottom-nav-bar-icon-fill-swap/index.html) 의 React 포트.
// fillAmt(0~1) 를 지수 평활로 목표까지 움직이고, 활성화 순간엔 pulseScale() 감쇠 코사인
// 진동으로 아이콘이 살짝 튀어올랐다 가라앉는다 — 둘 다 rAF 계산, CSS transition 아님.
import { useEffect, useRef } from "react";
import styles from "./IconFillSwap.module.scss";
type Box<T> = { current: T };
const HOLD_MS = 650; // 계산 겹 상수: 자동 루프가 각 탭에 머무는 시간
const FILL_RATE = 0.012; // 계산 겹: 채움 정도 지수 평활 상수(ms 기준)
const PULSE_DUR = 360; // ms, 활성화 순간 스케일 펄스 지속
const PULSE_FREQ = (2 * Math.PI * 1.6) / PULSE_DUR; // 계산 겹: 펄스 각속도
const PULSE_ZETA = 4.4; // 계산 겹: 펄스 감쇠 상수
const TABS = [
{ label: "홈",
outline: ["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", "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"],
fill: "M224 120v96a8 8 0 0 1-8 8h-56a8 8 0 0 1-8-8v-52a4 4 0 0 0-4-4h-40a4 4 0 0 0-4 4v52a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-96a16 16 0 0 1 4.69-11.31l80-80a16 16 0 0 1 22.62 0l80 80A16 16 0 0 1 224 120" },
{ label: "검색",
outline: ["M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80", "m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"],
fill: "M168 112a56 56 0 1 1-56-56a56 56 0 0 1 56 56m61.66 117.66a8 8 0 0 1-11.32 0l-50.06-50.07a88 88 0 1 1 11.32-11.31l50.06 50.06a8 8 0 0 1 0 11.32M112 184a72 72 0 1 0-72-72a72.08 72.08 0 0 0 72 72" },
{ label: "쇼핑",
outline: ["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", "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"],
fill: "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-32" },
{ label: "프로필",
outline: ["M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64", "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"],
fill: "M230.93 220a8 8 0 0 1-6.93 4H32a8 8 0 0 1-6.92-12c15.23-26.33 38.7-45.21 66.09-54.16a72 72 0 1 1 73.66 0c27.39 8.95 50.86 27.83 66.09 54.16a8 8 0 0 1 .01 8" },
];
function pulseScale(t: number): number {
if (t < 0 || t > PULSE_DUR) return 1;
const amp = Math.exp(-PULSE_ZETA * (t / PULSE_DUR));
return 1 + amp * (1 - Math.cos(PULSE_FREQ * t)) * 0.16;
}
export default function IconFillSwap() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tabRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const wrapRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLSpanElement | null)[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
let activeIdx = 0;
const fillAmt = [1, 0, 0, 0];
const pulseStart = [-9999, -9999, -9999, -9999];
let cycleStart = 0, lastTs = 0;
function setActive(idx: number, ts: number) {
if (idx === activeIdx) return;
activeIdx = idx;
pulseStart[idx] = ts;
tabRefs.current.forEach((t, i) => {
if (!t) return;
t.classList.toggle(styles.isActive, i === idx);
t.setAttribute("aria-current", i === idx ? "true" : "false");
});
}
function render(ts: number) {
wrapRefs.current.forEach((w, i) => {
if (!w) return;
const outline = w.children[0] as HTMLElement;
const fill = w.children[1] as HTMLElement;
const f = fillAmt[i];
fill.style.opacity = f.toFixed(3);
outline.style.opacity = (1 - f * 0.85).toFixed(3);
const s = pulseScale(ts - pulseStart[i]);
w.style.transform = "scale(" + s.toFixed(3) + ")";
});
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
const handlers = TABS.map((_, i) => () => { human(); setActive(i, performance.now()); });
tabRefs.current.forEach((t, i) => t?.addEventListener("click", handlers[i]));
function loop(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dt = ts - lastTs;
lastTs = ts;
if (isDemo) {
const elapsed = ts - cycleStart;
const idx = Math.floor(elapsed / HOLD_MS) % TABS.length;
if (idx !== activeIdx) setActive(idx, ts);
}
for (let i = 0; i < TABS.length; i++) {
const target = i === activeIdx ? 1 : 0;
fillAmt[i] += (target - fillAmt[i]) * (1 - Math.exp(-FILL_RATE * dt));
}
render(ts);
rafRef.current = requestAnimationFrame(loop);
}
render(0);
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
cancelAnimationFrame(rafRef.current);
tabRefs.current.forEach((t, i) => t?.removeEventListener("click", handlers[i]));
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + " " + styles.isDemo} ref={rootRef}>
<div className={styles.phone}>
<div className={styles.track}>
{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; }}
>
<span className={styles.iconWrap} ref={(el: HTMLSpanElement | null) => { wrapRefs.current[i] = el; }}>
<svg className={styles.icon + " " + styles.iconOutline} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor"><path d={t.outline[0]} opacity=".2" /><path d={t.outline[1]} /></g>
</svg>
<svg className={styles.icon + " " + styles.iconFill} viewBox="0 0 256 256" aria-hidden="true">
<path fill="currentColor" d={t.fill} />
</svg>
</span>
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
</div>
</div>
</main>
);
}02 · 아이콘 채움 전환 — 선택된 탭이 바뀌면 그 자리 아이콘의 채움 정도 값이 낮은 상태에서 가득 찬 상태로 바뀌는 방식 — 측정 누적면적 1.267% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03배지 수량 튐 전환 — 알림 수량 값이 올라갈 때마다 그 표시의 크기 값이 한 번 커졌다가 되돌아오는 방식
추가 버튼을 클릭하거나 Enter·Space를 누르면 increment()가 수량 값을 하나 올리고 bump()가 그 시각을 기록해, render() 안에서 bumpScale()이 BUMP_ZETA(4.8)로 감쇠하는 코사인 값을 배지 스케일에 곱하고 strip은 SPRING_K(260)·SPRING_C(22) 스프링으로 translateY 값을 새 숫자 위치로 옮깁니다. 읽지 않은 알림 수를 보여주는 탭에 씁니다.
function bumpScale(t) {
// 계산 겹: 배지 bump — 감쇠 코사인 진동
if (t < 0 || t > BUMP_DUR) return 1;
var amp = Math.exp(-BUMP_ZETA * (t / BUMP_DUR));
return 1 + amp * (1 - Math.cos(BUMP_FREQ * t)) * 0.5;
}
function bump(ts) {
bumpStart = ts;
}
function increment(ts) {
count = (count + 1) % 10;
badge.style.display = 'inline-flex';
bump(ts);
}
function render(ts) {
var s = bumpScale(ts - bumpStart);
badge.style.transform = 'scale(' + s.toFixed(3) + ')';
strip.style.transform = 'translateY(' + (-pos).toFixed(2) + 'px)';
}

<main class="stage">
<div class="bg is-demo" id="bg">
<div class="bg__phone">
<div class="bg__track">
<button type="button" class="bg__tab is-active" data-idx="0">
<svg class="bg__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="bg__label">홈</span>
</button>
<button type="button" class="bg__tab" data-idx="1">
<svg class="bg__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="bg__label">쇼핑</span>
</button>
<button type="button" class="bg__tab" data-idx="2" id="bgAlertTab">
<span class="bg__iconWrap">
<svg class="bg__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span class="bg__badge" id="bgBadge">
<span class="bg__strip" id="bgStrip">
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span><span>7</span><span>8</span><span>9</span>
</span>
</span>
</span>
<span class="bg__label">알림</span>
</button>
<button type="button" class="bg__tab" data-idx="3">
<svg class="bg__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="bg__label">프로필</span>
</button>
</div>
<button type="button" class="bg__hit" id="bgAdd">알림 추가</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html increment() 가 카운트를 올릴 때마다 springTo 감쇠 조화진동자가
// 숫자 롤(strip)의 translateY 를 목표 칸으로 당기고(오버슛 포함), badge 자체는
// bumpScale() 감쇠 코사인 진동으로 짧게 튀어올랐다 가라앉는다.
@include stage($stage-yellow);
.bg__phone { width: 100%; max-width: 300px; }
.bg__track {
display: flex; justify-content: space-between; background: $subject-cream; border-radius: $r-pill;
box-shadow: $shadow-raised; padding: $sp-1 $sp-2; box-sizing: border-box;
}
.bg__tab {
position: relative; flex: 0 0 auto; display: flex; flex-direction: column; align-items: center;
gap: $sp-1; border: none; background: transparent; cursor: pointer; padding: $sp-2 $sp-3;
color: rgba($subject-ink, .72);
}
.bg__tab.is-active { color: $subject-blue-deep; }
.bg__icon { width: 20px; height: 20px; }
.bg__label { font-size: 10px; font-weight: 700; line-height: 1; }
.bg__iconWrap { position: relative; display: inline-flex; }
.bg__badge {
position: absolute; top: -4px; right: -8px; min-width: $sp-4; height: $sp-4;
border-radius: $r-pill; background: $subject-orange-deep; color: $subject-cream;
display: none; align-items: center; justify-content: center; overflow: hidden;
box-shadow: $shadow-raised; transform-origin: 50% 50%;
}
.bg__strip {
display: flex; flex-direction: column; align-items: center; line-height: 14px;
font-size: 10px; font-weight: 800;
span { height: 14px; display: block; }
}
.bg__hit {
margin-top: $sp-2; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue-deep; color: $subject-cream; font-weight: 700; font-size: 11px;
padding: $sp-1 0; cursor: pointer;
}
@media (max-width: 340px) {
.bg__phone { max-width: 230px; }
.bg__tab { padding: $sp-1 $sp-2; }
.bg__icon { width: 18px; height: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.bg__badge, .bg__strip { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bg__phone {
width: 100%;
max-width: 300px;
}
.bg__track {
display: flex;
justify-content: space-between;
background: #fff7e6;
border-radius: 999px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px 8px;
box-sizing: border-box;
}
.bg__tab {
position: relative;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 8px 12px;
color: rgba(23, 20, 26, 0.72);
}
.bg__tab.is-active {
color: #0a4fe8;
}
.bg__icon {
width: 20px;
height: 20px;
}
.bg__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.bg__iconWrap {
position: relative;
display: inline-flex;
}
.bg__badge {
position: absolute;
top: -4px;
right: -8px;
min-width: 16px;
height: 16px;
border-radius: 999px;
background: #c93000;
color: #fff7e6;
display: none;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: 50% 50%;
}
.bg__strip {
display: flex;
flex-direction: column;
align-items: center;
line-height: 14px;
font-size: 10px;
font-weight: 800;
}
.bg__strip span {
height: 14px;
display: block;
}
.bg__hit {
margin-top: 8px;
width: 100%;
border: none;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
padding: 4px 0;
cursor: pointer;
}
@media (max-width: 340px) {
.bg__phone {
max-width: 230px;
}
.bg__tab {
padding: 4px 8px;
}
.bg__icon {
width: 18px;
height: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.bg__badge, .bg__strip {
transition: none;
}
}(function () {
var ROW_H = 14; // px, 숫자 롤 한 칸 높이
var INCR_MS = 520; // 계산 겹 상수: 자동 루프가 수량을 올리는 간격
var SPRING_K = 260; // 계산 겹: 숫자 롤 스프링 상수
var SPRING_C = 22; // 계산 겹: 숫자 롤 감쇠 상수
var BUMP_DUR = 320; // ms, 배지 bump 지속
var BUMP_FREQ = 2 * Math.PI * 1.8 / BUMP_DUR; // 계산 겹: bump 각속도
var BUMP_ZETA = 4.8; // 계산 겹: bump 감쇠 상수
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var bg = document.getElementById('bg');
var badge = document.getElementById('bgBadge');
var strip = document.getElementById('bgStrip');
var addBtn = document.getElementById('bgAdd');
var alertTab = document.getElementById('bgAlertTab');
var isDemo = true;
var count = 0;
var pos = 0, vel = 0;
var bumpStart = -9999;
var lastTs = 0;
var cycleStart = 0;
function bumpScale(t) {
// 계산 겹: 배지 bump — 감쇠 코사인 진동
if (t < 0 || t > BUMP_DUR) return 1;
var amp = Math.exp(-BUMP_ZETA * (t / BUMP_DUR));
return 1 + amp * (1 - Math.cos(BUMP_FREQ * t)) * 0.5;
}
function bump(ts) {
bumpStart = ts;
}
function increment(ts) {
count = (count + 1) % 10;
badge.style.display = 'inline-flex';
bump(ts);
}
function render(ts) {
var s = bumpScale(ts - bumpStart);
badge.style.transform = 'scale(' + s.toFixed(3) + ')';
strip.style.transform = 'translateY(' + (-pos).toFixed(2) + 'px)';
}
function human() {
if (isDemo) { bg.classList.remove('is-demo'); isDemo = false; }
}
addBtn.addEventListener('click', function () { human(); increment(performance.now()); });
addBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
addBtn.click();
});
alertTab.addEventListener('click', function () { human(); });
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
var n = Math.floor((ts - cycleStart) / INCR_MS);
if (n > loop._lastN) {
loop._lastN = n;
increment(ts);
}
}
var target = -count * ROW_H;
var acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
render(ts);
requestAnimationFrame(loop);
}
loop._lastN = -1;
render(0);
if (!reduce) {
requestAnimationFrame(loop);
}
})();// BadgeCountBump — vanilla 판(run/367/demo/03-bottom-nav-bar-badge-count-bump/index.html) 의 React 포트.
// increment() 가 카운트를 올릴 때마다 springTo 감쇠 조화진동자가 숫자 롤(strip)의
// translateY 를 목표 칸으로 당기고(오버슛 포함), badge 자체는 bumpScale() 감쇠 코사인
// 진동으로 짧게 튀어올랐다 가라앉는다 — 둘 다 rAF 계산, CSS transition 아님.
import { useEffect, useRef } from "react";
import styles from "./BadgeCountBump.module.scss";
type Box<T> = { current: T };
const ROW_H = 14; // px, 숫자 롤 한 칸 높이
const INCR_MS = 520; // 계산 겹 상수: 자동 루프가 수량을 올리는 간격
const SPRING_K = 260; // 계산 겹: 숫자 롤 스프링 상수
const SPRING_C = 22; // 계산 겹: 숫자 롤 감쇠 상수
const BUMP_DUR = 320; // ms, 배지 bump 지속
const BUMP_FREQ = (2 * Math.PI * 1.8) / BUMP_DUR; // 계산 겹: bump 각속도
const BUMP_ZETA = 4.8; // 계산 겹: bump 감쇠 상수
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" },
];
const ALERT_D = "M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12|.2#M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z";
function Icon({ spec, className }: { spec: string; className?: string }) {
const [a, b] = spec.split("#");
const [pathA, opacity] = a.split("|");
return (
<svg className={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 bumpScale(t: number): number {
if (t < 0 || t > BUMP_DUR) return 1;
const amp = Math.exp(-BUMP_ZETA * (t / BUMP_DUR));
return 1 + amp * (1 - Math.cos(BUMP_FREQ * t)) * 0.5;
}
export default function BadgeCountBump() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const badgeRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const stripRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const addRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const alertTabRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
let count = 0;
let pos = 0, vel = 0;
let bumpStart = -9999;
let lastTs = 0, cycleStart = 0;
let lastN = -1;
function bump(ts: number) { bumpStart = ts; }
function increment(ts: number) {
count = (count + 1) % 10;
const badge = badgeRef.current;
if (badge) badge.style.display = "inline-flex";
bump(ts);
}
function render(ts: number) {
const badge = badgeRef.current;
const strip = stripRef.current;
if (!badge || !strip) return;
const s = bumpScale(ts - bumpStart);
badge.style.transform = "scale(" + s.toFixed(3) + ")";
strip.style.transform = "translateY(" + (-pos).toFixed(2) + "px)";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function onAddClick() { human(); increment(performance.now()); }
function onAddKeydown(ev: KeyboardEvent) {
if (ev.key !== "Enter" && ev.key !== " ") return;
ev.preventDefault();
addRef.current?.click();
}
function onAlertClick() { human(); }
addRef.current?.addEventListener("click", onAddClick);
addRef.current?.addEventListener("keydown", onAddKeydown);
alertTabRef.current?.addEventListener("click", onAlertClick);
function loop(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
const n = Math.floor((ts - cycleStart) / INCR_MS);
if (n > lastN) { lastN = n; increment(ts); }
}
const target = -count * ROW_H;
const acc = (target - pos) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
pos += vel * dt;
render(ts);
rafRef.current = requestAnimationFrame(loop);
}
render(0);
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
cancelAnimationFrame(rafRef.current);
addRef.current?.removeEventListener("click", onAddClick);
addRef.current?.removeEventListener("keydown", onAddKeydown);
alertTabRef.current?.removeEventListener("click", onAlertClick);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + " " + styles.isDemo} ref={rootRef}>
<div className={styles.phone}>
<div className={styles.track}>
{TABS.slice(0, 2).map((t, i) => (
<button key={t.label} type="button"
className={styles.tab + (i === 0 ? " " + styles.isActive : "")}
aria-current={i === 0 ? "true" : undefined}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
<button type="button" className={styles.tab} ref={alertTabRef}>
<span className={styles.iconWrap}>
<Icon spec={ALERT_D} />
<span className={styles.badge} ref={badgeRef}>
<span className={styles.strip} ref={stripRef}>
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span>
<span>5</span><span>6</span><span>7</span><span>8</span><span>9</span>
</span>
</span>
</span>
<span className={styles.label}>알림</span>
</button>
<button type="button" className={styles.tab}>
<Icon spec={TABS[2].d} />
<span className={styles.label}>{TABS[2].label}</span>
</button>
</div>
<button type="button" className={styles.hit} ref={addRef}>알림 추가</button>
</div>
</div>
</main>
);
}03 · 배지 수량 튐 전환 — 알림 수량 값이 올라갈 때마다 그 표시의 크기 값이 한 번 커졌다가 되돌아오는 방식 — 측정 누적면적 0.217% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04중앙 융기 전환 — 가운데 탭의 선택 값이 켜지면 그 자리만 배경보다 높이 값이 커지며 위로 떠오르는 방식
중앙 자리를 클릭해 lifted 값이 켜지면 SPRING_K(190)·SPRING_C(18) 스프링이 liftT 값을 0에서 1로 밀어 올리고, render()는 그 값에 비례해 LIFT_PX(22)만큼의 translateY·SCALE_MAX(1.14)까지의 scale·SHADOW_BLUR_GAIN(20)만큼의 그림자 블러를 한 번에 계산합니다. 여기 쓰인 HOLD_MS(900ms)는 자동 루프가 이 상태를 얼마나 유지할지 정하는 데모 값일 뿐, 06의 누름 시간 HOLD_MS와는 다른 항목의 다른 값입니다. 중앙에 주요 기능을 둔 하단 내비게이션에 맞습니다.
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
var phase = Math.floor((ts - cycleStart) / HOLD_MS) % 2;
setLifted(phase === 1);
}
var target = lifted ? 1 : 0;
var acc = (target - liftT) * SPRING_K - liftV * SPRING_C;
liftV += acc * dt;
liftT += liftV * dt;
render();
requestAnimationFrame(loop);
}

<main class="stage">
<div class="cl is-demo" id="cl">
<div class="cl__phone">
<div class="cl__track">
<button type="button" class="cl__tab is-active" data-idx="0">
<svg class="cl__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="cl__label">홈</span>
</button>
<button type="button" class="cl__tab" data-idx="1">
<svg class="cl__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
<span class="cl__label">검색</span>
</button>
<div class="cl__centerSlot">
<button type="button" class="cl__center" id="clCenter" data-idx="2">
<svg class="cl__centerIcon" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></svg>
</button>
</div>
<button type="button" class="cl__tab" data-idx="3">
<svg class="cl__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="cl__label">쇼핑</span>
</button>
<button type="button" class="cl__tab" data-idx="4">
<svg class="cl__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="cl__label">프로필</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 liftT(0~1) 를 springTo 감쇠 조화진동자로 목표까지 당기고,
// 그 값으로 translateY·scale 뿐 아니라 box-shadow 의 blur·y오프셋·alpha 까지 매 프레임 다시 계산한다
// (들릴수록 그림자가 커지고 옅어지는 물리적 높이감).
@include stage($stage-paper);
.cl__phone { width: 100%; max-width: 300px; }
.cl__track {
position: relative; display: flex; align-items: center; justify-content: space-between;
background: $subject-cream; border-radius: $r-pill; box-shadow: $shadow-raised;
padding: $sp-1 $sp-2; box-sizing: border-box;
}
.cl__tab {
flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border: none; background: transparent; cursor: pointer; padding: $sp-2 $sp-3;
color: rgba($subject-ink, .72);
}
.cl__tab.is-active { color: $subject-blue-deep; }
.cl__icon { width: 20px; height: 20px; }
.cl__label { font-size: 10px; font-weight: 700; line-height: 1; }
.cl__centerSlot { flex: 0 0 auto; width: 52px; display: flex; justify-content: center; }
.cl__center {
width: 46px; height: 46px; border-radius: 50%; border: none; cursor: pointer;
background: $subject-blue-deep; color: $subject-cream; display: flex; align-items: center;
justify-content: center; transform-origin: 50% 50%; will-change: transform, box-shadow;
box-shadow: 0 2px 6px rgba(10, 20, 40, .32);
}
.cl__center.is-lifted { background: $subject-orange-deep; }
.cl__centerIcon { width: 22px; height: 22px; }
@media (max-width: 340px) {
.cl__phone { max-width: 230px; }
.cl__tab { padding: $sp-1 $sp-2; }
.cl__icon { width: 18px; height: 18px; }
.cl__center { width: 40px; height: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.cl__center { 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);
}
.cl__phone {
width: 100%;
max-width: 300px;
}
.cl__track {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff7e6;
border-radius: 999px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px 8px;
box-sizing: border-box;
}
.cl__tab {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 8px 12px;
color: rgba(23, 20, 26, 0.72);
}
.cl__tab.is-active {
color: #0a4fe8;
}
.cl__icon {
width: 20px;
height: 20px;
}
.cl__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.cl__centerSlot {
flex: 0 0 auto;
width: 52px;
display: flex;
justify-content: center;
}
.cl__center {
width: 46px;
height: 46px;
border-radius: 50%;
border: none;
cursor: pointer;
background: #0a4fe8;
color: #fff7e6;
display: flex;
align-items: center;
justify-content: center;
transform-origin: 50% 50%;
will-change: transform, box-shadow;
box-shadow: 0 2px 6px rgba(10, 20, 40, 0.32);
}
.cl__center.is-lifted {
background: #c93000;
}
.cl__centerIcon {
width: 22px;
height: 22px;
}
@media (max-width: 340px) {
.cl__phone {
max-width: 230px;
}
.cl__tab {
padding: 4px 8px;
}
.cl__icon {
width: 18px;
height: 18px;
}
.cl__center {
width: 40px;
height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__center {
transition: none;
}
}(function () {
var HOLD_MS = 900; // 계산 겹 상수: 자동 루프가 각 상태에 머무는 시간
var LIFT_PX = 22; // px, 완전히 들렸을 때 위로 이동하는 거리
var SCALE_MAX = 1.14; // 완전히 들렸을 때 배율
var SHADOW_BLUR_BASE = 6, SHADOW_BLUR_GAIN = 20; // 계산 겹: 들림 정도(liftT)에 따라 blur 를 늘린다
var SHADOW_Y_BASE = 2, SHADOW_Y_GAIN = 14; // 계산 겹: 들림 정도에 따라 그림자 y 오프셋을 늘린다(높이감)
var SHADOW_ALPHA_BASE = 0.32, SHADOW_ALPHA_GAIN = -0.10; // 계산 겹: 들릴수록 그림자가 옅어진다(광원과 멀어짐)
var SPRING_K = 190, SPRING_C = 18; // 계산 겹: liftT 스프링 상수·감쇠
var cl = document.getElementById('cl');
var center = document.getElementById('clCenter');
var tabs = Array.prototype.slice.call(document.querySelectorAll('.cl__tab'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
var lifted = false;
var liftT = 0, liftV = 0;
var lastTs = 0;
var cycleStart = 0;
function setLifted(v) {
lifted = v;
center.classList.toggle('is-lifted', v);
tabs.forEach(function (t) { t.classList.toggle('is-active', false); });
}
function render() {
// 계산 겹: 물리 그림자 근사 — 들린 높이(liftT)가 클수록 blur·y오프셋은 커지고 alpha는 옅어진다
var ty = -(LIFT_PX * liftT);
var scale = 1 + (SCALE_MAX - 1) * liftT;
var blur = SHADOW_BLUR_BASE + SHADOW_BLUR_GAIN * liftT;
var y = SHADOW_Y_BASE + SHADOW_Y_GAIN * liftT;
var alpha = Math.max(0.08, SHADOW_ALPHA_BASE + SHADOW_ALPHA_GAIN * liftT);
center.style.transform = 'translateY(' + ty.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
center.style.boxShadow = '0 ' + y.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(10, 20, 40,' + alpha.toFixed(3) + ')';
}
function human() {
if (isDemo) { cl.classList.remove('is-demo'); isDemo = false; }
}
center.addEventListener('click', function () { human(); setLifted(!lifted); });
tabs.forEach(function (t) {
t.addEventListener('click', function () { human(); setLifted(false); tabs.forEach(function (o) { o.classList.remove('is-active'); }); t.classList.add('is-active'); });
});
function loop(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
var phase = Math.floor((ts - cycleStart) / HOLD_MS) % 2;
setLifted(phase === 1);
}
var target = lifted ? 1 : 0;
var acc = (target - liftT) * SPRING_K - liftV * SPRING_C;
liftV += acc * dt;
liftT += liftV * dt;
render();
requestAnimationFrame(loop);
}
render();
if (!reduce) {
requestAnimationFrame(loop);
}
})();// CenterLiftScale — vanilla 판(run/367/demo/04-bottom-nav-bar-center-lift-scale/index.html) 의 React 포트.
// liftT(0~1) 를 springTo 감쇠 조화진동자로 목표까지 당기고, 그 값으로 translateY·scale 뿐 아니라
// box-shadow 의 blur·y오프셋·alpha 까지 매 프레임 다시 계산한다(들릴수록 그림자가 커지고 옅어지는
// 물리적 높이감) — CSS transition 이 아니라 rAF 계산이 만든다.
import { useEffect, useRef } from "react";
import styles from "./CenterLiftScale.module.scss";
type Box<T> = { current: T };
const HOLD_MS = 900; // 계산 겹 상수: 자동 루프가 각 상태에 머무는 시간
const LIFT_PX = 22; // px, 완전히 들렸을 때 위로 이동하는 거리
const SCALE_MAX = 1.14; // 완전히 들렸을 때 배율
const SHADOW_BLUR_BASE = 6, SHADOW_BLUR_GAIN = 20; // 계산 겹: 들림 정도(liftT)에 따라 blur 를 늘린다
const SHADOW_Y_BASE = 2, SHADOW_Y_GAIN = 14; // 계산 겹: 들림 정도에 따라 그림자 y 오프셋을 늘린다(높이감)
const SHADOW_ALPHA_BASE = 0.32, SHADOW_ALPHA_GAIN = -0.10; // 계산 겹: 들릴수록 그림자가 옅어진다(광원과 멀어짐)
const SPRING_K = 190, SPRING_C = 18; // 계산 겹: liftT 스프링 상수·감쇠
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: "M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80|.2#m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" },
{ 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, className }: { spec: string; className?: string }) {
const [a, b] = spec.split("#");
const [pathA, opacity] = a.split("|");
return (
<svg className={className || styles.icon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d={pathA} opacity={opacity} />
<path d={b} />
</g>
</svg>
);
}
export default function CenterLiftScale() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const centerRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const tabRefs = useRef([null, 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 lifted = false;
let liftT = 0, liftV = 0;
let lastTs = 0, cycleStart = 0;
function setLifted(v: boolean) {
lifted = v;
centerRef.current?.classList.toggle(styles.isLifted, v);
tabRefs.current.forEach((t) => t?.classList.toggle(styles.isActive, false));
}
function render() {
const center = centerRef.current;
if (!center) return;
const ty = -(LIFT_PX * liftT);
const scale = 1 + (SCALE_MAX - 1) * liftT;
const blur = SHADOW_BLUR_BASE + SHADOW_BLUR_GAIN * liftT;
const y = SHADOW_Y_BASE + SHADOW_Y_GAIN * liftT;
const alpha = Math.max(0.08, SHADOW_ALPHA_BASE + SHADOW_ALPHA_GAIN * liftT);
center.style.transform = "translateY(" + ty.toFixed(2) + "px) scale(" + scale.toFixed(3) + ")";
center.style.boxShadow = "0 " + y.toFixed(1) + "px " + blur.toFixed(1) + "px rgba(10, 20, 40," + alpha.toFixed(3) + ")";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function onCenterClick() { human(); setLifted(!lifted); }
centerRef.current?.addEventListener("click", onCenterClick);
const tabHandlers = tabRefs.current.map((_, i) => () => {
human(); setLifted(false);
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 dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo) {
const phase = Math.floor((ts - cycleStart) / HOLD_MS) % 2;
setLifted(phase === 1);
}
const target = lifted ? 1 : 0;
const acc = (target - liftT) * SPRING_K - liftV * SPRING_C;
liftV += acc * dt;
liftT += liftV * dt;
render();
rafRef.current = requestAnimationFrame(loop);
}
render();
if (!reduce) { rafRef.current = requestAnimationFrame(loop); }
return () => {
cancelAnimationFrame(rafRef.current);
centerRef.current?.removeEventListener("click", onCenterClick);
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.track}>
{TABS.slice(0, 2).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 className={styles.centerSlot}>
<button type="button" className={styles.center} ref={centerRef}>
<svg className={styles.centerIcon} viewBox="0 0 256 256" aria-hidden="true">
<path fill="currentColor" d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8" />
</svg>
</button>
</div>
{TABS.slice(2).map((t, i) => (
<button key={t.label} type="button"
className={styles.tab}
ref={(el: HTMLButtonElement | null) => { tabRefs.current[i + 2] = el; }}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
</div>
</div>
</main>
);
}04 · 중앙 융기 전환 — 가운데 탭의 선택 값이 켜지면 그 자리만 배경보다 높이 값이 커지며 위로 떠오르는 방식 — 측정 누적면적 2.599% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05스크롤 방향 숨김 전환 — 스크롤 방향 값이 아래쪽이면 바 전체가 화면 밖으로 숨고 위쪽이면 다시 나타나는 방식
스크롤 영역의 scrollTop 변화량으로 구한 속도(vel)가 VEL_HIDE(90px/s)를 넘으면 hiddenTarget 값이 1로 바뀌고, render()는 LERP_RATE(14)로 navY 값을 지수 평활해 트랙의 translateY 값을 화면 밖과 원래 자리 사이로 옮깁니다. 본문을 넓게 보여주고 싶은 화면의 하단 내비게이션에 씁니다.
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)';
}

<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">홈</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">쇼핑</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">프로필</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 · 스크롤 방향 숨김 전환 — 스크롤 방향 값이 아래쪽이면 바 전체가 화면 밖으로 숨고 위쪽이면 다시 나타나는 방식 — 측정 누적면적 39.533% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06길게 눌러 펼치기 전환 — 누름 지속 시간 값이 기준을 넘으면 그 자리 위로 펼침 정도 값이 커지며 추가 자리가 드러나는 방식
FAB를 누르고 있는 시간(heldMs)이 HOLD_MS(650ms)에 닿을 때까지 progress 값이 선형으로 차오르며 stroke-dashoffset 값이 RING_CIRC만큼 줄어들고, 링이 다 차면 openMenu()가 FAN_ANGLES([-150, -90, -30])를 삼각함수로 좌표 변환해 액션 버튼 세 개를 부채꼴로 펼칩니다. 탭마다 하위 선택지를 숨겨 둔 내비게이션에 씁니다.
var RING_CIRC = 2 * Math.PI * RING_R; // 계산 겹: 원둘레를 실제로 계산해 stroke-dashoffset 에 쓴다
var SPRING_RATE = 16; // 지수 평활 상수(1/s) — 뗄 때 링이 되돌아가는 속도
var MENU_RATE = 20; // 메뉴 등장/퇴장 지수 평활 상수(1/s)
var FAN_R = 62; // 부채꼴 반경(px) — 액션 버튼이 FAB 로부터 떨어지는 거리
var FAN_ANGLES = [-150, -90, -30]; // 계산 겹: 각 액션 버튼의 각도(도) — sin/cos 로 좌표를 만든다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var lp = document.getElementById('lp');
var fab = document.getElementById('lpFab');
var ring = document.getElementById('lpRingProgress');
var menu = document.getElementById('lpMenu');
var actions = Array.prototype.slice.call(menu.querySelectorAll('.lp__action'));
var tabs = Array.prototype.slice.call(document.querySelectorAll('.lp__tab'));
var isDemo = true;
var isPressing = false;
var pressStart = 0;
var opened = false;
var progress = 0; // 0..1, 링이 찬 비율
var menuT = 0; // 0..1, 메뉴 등장 비율
var lastTs = 0;
var cycleStart = 0;
var closeTimer = null;
ring.style.strokeDasharray = RING_CIRC.toFixed(2);

<main class="stage">
<div class="lp is-demo" id="lp">
<div class="lp__phone">
<div class="lp__menu" id="lpMenu">
<button type="button" class="lp__action" data-i="0" aria-label="알림">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button type="button" class="lp__action" data-i="1" aria-label="메시지">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button type="button" class="lp__action" data-i="2" aria-label="좋아요">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
</button>
</div>
<div class="lp__track">
<button type="button" class="lp__tab is-active" data-idx="0">
<svg class="lp__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="lp__label">홈</span>
</button>
<button type="button" class="lp__tab" data-idx="1">
<svg class="lp__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="lp__label">쇼핑</span>
</button>
<button type="button" class="lp__fab" id="lpFab" aria-label="빠른메뉴 길게 누르기">
<svg class="lp__ring" viewBox="0 0 64 64" aria-hidden="true">
<circle class="lp__ringTrack" cx="32" cy="32" r="27"/>
<circle class="lp__ringProgress" id="lpRingProgress" cx="32" cy="32" r="27"/>
</svg>
<svg class="lp__fabIcon" viewBox="0 0 256 256" aria-hidden="true"><path fill="currentColor" d="M228 128a12 12 0 0 1-12 12h-76v76a12 12 0 0 1-24 0v-76H40a12 12 0 0 1 0-24h76V40a12 12 0 0 1 24 0v76h76a12 12 0 0 1 12 12"/></svg>
</button>
<button type="button" class="lp__tab" data-idx="2">
<svg class="lp__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="lp__label">프로필</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 누른 시간(ms)을 HOLD_MS 로 나눈 비율을 svg 원의 실제 둘레(2*PI*r)에
// 곱해 stroke-dashoffset 을 계산하고, 다 차면(1) 메뉴를 연다. 메뉴 3항목의 위치는
// FAB 중심에서 각도(FAN_ANGLES)를 sin/cos 로 변환해 자리를 잡는다.
@include stage($stage-yellow);
// .lp 는 is-demo 클래스만 들고 있는 래퍼 — 상자를 만들면 .stage 의 grid
// place-items:center 밑에서 .lp__phone 의 width:100% 가 래퍼의 그리드-아이템
// 고유 크기에 걸려 0으로 무너진다(367-05 실측). display:contents 로 트리에서
// 지워 .lp__phone 이 직계 그리드 아이템이 되게 한다.
.lp { display: contents; }
.lp__phone {
width: 100%; max-width: 300px; position: relative; height: 200px;
border-radius: $r-card; overflow: visible; box-shadow: $shadow-raised; background: $subject-cream;
}
.lp__track {
position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center;
justify-content: space-around; background: $subject-cream; border-radius: $r-card;
border-top: 1px solid rgba($subject-ink, .12); padding: $sp-1 0; box-shadow: $shadow-raised;
}
.lp__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);
}
.lp__tab.is-active { color: $subject-blue-deep; }
.lp__icon { width: 18px; height: 18px; }
.lp__label { font-size: 10px; font-weight: 700; line-height: 1; }
.lp__fab {
flex: 0 0 auto; position: relative; width: 52px; height: 52px; margin: 0 $sp-2;
border: none; border-radius: $r-pill; background: $subject-blue-deep; color: $subject-cream;
display: flex; align-items: center; justify-content: center; cursor: pointer;
box-shadow: $shadow-raised;
}
.lp__ring {
position: absolute; inset: -6px; width: 64px; height: 64px; transform: rotate(-90deg);
pointer-events: none;
}
.lp__ringTrack, .lp__ringProgress {
fill: none; stroke-width: 4px;
}
.lp__ringTrack { stroke: rgba($subject-cream, .3); }
.lp__ringProgress { stroke: $subject-mint; stroke-dashoffset: 999px; stroke-linecap: round; }
.lp__fabIcon { width: 22px; height: 22px; }
.lp__menu {
position: absolute; inset: 0; opacity: 0; pointer-events: none;
}
.lp__action {
position: absolute; left: 0; top: 0; width: 40px; height: 40px; border: none;
border-radius: $r-pill; background: $subject-blue-light; color: $subject-blue-deep;
display: flex; align-items: center; justify-content: center; cursor: pointer;
box-shadow: $shadow-raised; transform: translate(-50%, -50%) scale(.6);
}
.lp__action svg { width: 18px; height: 18px; }
.lp__action[data-i="0"] { background: $subject-pink; color: $subject-ink; }
.lp__action[data-i="1"] { background: $subject-sky; color: $subject-ink; }
.lp__action[data-i="2"] { background: $subject-lilac; color: $subject-ink; }
@media (max-width: 340px) {
// 320px 뷰포트 예산(174.4px) 안에 들어오도록 축소 (BD9 실측)
.lp__phone { max-width: 230px; height: 160px; }
.lp__fab { width: 46px; height: 46px; }
}
@media (prefers-reduced-motion: reduce) {
.lp__ringProgress, .lp__action { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lp {
display: contents;
}
.lp__phone {
width: 100%;
max-width: 300px;
position: relative;
height: 200px;
border-radius: 16px;
overflow: visible;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.lp__track {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-around;
background: #fff7e6;
border-radius: 16px;
border-top: 1px solid rgba(23, 20, 26, 0.12);
padding: 4px 0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lp__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);
}
.lp__tab.is-active {
color: #0a4fe8;
}
.lp__icon {
width: 18px;
height: 18px;
}
.lp__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.lp__fab {
flex: 0 0 auto;
position: relative;
width: 52px;
height: 52px;
margin: 0 8px;
border: none;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.lp__ring {
position: absolute;
inset: -6px;
width: 64px;
height: 64px;
transform: rotate(-90deg);
pointer-events: none;
}
.lp__ringTrack, .lp__ringProgress {
fill: none;
stroke-width: 4px;
}
.lp__ringTrack {
stroke: rgba(255, 247, 230, 0.3);
}
.lp__ringProgress {
stroke: #4cd4a6;
stroke-dashoffset: 999px;
stroke-linecap: round;
}
.lp__fabIcon {
width: 22px;
height: 22px;
}
.lp__menu {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.lp__action {
position: absolute;
left: 0;
top: 0;
width: 40px;
height: 40px;
border: none;
border-radius: 999px;
background: #5b8cf8;
color: #0a4fe8;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%) scale(0.6);
}
.lp__action svg {
width: 18px;
height: 18px;
}
.lp__action[data-i="0"] {
background: #ff9ebb;
color: #17141a;
}
.lp__action[data-i="1"] {
background: #6ec8ff;
color: #17141a;
}
.lp__action[data-i="2"] {
background: #b9a0ff;
color: #17141a;
}
@media (max-width: 340px) {
.lp__phone {
max-width: 230px;
height: 160px;
}
.lp__fab {
width: 46px;
height: 46px;
}
}
@media (prefers-reduced-motion: reduce) {
.lp__ringProgress, .lp__action {
transition: none;
}
}(function () {
var HOLD_MS = 650; // 계산 겹: 이 길이만큼 눌러야 링이 다 차서 메뉴가 열린다
var OPEN_MS = 900; // 열린 채로 머무는 시간(자동 루프용)
var CYCLE_MS = 2600; // 자동 루프 왕복 주기
var RING_R = 27; // svg 링 반지름(px, viewBox 좌표) — 원둘레 계산의 재료
var RING_CIRC = 2 * Math.PI * RING_R; // 계산 겹: 원둘레를 실제로 계산해 stroke-dashoffset 에 쓴다
var SPRING_RATE = 16; // 지수 평활 상수(1/s) — 뗄 때 링이 되돌아가는 속도
var MENU_RATE = 20; // 메뉴 등장/퇴장 지수 평활 상수(1/s)
var FAN_R = 62; // 부채꼴 반경(px) — 액션 버튼이 FAB 로부터 떨어지는 거리
var FAN_ANGLES = [-150, -90, -30]; // 계산 겹: 각 액션 버튼의 각도(도) — sin/cos 로 좌표를 만든다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var lp = document.getElementById('lp');
var fab = document.getElementById('lpFab');
var ring = document.getElementById('lpRingProgress');
var menu = document.getElementById('lpMenu');
var actions = Array.prototype.slice.call(menu.querySelectorAll('.lp__action'));
var tabs = Array.prototype.slice.call(document.querySelectorAll('.lp__tab'));
var isDemo = true;
var isPressing = false;
var pressStart = 0;
var opened = false;
var progress = 0; // 0..1, 링이 찬 비율
var menuT = 0; // 0..1, 메뉴 등장 비율
var lastTs = 0;
var cycleStart = 0;
var closeTimer = null;
ring.style.strokeDasharray = RING_CIRC.toFixed(2);
function human() {
if (isDemo) { lp.classList.remove('is-demo'); isDemo = false; }
}
function placeActions() {
// 계산 겹: FAB 중심을 기준으로 각도(FAN_ANGLES)를 실제 삼각함수로 좌표 변환한다
var fabRect = fab.getBoundingClientRect();
var phoneRect = fab.closest('.lp__phone').getBoundingClientRect();
var cx = fabRect.left + fabRect.width / 2 - phoneRect.left;
var cy = fabRect.top + fabRect.height / 2 - phoneRect.top;
actions.forEach(function (btn, i) {
var rad = FAN_ANGLES[i] * Math.PI / 180;
var x = cx + FAN_R * Math.cos(rad);
var y = cy + FAN_R * Math.sin(rad);
btn.style.left = x.toFixed(1) + 'px';
btn.style.top = y.toFixed(1) + 'px';
});
}
function openMenu() {
opened = true;
placeActions();
clearTimeout(closeTimer);
closeTimer = setTimeout(closeMenu, OPEN_MS);
}
function closeMenu() {
opened = false;
isPressing = false;
clearTimeout(closeTimer);
}
fab.addEventListener('pointerdown', function (e) {
human();
if (opened) { closeMenu(); return; }
isPressing = true;
pressStart = performance.now();
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (evt) {
fab.addEventListener(evt, function () {
if (!opened) { isPressing = false; }
});
});
actions.forEach(function (btn) {
btn.addEventListener('click', function () { human(); closeMenu(); });
});
tabs.forEach(function (t) {
t.addEventListener('click', function () {
human();
tabs.forEach(function (o) { o.classList.remove('is-active'); });
t.classList.add('is-active');
});
});
function demoPhase(cycleT) {
if (cycleT < HOLD_MS) { return { pressing: true, held: cycleT, opened: false }; }
if (cycleT < HOLD_MS + OPEN_MS) { return { pressing: false, held: HOLD_MS, opened: true }; }
return { pressing: false, held: 0, opened: false };
}
function tick(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo && !reduce) {
var cycleT = (ts - cycleStart) % CYCLE_MS;
var phase = demoPhase(cycleT);
if (phase.pressing) {
progress = Math.min(1, phase.held / HOLD_MS);
if (progress >= 1 && !opened) { openMenu(); }
} else if (phase.opened) {
if (!opened) { openMenu(); }
progress = 1;
} else {
if (opened) { closeMenu(); }
progress += (0 - progress) * (1 - Math.exp(-SPRING_RATE * dt));
}
} else if (isPressing) {
var heldMs = ts - pressStart;
progress = Math.min(1, heldMs / HOLD_MS);
if (progress >= 1 && !opened) { openMenu(); }
} else if (opened) {
progress = 1;
} else {
progress += (0 - progress) * (1 - Math.exp(-SPRING_RATE * dt));
}
var menuTarget = opened ? 1 : 0;
menuT += (menuTarget - menuT) * (1 - Math.exp(-MENU_RATE * dt));
ring.style.strokeDashoffset = (RING_CIRC * (1 - progress)).toFixed(2);
menu.style.opacity = menuT.toFixed(3);
var menuScale = 0.6 + 0.4 * menuT;
actions.forEach(function (btn) {
btn.style.transform = 'translate(-50%, -50%) scale(' + menuScale.toFixed(3) + ')';
});
menu.style.pointerEvents = menuT > 0.5 ? 'auto' : 'none';
requestAnimationFrame(tick);
}
placeActions();
window.addEventListener('resize', placeActions);
requestAnimationFrame(tick);
})();// LongpressReveal — vanilla 판(run/367/demo/06-bottom-nav-bar-longpress-reveal/index.html) 의 React 포트.
// 누른 시간(ms)을 HOLD_MS 로 나눈 비율을 svg 원의 실제 둘레(2*PI*r)에 곱해 stroke-dashoffset 을
// 계산하고, 다 차면(1) 메뉴를 연다. 메뉴 3항목의 위치는 FAB 중심에서 각도(FAN_ANGLES)를
// sin/cos 로 변환해 자리를 잡는다 — 전부 rAF 계산, CSS transition 아님.
import { useEffect, useRef } from "react";
import styles from "./LongpressReveal.module.scss";
type Box<T> = { current: T };
type PtrEvt = { clientX: number; clientY: number; pointerId: number };
const HOLD_MS = 650; // 계산 겹: 이 길이만큼 눌러야 링이 다 차서 메뉴가 열린다
const OPEN_MS = 900; // 열린 채로 머무는 시간(자동 루프용)
const CYCLE_MS = 2600; // 자동 루프 왕복 주기
const RING_R = 27; // svg 링 반지름(px, viewBox 좌표) — 원둘레 계산의 재료
const RING_CIRC = 2 * Math.PI * RING_R; // 계산 겹: 원둘레를 실제로 계산해 stroke-dashoffset 에 쓴다
const SPRING_RATE = 16; // 지수 평활 상수(1/s) — 뗄 때 링이 되돌아가는 속도
const MENU_RATE = 20; // 메뉴 등장/퇴장 지수 평활 상수(1/s)
const FAN_R = 62; // 부채꼴 반경(px) — 액션 버튼이 FAB 로부터 떨어지는 거리
const FAN_ANGLES = [-150, -90, -30]; // 계산 겹: 각 액션 버튼의 각도(도) — sin/cos 로 좌표를 만든다
const ACTIONS = [
{ label: "알림", d: "M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12|.2#M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" },
{ label: "메시지", d: "M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128|.2#M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216" },
{ label: "좋아요", d: "M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54|.2#M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" },
];
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, cls }: { spec: string; cls?: string }) {
const [a, b] = spec.split("#");
const [pathA, opacity] = a.split("|");
return (
<svg className={cls} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d={pathA} opacity={opacity} />
<path d={b} />
</g>
</svg>
);
}
export default function LongpressReveal() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const phoneRef = useRef(null) as unknown as Box<HTMLDivElement>;
const fabRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const ringRef = useRef(null) as unknown as Box<SVGCircleElement>;
const menuRef = useRef(null) as unknown as Box<HTMLDivElement>;
const actionRefs = useRef([null, null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
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 isPressing = false;
let pressStart = 0;
let opened = false;
let progress = 0;
let menuT = 0;
let lastTs = 0, cycleStart = 0;
let closeTimer: number | undefined;
const ring = ringRef.current;
if (ring) ring.style.strokeDasharray = RING_CIRC.toFixed(2);
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function placeActions() {
const fab = fabRef.current;
const phone = phoneRef.current;
if (!fab || !phone) return;
const fabRect = fab.getBoundingClientRect();
const phoneRect = phone.getBoundingClientRect();
const cx = fabRect.left + fabRect.width / 2 - phoneRect.left;
const cy = fabRect.top + fabRect.height / 2 - phoneRect.top;
actionRefs.current.forEach((btn, i) => {
if (!btn) return;
const rad = (FAN_ANGLES[i] * Math.PI) / 180;
const x = cx + FAN_R * Math.cos(rad);
const y = cy + FAN_R * Math.sin(rad);
btn.style.left = x.toFixed(1) + "px";
btn.style.top = y.toFixed(1) + "px";
});
}
function openMenu() {
opened = true;
placeActions();
window.clearTimeout(closeTimer);
closeTimer = window.setTimeout(closeMenu, OPEN_MS);
}
function closeMenu() {
opened = false;
isPressing = false;
window.clearTimeout(closeTimer);
}
function onFabPointerDown() {
human();
if (opened) { closeMenu(); return; }
isPressing = true;
pressStart = performance.now();
}
function onFabRelease() {
if (!opened) { isPressing = false; }
}
fabRef.current?.addEventListener("pointerdown", onFabPointerDown);
["pointerup", "pointercancel", "pointerleave"].forEach((evt) => {
fabRef.current?.addEventListener(evt, onFabRelease);
});
const actionHandlers = actionRefs.current.map(() => () => { human(); closeMenu(); });
actionRefs.current.forEach((btn, i) => btn?.addEventListener("click", actionHandlers[i]));
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 demoPhase(cycleT: number) {
if (cycleT < HOLD_MS) return { pressing: true, held: cycleT, opened: false };
if (cycleT < HOLD_MS + OPEN_MS) return { pressing: false, held: HOLD_MS, opened: true };
return { pressing: false, held: 0, opened: false };
}
function tick(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo && !reduce) {
const cycleT = (ts - cycleStart) % CYCLE_MS;
const phase = demoPhase(cycleT);
if (phase.pressing) {
progress = Math.min(1, phase.held / HOLD_MS);
if (progress >= 1 && !opened) openMenu();
} else if (phase.opened) {
if (!opened) openMenu();
progress = 1;
} else {
if (opened) closeMenu();
progress += (0 - progress) * (1 - Math.exp(-SPRING_RATE * dt));
}
} else if (isPressing) {
const heldMs = ts - pressStart;
progress = Math.min(1, heldMs / HOLD_MS);
if (progress >= 1 && !opened) openMenu();
} else if (opened) {
progress = 1;
} else {
progress += (0 - progress) * (1 - Math.exp(-SPRING_RATE * dt));
}
const menuTarget = opened ? 1 : 0;
menuT += (menuTarget - menuT) * (1 - Math.exp(-MENU_RATE * dt));
if (ring) ring.style.strokeDashoffset = (RING_CIRC * (1 - progress)).toFixed(2);
const menu = menuRef.current;
if (menu) {
menu.style.opacity = menuT.toFixed(3);
menu.style.pointerEvents = menuT > 0.5 ? "auto" : "none";
}
const menuScale = 0.6 + 0.4 * menuT;
actionRefs.current.forEach((btn) => {
if (btn) btn.style.transform = "translate(-50%, -50%) scale(" + menuScale.toFixed(3) + ")";
});
rafRef.current = requestAnimationFrame(tick);
}
function onResize() { placeActions(); }
placeActions();
window.addEventListener("resize", onResize);
rafRef.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener("resize", onResize);
window.clearTimeout(closeTimer);
cancelAnimationFrame(rafRef.current);
fabRef.current?.removeEventListener("pointerdown", onFabPointerDown);
["pointerup", "pointercancel", "pointerleave"].forEach((evt) => {
fabRef.current?.removeEventListener(evt, onFabRelease);
});
actionRefs.current.forEach((btn, i) => btn?.removeEventListener("click", actionHandlers[i]));
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} ref={phoneRef}>
<div className={styles.menu} ref={menuRef}>
{ACTIONS.map((a, i) => (
<button key={a.label} type="button"
className={styles.action}
data-i={i}
aria-label={a.label}
ref={(el: HTMLButtonElement | null) => { actionRefs.current[i] = el; }}>
<Icon spec={a.d} />
</button>
))}
</div>
<div className={styles.track}>
<button type="button"
className={styles.tab + " " + styles.isActive}
aria-current="true"
ref={(el: HTMLButtonElement | null) => { tabRefs.current[0] = el; }}>
<Icon spec={TABS[0].d} cls={styles.icon} />
<span className={styles.label}>{TABS[0].label}</span>
</button>
<button type="button" className={styles.tab} ref={(el: HTMLButtonElement | null) => { tabRefs.current[1] = el; }}>
<Icon spec={TABS[1].d} cls={styles.icon} />
<span className={styles.label}>{TABS[1].label}</span>
</button>
<button type="button" className={styles.fab} aria-label="빠른메뉴 길게 누르기" ref={fabRef}>
<svg className={styles.ring} viewBox="0 0 64 64" aria-hidden="true">
<circle className={styles.ringTrack} cx={32} cy={32} r={RING_R} />
<circle className={styles.ringProgress} ref={ringRef} cx={32} cy={32} r={RING_R} />
</svg>
<svg className={styles.fabIcon} viewBox="0 0 256 256" aria-hidden="true">
<path fill="currentColor" d="M228 128a12 12 0 0 1-12 12h-76v76a12 12 0 0 1-24 0v-76H40a12 12 0 0 1 0-24h76V40a12 12 0 0 1 24 0v76h76a12 12 0 0 1 12 12" />
</svg>
</button>
<button type="button" className={styles.tab} ref={(el: HTMLButtonElement | null) => { tabRefs.current[2] = el; }}>
<Icon spec={TABS[2].d} cls={styles.icon} />
<span className={styles.label}>{TABS[2].label}</span>
</button>
</div>
</div>
</div>
</main>
);
}06 · 길게 눌러 펼치기 전환 — 누름 지속 시간 값이 기준을 넘으면 그 자리 위로 펼침 정도 값이 커지며 추가 자리가 드러나는 방식 — 측정 누적면적 3.297% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07스와이프 추종 전환 — 화면을 옆으로 미는 거리 값이 커지는 동안 강조 표시의 위치 값이 그 비율만큼 옆 탭 쪽으로 따라가는 방식
손가락을 track 위에서 미는 거리(dragRatio)만큼 인디케이터의 translateX 값이 옆 탭 쪽으로 따라가고, 손을 떼는 순간 SPRING_K(240)·SPRING_C(20) 스프링이 가장 가까운 탭 좌표(settleTarget)로 posX 값을 당겨 정착시킵니다. 화면을 옆으로 넘겨 탭을 바꾸는 제스처 중심의 탭 구성에 맞습니다.
if (settling) {
var acc = (settleTarget - posX) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
posX += vel * dt;
if (Math.abs(settleTarget - posX) < 0.4 && Math.abs(vel) < 4) {
posX = settleTarget; vel = 0; settling = false;
}
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
}

<main class="stage">
<div class="sf is-demo" id="sf">
<div class="sf__phone">
<div class="sf__track" id="sfTrack">
<div class="sf__indicator" id="sfIndicator"></div>
<button type="button" class="sf__tab is-active" data-idx="0">
<svg class="sf__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="sf__label">홈</span>
</button>
<button type="button" class="sf__tab" data-idx="1">
<svg class="sf__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="sf__label">쇼핑</span>
</button>
<button type="button" class="sf__tab" data-idx="2">
<svg class="sf__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span class="sf__label">알림</span>
</button>
<button type="button" class="sf__tab" data-idx="3">
<svg class="sf__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="sf__label">프로필</span>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 드래그 중엔 포인터 x 를 그대로 clamp 해 인디케이터가 1:1 로 따라오고,
// 놓으면 가장 가까운 탭의 실측 rect(getBoundingClientRect) 로 스프링(SPRING_K·C) 정착한다.
@include stage($stage-orange);
// .sf 는 is-demo 클래스만 들고 있는 래퍼 — display:contents 로 트리에서 지워
// .sf__phone 이 .stage 그리드의 직계 아이템이 되게 한다(367-05 실측 버그 회피).
.sf { display: contents; }
.sf__phone {
width: 100%; max-width: 300px; position: relative; height: 96px;
display: flex; align-items: center;
}
.sf__track {
position: relative; width: 100%; display: flex; justify-content: space-around;
background: $subject-cream; border-radius: $r-card; padding: $sp-2 0;
box-shadow: $shadow-raised; touch-action: none; cursor: grab;
}
.sf__indicator {
position: absolute; left: 0; top: 8px;
border-radius: $r-pill; background: $subject-blue-light; will-change: transform;
}
.sf__tab {
position: relative; z-index: 1; 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);
}
.sf__tab.is-active { color: $subject-blue-deep; }
.sf__icon { width: 18px; height: 18px; }
.sf__label { font-size: 10px; font-weight: 700; line-height: 1; }
@media (max-width: 340px) {
.sf__phone { max-width: 230px; }
.sf__tab { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sf__indicator { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sf {
display: contents;
}
.sf__phone {
width: 100%;
max-width: 300px;
position: relative;
height: 96px;
display: flex;
align-items: center;
}
.sf__track {
position: relative;
width: 100%;
display: flex;
justify-content: space-around;
background: #fff7e6;
border-radius: 16px;
padding: 8px 0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: grab;
}
.sf__indicator {
position: absolute;
left: 0;
top: 8px;
border-radius: 999px;
background: #5b8cf8;
will-change: transform;
}
.sf__tab {
position: relative;
z-index: 1;
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);
}
.sf__tab.is-active {
color: #0a4fe8;
}
.sf__icon {
width: 18px;
height: 18px;
}
.sf__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
@media (max-width: 340px) {
.sf__phone {
max-width: 230px;
}
.sf__tab {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sf__indicator {
transition: none;
}
}(function () {
var SPRING_K = 240; // 계산 겹: 정착(settle) 스프링 상수 — 드래그를 놓은 뒤 가장 가까운 탭으로 당긴다
var SPRING_C = 20; // 감쇠 상수
var DRAG_MS = 520; // 자동 루프에서 가상 손가락이 다음 탭까지 이동하는 시간
var WAIT_MS = 700; // 정착 뒤 머무는 시간
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var sf = document.getElementById('sf');
var track = document.getElementById('sfTrack');
var indicator = document.getElementById('sfIndicator');
var tabs = Array.prototype.slice.call(document.querySelectorAll('.sf__tab'));
var isDemo = true;
var geo = []; // 계산 겹: 탭마다 실제 렌더 폭·left 오프셋(getBoundingClientRect 실측)
var maxX = 0;
var indW = 0, indH = 0;
var activeIdx = 0;
var posX = 0; // 인디케이터 현재 x(px)
var vel = 0; // 정착 스프링 속도
var settling = false;
var settleTarget = 0;
var dragging = false;
var dragStartClientX = 0;
var dragStartX = 0;
var lastTs = 0;
function measure() {
var trackRect = track.getBoundingClientRect();
geo = tabs.map(function (t) {
var r = t.getBoundingClientRect();
return { left: r.left - trackRect.left, width: r.width, height: r.height };
});
var first = geo[0];
indW = first.width; indH = first.height;
indicator.style.width = indW + 'px';
indicator.style.height = indH + 'px';
maxX = geo[geo.length - 1].left;
posX = geo[activeIdx].left;
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
}
function human() {
if (isDemo) { sf.classList.remove('is-demo'); isDemo = false; }
}
function nearestIndex(centerX) {
var best = 0, bestDist = Infinity;
geo.forEach(function (g, i) {
var c = g.left + g.width / 2;
var d = Math.abs(c - centerX);
if (d < bestDist) { bestDist = d; best = i; }
});
return best;
}
function setActive(idx) {
activeIdx = idx;
tabs.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
}
function onPointerDown(e) {
human();
dragging = true;
settling = false;
dragStartClientX = e.clientX;
dragStartX = posX;
track.setPointerCapture && e.pointerId != null && track.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
if (!dragging) return;
var dx = e.clientX - dragStartClientX;
posX = Math.max(0, Math.min(maxX, dragStartX + dx));
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
var idx = nearestIndex(posX + indW / 2);
setActive(idx);
settling = true;
settleTarget = geo[idx].left;
vel = 0;
}
track.addEventListener('pointerdown', onPointerDown);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
tabs.forEach(function (t, i) {
t.addEventListener('click', function () {
human();
if (dragging) return;
setActive(i);
settling = true;
settleTarget = geo[i].left;
vel = 0;
});
});
var demoOrder = [2, 1, 3, 0]; // 자동 루프가 방문할 탭 순서
var demoStep = 0;
var demoPhase = 'wait'; // 'drag' | 'settle' | 'wait'
var demoElapsed = 0;
var demoFromIdx = 0;
function tick(ts) {
if (!lastTs) { lastTs = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo && !reduce && !dragging) {
demoElapsed += dt * 1000;
if (demoPhase === 'drag') {
var t = Math.min(1, demoElapsed / DRAG_MS);
var toIdx = demoOrder[demoStep];
var fromCenter = geo[demoFromIdx].left + indW / 2;
var toCenter = geo[toIdx].left + indW / 2;
var virtualCenter = fromCenter + (toCenter - fromCenter) * t;
posX = Math.max(0, Math.min(maxX, virtualCenter - indW / 2));
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
if (t >= 1) {
setActive(toIdx);
settling = true; settleTarget = geo[toIdx].left; vel = 0;
demoPhase = 'settle'; demoElapsed = 0;
}
} else if (demoPhase === 'settle') {
if (!settling) {
demoPhase = 'wait'; demoElapsed = 0;
}
} else if (demoPhase === 'wait') {
if (demoElapsed >= WAIT_MS) {
demoFromIdx = demoOrder[demoStep];
demoStep = (demoStep + 1) % demoOrder.length;
demoPhase = 'drag'; demoElapsed = 0;
}
}
}
if (settling) {
var acc = (settleTarget - posX) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
posX += vel * dt;
if (Math.abs(settleTarget - posX) < 0.4 && Math.abs(vel) < 4) {
posX = settleTarget; vel = 0; settling = false;
}
indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
}
requestAnimationFrame(tick);
}
measure();
window.addEventListener('resize', measure);
requestAnimationFrame(tick);
})();// SwipeFollow — vanilla 판(run/367/demo/07-bottom-nav-bar-swipe-follow/index.html) 의 React 포트.
// 드래그 중엔 포인터 x 를 그대로 clamp 해 인디케이터가 1:1 로 따라오고, 놓으면 가장 가까운
// 탭의 실측 rect(getBoundingClientRect) 로 스프링(SPRING_K·C) 정착한다.
import { useEffect, useRef } from "react";
import styles from "./SwipeFollow.module.scss";
type Box<T> = { current: T };
type Geo = { left: number; width: number; height: number };
const SPRING_K = 240; // 계산 겹: 정착(settle) 스프링 상수 — 드래그를 놓은 뒤 가장 가까운 탭으로 당긴다
const SPRING_C = 20; // 감쇠 상수
const DRAG_MS = 520; // 자동 루프에서 가상 손가락이 다음 탭까지 이동하는 시간
const WAIT_MS = 700; // 정착 뒤 머무는 시간
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: "M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12|.2#M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" },
{ 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>
);
}
export default function SwipeFollow() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackRef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorRef = useRef(null) as unknown as Box<HTMLDivElement>;
const tabRefs = useRef([null, 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;
const track = trackRef.current;
const indicator = indicatorRef.current;
const tabs = tabRefs.current;
if (!track || !indicator) return;
let isDemo = true;
let geo: Geo[] = [];
let maxX = 0, indW = 0, indH = 0;
let activeIdx = 0;
let posX = 0;
let vel = 0;
let settling = false;
let settleTarget = 0;
let dragging = false;
let dragStartClientX = 0;
let dragStartX = 0;
let lastTs = 0;
function measure() {
const trackRect = track!.getBoundingClientRect();
geo = tabs.map((t) => {
const r = t!.getBoundingClientRect();
return { left: r.left - trackRect.left, width: r.width, height: r.height };
});
const first = geo[0];
indW = first.width; indH = first.height;
indicator!.style.width = indW + "px";
indicator!.style.height = indH + "px";
maxX = geo[geo.length - 1].left;
posX = geo[activeIdx].left;
indicator!.style.transform = "translateX(" + posX.toFixed(1) + "px)";
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function nearestIndex(centerX: number) {
let best = 0, bestDist = Infinity;
geo.forEach((g, i) => {
const c = g.left + g.width / 2;
const d = Math.abs(c - centerX);
if (d < bestDist) { bestDist = d; best = i; }
});
return best;
}
function setActive(idx: number) {
activeIdx = idx;
tabs.forEach((t, i) => t?.classList.toggle(styles.isActive, i === idx));
}
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
settling = false;
dragStartClientX = e.clientX;
dragStartX = posX;
if (e.pointerId != null) {
try { track!.setPointerCapture(e.pointerId); } catch { /* noop */ }
}
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const dx = e.clientX - dragStartClientX;
posX = Math.max(0, Math.min(maxX, dragStartX + dx));
indicator!.style.transform = "translateX(" + posX.toFixed(1) + "px)";
}
function onPointerUp() {
if (!dragging) return;
dragging = false;
const idx = nearestIndex(posX + indW / 2);
setActive(idx);
settling = true;
settleTarget = geo[idx].left;
vel = 0;
}
track.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
const tabHandlers = tabs.map((_, i) => () => {
human();
if (dragging) return;
setActive(i);
settling = true;
settleTarget = geo[i].left;
vel = 0;
});
tabs.forEach((t, i) => t?.addEventListener("click", tabHandlers[i]));
const demoOrder = [2, 1, 3, 0]; // 자동 루프가 방문할 탭 순서
let demoStep = 0;
let demoPhase: "drag" | "settle" | "wait" = "wait";
let demoElapsed = 0;
let demoFromIdx = 0;
function tick(ts: number) {
if (!lastTs) { lastTs = ts; }
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo && !reduce && !dragging) {
demoElapsed += dt * 1000;
if (demoPhase === "drag") {
const t = Math.min(1, demoElapsed / DRAG_MS);
const toIdx = demoOrder[demoStep];
const fromCenter = geo[demoFromIdx].left + indW / 2;
const toCenter = geo[toIdx].left + indW / 2;
const virtualCenter = fromCenter + (toCenter - fromCenter) * t;
posX = Math.max(0, Math.min(maxX, virtualCenter - indW / 2));
indicator!.style.transform = "translateX(" + posX.toFixed(1) + "px)";
if (t >= 1) {
setActive(toIdx);
settling = true; settleTarget = geo[toIdx].left; vel = 0;
demoPhase = "settle"; demoElapsed = 0;
}
} else if (demoPhase === "settle") {
if (!settling) { demoPhase = "wait"; demoElapsed = 0; }
} else if (demoPhase === "wait") {
if (demoElapsed >= WAIT_MS) {
demoFromIdx = demoOrder[demoStep];
demoStep = (demoStep + 1) % demoOrder.length;
demoPhase = "drag"; demoElapsed = 0;
}
}
}
if (settling) {
const acc = (settleTarget - posX) * SPRING_K - vel * SPRING_C;
vel += acc * dt;
posX += vel * dt;
if (Math.abs(settleTarget - posX) < 0.4 && Math.abs(vel) < 4) {
posX = settleTarget; vel = 0; settling = false;
}
indicator!.style.transform = "translateX(" + posX.toFixed(1) + "px)";
}
rafRef.current = requestAnimationFrame(tick);
}
function onResize() { measure(); }
measure();
window.addEventListener("resize", onResize);
rafRef.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
track.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
tabs.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.track} ref={trackRef}>
<div className={styles.indicator} ref={indicatorRef} />
{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>
);
}07 · 스와이프 추종 전환 — 화면을 옆으로 미는 거리 값이 커지는 동안 강조 표시의 위치 값이 그 비율만큼 옆 탭 쪽으로 따라가는 방식 — 측정 누적면적 5.814% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08여백 값 대응 전환 — 화면 아래쪽 여백 값이 커지면 바의 전체 높이와 안쪽 여백 값이 그 차이만큼 함께 늘어나는 방식
홈 인디케이터 기기로 전환하면 insetTarget 값이 INSET_ON(34px, 실제 기기 실측이 아니라 이 데모가 가정한 값)으로 바뀌고, tick()은 LERP_RATE(10)로 insetPx 값을 그 목표까지 지수 평활해 render()가 BASE_H(58)와 더한 totalH 값으로 바의 전체 높이와 안쪽 여백을 다시 그립니다. 기기마다 다른 모바일 하단 탭바 사이즈에 맞추는 화면에 씁니다.
var BASE_H = 58; // px, 인셋 0일 때 바 콘텐츠 높이 — 계산 겹의 기준값
var INSET_ON = 34; // px, 계산 겹: 홈 인디케이터가 있는 기기의 안전영역 인셋(데모용 가정값)
var LERP_RATE = 10; // 인셋 값이 목표로 지수 평활되는 속도(1/s)
var CYCLE_MS = 2600; // 자동 루프 왕복 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var ai = document.getElementById('ai');
var nav = document.getElementById('aiNav');
var inset = document.getElementById('aiInset');
var indicator = document.getElementById('aiIndicator');
var readout = document.getElementById('aiReadout');
var toggle = document.getElementById('aiToggle');
var tabs = Array.prototype.slice.call(document.querySelectorAll('.ai__tab'));
var isDemo = true;
var manualOn = false;
var insetTarget = 0;
var insetPx = 0;
var lastTs = 0;
var cycleStart = 0;
function human() {
if (isDemo) { ai.classList.remove('is-demo'); isDemo = false; }
}

<main class="stage">
<div class="ai is-demo" id="ai">
<div class="ai__phone">
<div class="ai__screen">
<div class="ai__row"></div>
<div class="ai__row"></div>
<div class="ai__row"></div>
</div>
<div class="ai__nav" id="aiNav">
<button type="button" class="ai__tab is-active" data-idx="0">
<svg class="ai__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="ai__label">홈</span>
</button>
<button type="button" class="ai__tab" data-idx="1">
<svg class="ai__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="ai__label">쇼핑</span>
</button>
<button type="button" class="ai__tab" data-idx="2">
<svg class="ai__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="ai__label">프로필</span>
</button>
<div class="ai__inset" id="aiInset">
<span class="ai__homeIndicator" id="aiIndicator"></span>
</div>
</div>
</div>
<div class="ai__info">
<span id="aiReadout">인셋 0px · 바 높이 58px</span>
<button type="button" class="ai__toggle" id="aiToggle">홈 인디케이터 기기로 전환</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 안전영역 인셋 값(0↔34px)을 지수 평활로 따라가며, 바의 총 높이를
// "콘텐츠 높이 + 인셋" 으로 매 프레임 다시 계산해 style.height 에 직접 쓴다.
@include stage($stage-ink);
// .ai 는 is-demo 클래스만 들고 있는 래퍼 — display:contents 로 트리에서 지워
// .stage 그리드의 직계 아이템이 되게 한다(367-05 실측 버그 회피).
.ai { display: contents; }
.ai__phone {
width: 100%; max-width: 300px; position: relative;
border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; background: $subject-cream;
display: flex; flex-direction: column;
}
.ai__screen {
flex: 0 0 64px; padding: $sp-2; display: flex; flex-direction: column; gap: $sp-1;
}
.ai__row {
flex: 0 0 auto; height: 18px; border-radius: $r-sm; background: rgba($subject-blue, .16);
}
.ai__nav {
flex: 0 0 auto; position: relative; display: flex; justify-content: space-around;
align-items: flex-start; padding-top: $sp-1; background: $subject-cream;
border-top: 1px solid rgba($subject-ink, .12); box-shadow: $shadow-raised; will-change: height;
}
.ai__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);
}
.ai__tab.is-active { color: $subject-blue-deep; }
.ai__icon { width: 18px; height: 18px; }
.ai__label { font-size: 10px; font-weight: 700; line-height: 1; }
.ai__inset {
position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center;
justify-content: center; will-change: height; overflow: hidden;
}
.ai__homeIndicator {
width: 92px; height: 5px; border-radius: $r-pill; background: rgba($subject-ink, .55);
margin-bottom: $sp-1;
}
.ai__info {
display: flex; flex-direction: column; align-items: center; gap: $sp-2; margin-top: $sp-4;
color: $subject-cream; font-size: 12px; text-align: center;
}
.ai__toggle {
border: none; border-radius: $r-control; background: $subject-blue-deep; color: $subject-cream;
padding: $sp-2 $sp-4; font-size: 12px; font-weight: 700; cursor: pointer;
}
@media (max-width: 340px) {
// .ai 는 display:contents 로 .ai__phone·.ai__info 둘 다 .stage 의 직계 그리드 아이템이라
// 두 블록 높이 합이 320px 예산(174.4px) 안에 들어와야 한다. .ai__nav 의 최대 높이(BASE_H+INSET_ON
// 실측 약 97px, JS 실측값이라 안 줄인다)는 고정이므로 .ai__screen·.ai__info 를 인셋 최대 상태로
// Playwright 실측해 더 줄였다 — 패딩·행간격 0, 행 높이 8px, info 는 세로 2줄 대신 가로 1줄 (BD9 재실측).
.ai__phone { max-width: 230px; }
.ai__screen { flex: 0 0 auto; padding: 0; gap: 0; }
.ai__row { height: 8px; }
.ai__info {
flex-direction: row; align-items: center; justify-content: center;
margin-top: $sp-1; gap: $sp-1; font-size: 10px;
}
.ai__toggle { padding: $sp-1 $sp-2; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ai__nav, .ai__inset, .ai__homeIndicator { 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);
}
.ai {
display: contents;
}
.ai__phone {
width: 100%;
max-width: 300px;
position: relative;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
display: flex;
flex-direction: column;
}
.ai__screen {
flex: 0 0 64px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.ai__row {
flex: 0 0 auto;
height: 18px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.16);
}
.ai__nav {
flex: 0 0 auto;
position: relative;
display: flex;
justify-content: space-around;
align-items: flex-start;
padding-top: 4px;
background: #fff7e6;
border-top: 1px solid rgba(23, 20, 26, 0.12);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: height;
}
.ai__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);
}
.ai__tab.is-active {
color: #0a4fe8;
}
.ai__icon {
width: 18px;
height: 18px;
}
.ai__label {
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.ai__inset {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
will-change: height;
overflow: hidden;
}
.ai__homeIndicator {
width: 92px;
height: 5px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.55);
margin-bottom: 4px;
}
.ai__info {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-top: 16px;
color: #fff7e6;
font-size: 12px;
text-align: center;
}
.ai__toggle {
border: none;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
padding: 8px 16px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
@media (max-width: 340px) {
.ai__phone {
max-width: 230px;
}
.ai__screen {
flex: 0 0 auto;
padding: 0;
gap: 0;
}
.ai__row {
height: 8px;
}
.ai__info {
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: 4px;
gap: 4px;
font-size: 10px;
}
.ai__toggle {
padding: 4px 8px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ai__nav, .ai__inset, .ai__homeIndicator {
transition: none;
}
}(function () {
var BASE_H = 58; // px, 인셋 0일 때 바 콘텐츠 높이 — 계산 겹의 기준값
var INSET_ON = 34; // px, 계산 겹: 홈 인디케이터가 있는 기기의 안전영역 인셋(데모용 가정값)
var LERP_RATE = 10; // 인셋 값이 목표로 지수 평활되는 속도(1/s)
var CYCLE_MS = 2600; // 자동 루프 왕복 주기
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var ai = document.getElementById('ai');
var nav = document.getElementById('aiNav');
var inset = document.getElementById('aiInset');
var indicator = document.getElementById('aiIndicator');
var readout = document.getElementById('aiReadout');
var toggle = document.getElementById('aiToggle');
var tabs = Array.prototype.slice.call(document.querySelectorAll('.ai__tab'));
var isDemo = true;
var manualOn = false;
var insetTarget = 0;
var insetPx = 0;
var lastTs = 0;
var cycleStart = 0;
function human() {
if (isDemo) { ai.classList.remove('is-demo'); isDemo = false; }
}
function render() {
// 계산 겹: 바 전체 높이는 콘텐츠 높이(BASE_H) + 실시간 인셋 값을 더해 매번 다시 계산한다
var totalH = BASE_H + insetPx;
nav.style.height = totalH.toFixed(1) + 'px';
inset.style.height = insetPx.toFixed(1) + 'px';
var t = insetPx / INSET_ON;
indicator.style.opacity = t.toFixed(3);
indicator.style.transform = 'translateY(' + ((1 - t) * 6).toFixed(1) + 'px) scaleX(' + (0.6 + 0.4 * t).toFixed(3) + ')';
readout.textContent = '인셋 ' + Math.round(insetPx) + 'px · 바 높이 ' + Math.round(totalH) + 'px';
}
toggle.addEventListener('click', function () {
human();
manualOn = !manualOn;
insetTarget = manualOn ? INSET_ON : 0;
toggle.textContent = manualOn ? '홈 버튼 기기로 전환' : '홈 인디케이터 기기로 전환';
});
toggle.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); }
});
tabs.forEach(function (t) {
t.addEventListener('click', function () {
human();
tabs.forEach(function (o) { o.classList.remove('is-active'); });
t.classList.add('is-active');
});
});
function tick(ts) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo && !reduce) {
var cycleT = (ts - cycleStart) % CYCLE_MS;
insetTarget = cycleT < CYCLE_MS / 2 ? INSET_ON : 0;
}
insetPx += (insetTarget - insetPx) * (1 - Math.exp(-LERP_RATE * dt));
render();
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();// SafeAreaInsetResize — vanilla 판(run/367/demo/08-bottom-nav-bar-safe-area-inset-resize/index.html) 의
// React 포트. 계산 겹: 안전영역 인셋 값(0↔34px)을 지수 평활로 따라가며, 바의 총 높이를
// "콘텐츠 높이 + 인셋" 으로 매 프레임 다시 계산해 style.height 에 직접 쓴다.
import { useEffect, useRef } from "react";
import styles from "./SafeAreaInsetResize.module.scss";
type Box<T> = { current: T };
const BASE_H = 58; // px, 인셋 0일 때 바 콘텐츠 높이 — 계산 겹의 기준값
const INSET_ON = 34; // px, 계산 겹: 홈 인디케이터가 있는 기기의 안전영역 인셋(데모용 가정값)
const LERP_RATE = 10; // 인셋 값이 목표로 지수 평활되는 속도(1/s)
const CYCLE_MS = 2600; // 자동 루프 왕복 주기
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>
);
}
export default function SafeAreaInsetResize() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const navRef = useRef(null) as unknown as Box<HTMLDivElement>;
const insetRef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const toggleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
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 manualOn = false;
let insetTarget = 0;
let insetPx = 0;
let lastTs = 0, cycleStart = 0;
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function render() {
const nav = navRef.current, inset = insetRef.current;
const indicator = indicatorRef.current, readout = readoutRef.current;
if (!nav || !inset || !indicator || !readout) return;
const totalH = BASE_H + insetPx;
nav.style.height = totalH.toFixed(1) + "px";
inset.style.height = insetPx.toFixed(1) + "px";
const t = insetPx / INSET_ON;
indicator.style.opacity = t.toFixed(3);
indicator.style.transform = "translateY(" + ((1 - t) * 6).toFixed(1) + "px) scaleX(" + (0.6 + 0.4 * t).toFixed(3) + ")";
readout.textContent = "인셋 " + Math.round(insetPx) + "px · 바 높이 " + Math.round(totalH) + "px";
}
function onToggleClick() {
human();
manualOn = !manualOn;
insetTarget = manualOn ? INSET_ON : 0;
if (toggleRef.current) {
toggleRef.current.textContent = manualOn ? "홈 버튼 기기로 전환" : "홈 인디케이터 기기로 전환";
}
}
function onToggleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleRef.current?.click(); }
}
toggleRef.current?.addEventListener("click", onToggleClick);
toggleRef.current?.addEventListener("keydown", onToggleKeydown);
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 tick(ts: number) {
if (!lastTs) { lastTs = ts; cycleStart = ts; }
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (isDemo && !reduce) {
const cycleT = (ts - cycleStart) % CYCLE_MS;
insetTarget = cycleT < CYCLE_MS / 2 ? INSET_ON : 0;
}
insetPx += (insetTarget - insetPx) * (1 - Math.exp(-LERP_RATE * dt));
render();
rafRef.current = requestAnimationFrame(tick);
}
render();
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafRef.current);
toggleRef.current?.removeEventListener("click", onToggleClick);
toggleRef.current?.removeEventListener("keydown", onToggleKeydown);
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.screen}>
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
</div>
<div className={styles.nav} ref={navRef}>
{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 className={styles.inset} ref={insetRef}>
<span className={styles.homeIndicator} ref={indicatorRef} />
</div>
</div>
</div>
<div className={styles.info}>
<span ref={readoutRef}>인셋 0px · 바 높이 58px</span>
<button type="button" className={styles.toggle} ref={toggleRef}>홈 인디케이터 기기로 전환</button>
</div>
</div>
</main>
);
}08 · 여백 값 대응 전환 — 화면 아래쪽 여백 값이 커지면 바의 전체 높이와 안쪽 여백 값이 그 차이만큼 함께 늘어나는 방식 — 측정 누적면적 22.792% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09모션 감소 즉시 전환 — 모션 감소 설정이 켜지면 앞선 여덟 방식의 전환이 전부 중간 단계 없이 목표 값으로 바뀌는 방식
왼쪽 판(A)은 easeOutCubic()을 rAF로 직접 적분해 DURATION_MS(320ms) 동안 translateX 값을 매끄럽게 옮기고, 오른쪽 판(B)은 계산 없이 목표 좌표를 즉시 대입해 두 판을 나란히 두고 대조합니다. 접근성 설정을 켠 사용자의 화면이나 애니메이션을 줄이고 싶은 내비게이션을 설계할 때 참고할 수 있습니다.
var DURATION_MS = 320; // 계산 겹: 일반 패널의 전환 실제 시간(ms) — CSS transition 이 아니라 rAF 로 직접 적분
var CYCLE_MS = 700; // 자동 루프가 좌우 탭을 오가는 간격
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var rm = document.getElementById('rm');
var trackA = document.getElementById('rmTrackA');
var indicatorA = document.getElementById('rmIndicatorA');
var readoutA = document.getElementById('rmReadoutA');
var trackB = document.getElementById('rmTrackB');
var indicatorB = document.getElementById('rmIndicatorB');
var readoutB = document.getElementById('rmReadoutB');
var tabsA = Array.prototype.slice.call(trackA.querySelectorAll('.rm__tab'));
var tabsB = Array.prototype.slice.call(trackB.querySelectorAll('.rm__tab'));
var isDemo = true;
function human() {
if (isDemo) { rm.classList.remove('is-demo'); isDemo = false; }
}

<main class="stage">
<div class="rm is-demo" id="rm">
<div class="rm__panel" data-role="normal">
<p class="rm__caption">일반 전환</p>
<div class="rm__track" id="rmTrackA">
<div class="rm__indicator rm__indicator--a" id="rmIndicatorA"></div>
<button type="button" class="rm__tab is-active" data-idx="0">
<svg class="rm__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="rm__label">홈</span>
</button>
<button type="button" class="rm__tab" data-idx="1">
<svg class="rm__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="rm__label">쇼핑</span>
</button>
</div>
<p class="rm__readout" id="rmReadoutA">0 / 320ms</p>
</div>
<div class="rm__panel" data-role="reduced">
<p class="rm__caption">축소 모션 전환</p>
<div class="rm__track" id="rmTrackB">
<div class="rm__indicator rm__indicator--b" id="rmIndicatorB"></div>
<button type="button" class="rm__tab is-active" data-idx="0">
<svg class="rm__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="rm__label">홈</span>
</button>
<button type="button" class="rm__tab" data-idx="1">
<svg class="rm__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="rm__label">쇼핑</span>
</button>
</div>
<p class="rm__readout" id="rmReadoutB">0ms</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 일반 패널은 easeOutCubic 을 rAF 로 직접 적분해 실제 경과시간(ms)을
// 화면에 그대로 찍고, 축소 패널은 보간 없이 즉시 놓은 뒤 perf.now 차로 실제
// 지연시간을 측정해 찍는다 — 두 패널 다 CSS transition 이 아니라 계산이 위치를 만든다.
@include stage($stage-paper);
// .rm 은 is-demo 클래스와 실제 레이아웃 규칙(너비·flex)을 함께 들고 있는 그리드
// 직계 아이템이다 — is-demo 만 들고 아무 규칙도 없는 빈 래퍼를 한 겹 더 두면
// .stage 의 grid place-items:center 밑에서 자식 width:100% 가 0으로 무너진다
// (367-05 실측). 여기서는 그 빈 래퍼 자체를 두지 않는다.
.rm {
width: 100%; max-width: 420px; display: flex; gap: $sp-4; justify-content: center;
}
.rm__panel {
flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: $sp-2;
}
.rm__caption { font-size: 11px; font-weight: 700; color: $subject-ink; margin: 0; }
.rm__track {
position: relative; width: 100%; display: flex; justify-content: space-around;
background: $subject-blue-light; border-radius: $r-card; padding: $sp-2 0;
box-shadow: $shadow-raised;
}
.rm__panel[data-role="reduced"] .rm__track { background: $subject-mint; }
.rm__indicator {
position: absolute; left: 0; top: 6px; border-radius: $r-pill; background: $subject-cream;
will-change: transform;
}
.rm__tab {
position: relative; z-index: 1; 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-2; color: rgba($subject-ink, .72); transition: color .15s ease;
}
.rm__tab.is-active { color: $subject-blue-deep; }
.rm__icon { width: 16px; height: 16px; }
.rm__label { font-size: 9px; font-weight: 700; line-height: 1; }
.rm__readout { font-size: 11px; color: $subject-ink; margin: 0; font-weight: 600; }
@media (max-width: 340px) {
.rm { gap: $sp-2; }
.rm__tab { padding: $sp-1; }
}
// 장식 요소(탭 글자색 전환)만 줄인다 — 일반 패널의 비교용 이동 자체는 rAF 계산이라
// 이 블록과 무관하게 항상 그대로 재생된다(비교를 보여주는 목적이 실제 설정보다 먼저다).
@media (prefers-reduced-motion: reduce) {
.rm__tab { 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);
}
.rm {
width: 100%;
max-width: 420px;
display: flex;
gap: 16px;
justify-content: center;
}
.rm__panel {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.rm__caption {
font-size: 11px;
font-weight: 700;
color: #17141a;
margin: 0;
}
.rm__track {
position: relative;
width: 100%;
display: flex;
justify-content: space-around;
background: #5b8cf8;
border-radius: 16px;
padding: 8px 0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rm__panel[data-role=reduced] .rm__track {
background: #4cd4a6;
}
.rm__indicator {
position: absolute;
left: 0;
top: 6px;
border-radius: 999px;
background: #fff7e6;
will-change: transform;
}
.rm__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 4px 8px;
color: rgba(23, 20, 26, 0.72);
transition: color 0.15s ease;
}
.rm__tab.is-active {
color: #0a4fe8;
}
.rm__icon {
width: 16px;
height: 16px;
}
.rm__label {
font-size: 9px;
font-weight: 700;
line-height: 1;
}
.rm__readout {
font-size: 11px;
color: #17141a;
margin: 0;
font-weight: 600;
}
@media (max-width: 340px) {
.rm {
gap: 8px;
}
.rm__tab {
padding: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__tab {
transition: none;
}
}(function () {
var DURATION_MS = 320; // 계산 겹: 일반 패널의 전환 실제 시간(ms) — CSS transition 이 아니라 rAF 로 직접 적분
var CYCLE_MS = 700; // 자동 루프가 좌우 탭을 오가는 간격
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var rm = document.getElementById('rm');
var trackA = document.getElementById('rmTrackA');
var indicatorA = document.getElementById('rmIndicatorA');
var readoutA = document.getElementById('rmReadoutA');
var trackB = document.getElementById('rmTrackB');
var indicatorB = document.getElementById('rmIndicatorB');
var readoutB = document.getElementById('rmReadoutB');
var tabsA = Array.prototype.slice.call(trackA.querySelectorAll('.rm__tab'));
var tabsB = Array.prototype.slice.call(trackB.querySelectorAll('.rm__tab'));
var isDemo = true;
function human() {
if (isDemo) { rm.classList.remove('is-demo'); isDemo = false; }
}
function measure(track, tabs) {
var trackRect = track.getBoundingClientRect();
return tabs.map(function (t) {
var r = t.getBoundingClientRect();
return { left: r.left - trackRect.left, width: r.width, height: r.height };
});
}
// ---- 패널 A: 일반 전환 — easeOutCubic 을 rAF 로 직접 적분해 진짜 경과시간을 잰다.
// 실제 OS/브라우저의 prefers-reduced-motion 설정과 무관하게 항상 이 계산을 수행한다.
var geoA = measure(trackA, tabsA);
indicatorA.style.width = geoA[0].width + 'px';
indicatorA.style.height = geoA[0].height + 'px';
var activeA = 0;
var fromXA = geoA[0].left, toXA = geoA[0].left;
var transStartA = 0;
var transitioningA = false;
indicatorA.style.transform = 'translateX(' + geoA[0].left.toFixed(1) + 'px)';
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function goToA(idx, ts) {
activeA = idx;
tabsA.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
fromXA = currentXA(ts);
toXA = geoA[idx].left;
transStartA = ts;
transitioningA = true;
}
function currentXA(ts) {
if (!transitioningA) return toXA;
var t = Math.min(1, (ts - transStartA) / DURATION_MS);
return fromXA + (toXA - fromXA) * easeOutCubic(t);
}
// ---- 패널 B: 축소 모션 전환 — 보간 없이 즉시 위치를 놓고, 실제 지연시간(perf.now 차)만 잰다.
// 이 패널은 늘 즉시 전환한다는 사실을 보여주는 쪽이라 실제 설정과 무관하게 동작을 고정한다.
var geoB = measure(trackB, tabsB);
indicatorB.style.width = geoB[0].width + 'px';
indicatorB.style.height = geoB[0].height + 'px';
var activeB = 0;
var switchAtB = 0;
var latencyMeasured = true;
indicatorB.style.transform = 'translateX(' + geoB[0].left.toFixed(1) + 'px)';
function goToB(idx, ts) {
activeB = idx;
tabsB.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
indicatorB.style.transform = 'translateX(' + geoB[idx].left.toFixed(1) + 'px)';
switchAtB = ts;
latencyMeasured = false;
}
tabsA.forEach(function (t, i) {
t.addEventListener('click', function () { human(); if (i !== activeA) goToA(i, performance.now()); });
});
tabsB.forEach(function (t, i) {
t.addEventListener('click', function () { human(); if (i !== activeB) goToB(i, performance.now()); });
});
var cycleStart = 0;
var lastCycleIdx = 0;
function tick(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
var step = Math.floor((ts - cycleStart) / CYCLE_MS);
var idx = step % 2;
if (idx !== lastCycleIdx) {
lastCycleIdx = idx;
if (idx !== activeA) goToA(idx, ts);
if (idx !== activeB) goToB(idx, ts);
}
}
if (transitioningA) {
var x = currentXA(ts);
indicatorA.style.transform = 'translateX(' + x.toFixed(1) + 'px)';
var elapsed = Math.min(DURATION_MS, ts - transStartA);
readoutA.textContent = Math.round(elapsed) + ' / ' + DURATION_MS + 'ms';
if (elapsed >= DURATION_MS) { transitioningA = false; }
}
if (!latencyMeasured) {
var lat = ts - switchAtB;
readoutB.textContent = Math.round(lat) + 'ms (1프레임)';
latencyMeasured = true;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();// ReducedMotionInstant — vanilla 판(run/367/demo/09-bottom-nav-bar-reduced-motion-instant/index.html) 의
// React 포트. 패널 A(일반)는 easeOutCubic 을 rAF 로 직접 적분해 실제 경과시간(ms)을 화면에
// 그대로 찍고, 패널 B(축소)는 보간 없이 즉시 놓은 뒤 perf.now 차로 실제 지연시간을 측정해
// 찍는다 — 두 패널 다 CSS transition 이 아니라 계산이 위치를 만든다.
import { useEffect, useRef } from "react";
import styles from "./ReducedMotionInstant.module.scss";
type Box<T> = { current: T };
type Geo = { left: number; width: number; height: number };
const DURATION_MS = 320; // 계산 겹: 일반 패널의 전환 실제 시간(ms) — CSS transition 이 아니라 rAF 로 직접 적분
const CYCLE_MS = 700; // 자동 루프가 좌우 탭을 오가는 간격
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" },
];
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 easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); }
export default function ReducedMotionInstant() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackARef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorARef = useRef(null) as unknown as Box<HTMLDivElement>;
const readoutARef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const tabARefs = useRef([null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const trackBRef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorBRef = useRef(null) as unknown as Box<HTMLDivElement>;
const readoutBRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const tabBRefs = useRef([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;
const trackA = trackARef.current, indicatorA = indicatorARef.current, readoutA = readoutARef.current;
const trackB = trackBRef.current, indicatorB = indicatorBRef.current, readoutB = readoutBRef.current;
const tabsA = tabARefs.current, tabsB = tabBRefs.current;
if (!trackA || !indicatorA || !readoutA || !trackB || !indicatorB || !readoutB) return;
let isDemo = true;
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function measure(track: HTMLDivElement, tabs: (HTMLButtonElement | null)[]): Geo[] {
const trackRect = track.getBoundingClientRect();
return tabs.map((t) => {
const r = t!.getBoundingClientRect();
return { left: r.left - trackRect.left, width: r.width, height: r.height };
});
}
// ---- 패널 A: 일반 전환
const geoA = measure(trackA, tabsA);
indicatorA.style.width = geoA[0].width + "px";
indicatorA.style.height = geoA[0].height + "px";
let activeA = 0;
let fromXA = geoA[0].left, toXA = geoA[0].left;
let transStartA = 0;
let transitioningA = false;
indicatorA.style.transform = "translateX(" + geoA[0].left.toFixed(1) + "px)";
function currentXA(ts: number) {
if (!transitioningA) return toXA;
const t = Math.min(1, (ts - transStartA) / DURATION_MS);
return fromXA + (toXA - fromXA) * easeOutCubic(t);
}
function goToA(idx: number, ts: number) {
activeA = idx;
tabsA.forEach((t, i) => t?.classList.toggle(styles.isActive, i === idx));
fromXA = currentXA(ts);
toXA = geoA[idx].left;
transStartA = ts;
transitioningA = true;
}
// ---- 패널 B: 축소 모션 전환
const geoB = measure(trackB, tabsB);
indicatorB.style.width = geoB[0].width + "px";
indicatorB.style.height = geoB[0].height + "px";
let activeB = 0;
let switchAtB = 0;
let latencyMeasured = true;
indicatorB.style.transform = "translateX(" + geoB[0].left.toFixed(1) + "px)";
function goToB(idx: number, ts: number) {
activeB = idx;
tabsB.forEach((t, i) => t?.classList.toggle(styles.isActive, i === idx));
indicatorB.style.transform = "translateX(" + geoB[idx].left.toFixed(1) + "px)";
switchAtB = ts;
latencyMeasured = false;
}
const handlersA = tabsA.map((_, i) => () => { human(); if (i !== activeA) goToA(i, performance.now()); });
tabsA.forEach((t, i) => t?.addEventListener("click", handlersA[i]));
const handlersB = tabsB.map((_, i) => () => { human(); if (i !== activeB) goToB(i, performance.now()); });
tabsB.forEach((t, i) => t?.addEventListener("click", handlersB[i]));
let cycleStart = 0;
let lastCycleIdx = 0;
function tick(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
const step = Math.floor((ts - cycleStart) / CYCLE_MS);
const idx = step % 2;
if (idx !== lastCycleIdx) {
lastCycleIdx = idx;
if (idx !== activeA) goToA(idx, ts);
if (idx !== activeB) goToB(idx, ts);
}
}
if (transitioningA) {
const x = currentXA(ts);
indicatorA.style.transform = "translateX(" + x.toFixed(1) + "px)";
const elapsed = Math.min(DURATION_MS, ts - transStartA);
readoutA.textContent = Math.round(elapsed) + " / " + DURATION_MS + "ms";
if (elapsed >= DURATION_MS) { transitioningA = false; }
}
if (!latencyMeasured) {
const lat = ts - switchAtB;
readoutB.textContent = Math.round(lat) + "ms (1프레임)";
latencyMeasured = true;
}
rafRef.current = requestAnimationFrame(tick);
}
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafRef.current);
tabsA.forEach((t, i) => t?.removeEventListener("click", handlersA[i]));
tabsB.forEach((t, i) => t?.removeEventListener("click", handlersB[i]));
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + " " + styles.isDemo} ref={rootRef}>
<div className={styles.panel}>
<p className={styles.caption}>일반 전환</p>
<div className={styles.track} ref={trackARef}>
<div className={styles.indicatorA} ref={indicatorARef} />
{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) => { tabARefs.current[i] = el; }}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
<p className={styles.readout} ref={readoutARef}>0 / 320ms</p>
</div>
<div className={styles.panel}>
<p className={styles.caption}>축소 모션 전환</p>
<div className={styles.track + " " + styles.trackReduced} ref={trackBRef}>
<div className={styles.indicatorB} ref={indicatorBRef} />
{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) => { tabBRefs.current[i] = el; }}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
<p className={styles.readout} ref={readoutBRef}>0ms</p>
</div>
</div>
</main>
);
}09 · 모션 감소 즉시 전환 — 모션 감소 설정이 켜지면 앞선 여덟 방식의 전환이 전부 중간 단계 없이 목표 값으로 바뀌는 방식 — 측정 누적면적 5.072% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 자리와 크기를 바꾸는 곳 — 선택 값·수량 값·스크롤 방향 값 기준
선택 값 하나로 자리·채움을 바꾸는 건 01·02·07입니다. 01은 activeIdx가 바뀌는 순간 인디케이터의 좌표·너비를 스프링으로 당기고, 02는 같은 값으로 아이콘 채움 정도를 지수 평활하며, 07은 스와이프 중 실시간 비율(dragRatio)로, 뗀 뒤에는 스프링으로 같은 선택 값을 좌표로 바꿉니다. 셋 다 같은 activeIdx 계열 값을 쓰지만 언제(클릭 즉시·드래그 도중·드래그 후)와 어떻게(스프링·평활) 바뀌는지는 서로 다릅니다.
수량 값이 기준인 건 03입니다. 클릭마다 오르는 count 값 자체는 즉시 바뀌지만, 화면에 보이는 스케일·위치 값은 BUMP_ZETA 펄스와 SPRING_K 스프링을 거쳐 뒤늦게 바뀝니다. 04는 수량이 아니라 중앙 자리의 on/off 값(lifted) 하나가 기준이고, 그 값이 스프링을 거쳐 높이·크기·그림자 세 가지를 동시에 계산해 냅니다.
스크롤 방향·눌린 시간·여백 값이 기준인 건 05·06·08입니다. 05는 scrollTop 변화량에서 구한 속도(vel)의 부호가 기준이고, 06은 heldMs가 HOLD_MS를 넘었는지가 기준이며, 08은 insetTarget이 0인지 INSET_ON인지가 기준입니다. 09는 앞선 값 계산을 스스로 갖지 않고, 01과 같은 계산이 있는 판과 계산 없는 판을 나란히 비교하는 값만 다룹니다.
아홉 개 패턴의 html·scss·js 소스 전부가 비번 zwxer7f5 으로 열리는 zip 안에 들어 있어, 위에서 짚은 01의 스프링 계산과 06의 선형 진행 계산을 직접 코드에서 확인하고 바꿔볼 수 있습니다.
어디서 깨지는가 — 빠른 연속 전환, 안전 여백 값, 모션 감소
빠른 연속 전환에서 가장 눈에 띄게 갈리는 건 01·07입니다. 탭을 여러 번 빠르게 눌러 activeIdx가 계속 바뀌면 springTo()의 목표(target)가 매번 바뀌어 인디케이터가 이전 목표에 닿기도 전에 방향을 트는데, 감쇠(SPRING_C)가 충분해 튕겨나가지는 않지만 화면상으로는 거의 멈추지 않고 계속 미끄러집니다. 07도 손가락으로 빠르게 여러 탭을 오가며 밀면 settling 스프링이 매번 새 목표로 다시 시작해 같은 방식으로 계속 미끄러집니다.
안전 여백 값(INSET_ON)은 08의 기준 자체가 실제 기기 실측이 아니라는 점에서 깨지기 쉽습니다. 이 34px는 이 데모가 가정한 값일 뿐이라, 실제 서비스에 쓸 때는 각 기기의 안전영역 값을 CSS env(safe-area-inset-bottom) 같은 실제 값으로 다시 재야 합니다. 06은 반대로 너무 짧게 눌렀다 떼는 순간에 깨지는데, heldMs가 HOLD_MS 근처에서 손을 떼면 progress 값이 절반쯤에서 멈춘 채 링이 서서히 되돌아가 사용자가 "눌렀는데 왜 안 열리지" 라고 느낄 수 있습니다.
모션 감소 처리는 01~08 전부 자바스크립트에서 prefers-reduced-motion 미디어 쿼리 값을 matchMedia()로 한 번 읽어 두지만, 그 값을 쓰는 방식은 둘로 나뉩니다. 01·02·03·04·05는 그 값이 참이면 requestAnimationFrame 루프 자체를 시작하지 않고 render()를 한 번만 호출해 최종 모습으로 즉시 넘어가는 반면, 06·07·08은 루프는 그대로 돌리고 자동 재생이 목표 값을 바꾸는 부분만 건너뛰어 실제 입력에 대한 지수 평활·선형 진행은 모션 감소와 무관하게 그대로 이어집니다. 09만 예외로 시스템 reduce 값과 무관하게 두 판이 항상 나란히 돌아 계산이 있는 경우와 없는 경우를 계속 대조해 보여줍니다.
이 하단 탭바 구성 자체를 다른 내비게이션과 비교하면 이 편의 계산 축이 더 뚜렷해집니다. CSS 탭 메뉴 전환 애니메이션 9종은 탭 메뉴가 전환되는 시각 연출 자체를 다루는 반면, 이 편은 그 전환이 이 바의 상태 값(활성 인덱스)에서 어떻게 파생되는지를 다룬다는 점에서 계산 축이 다릅니다. 플로팅 내비게이션 독 스타일 9종은 화면에 떠 있는 독이 마우스·터치에 반응하는 계산을 다루는데, 이 편은 화면 바닥에 고정된 채 스크롤 방향·여백 같은 값에서 위치·크기를 계산한다는 점에서 기준 값 자체가 다릅니다. 메가 메뉴 UI 9종은 메뉴 항목이 여러 겹으로 펼쳐지는 구성을 다루는 반면, 이 편은 배지 수량·중앙 융기처럼 단일 상태 값 하나가 크기·위치로 바뀌는 계산만 다룬다는 점에서 다룹니다.
변형 세 가지와 접근성
이 바를 자기 화면에 맞게 손볼 때는 수치 하나만 바꿔도 느낌이 크게 달라집니다.
| 이름 | 값 | 느낌 |
|---|---|---|
| 중앙 버튼 융기 배율 키우기 | 04 SCALE_MAX 1.14 → 1.28 | 배율을 1.14에서 1.28로 올리면 중앙 탭이 선택되는 순간 훨씬 크게 튀어 올라 주요 동작이라는 인상이 강해진다. |
| 길게 누르는 기준 시간 늘리기 | 06 HOLD_MS 650 → 1200 | 기준 시간을 650에서 1200ms로 늘리면 링이 다 차는 데 거의 두 배가 걸려 실수로 눌러 메뉴가 열리는 일이 줄어든다. |
| 인셋 반응 속도 늦추기 | 08 LERP_RATE 10 → 4 | 평활 속도를 10에서 4로 낮추면 바 높이가 목표 값을 향해 훨씬 느긋하게 늘어나 여백 전환이 덜 갑작스럽게 느껴진다. |
접근성은 01·02가 가장 갖춰진 쪽입니다. 이 둘만 탭 버튼에 aria-current를 걸어 두고, 선택 값이 바뀔 때마다 t.setAttribute('aria-current', i === idx ? 'true' : 'false')로 값을 다시 매겨 스크린리더에도 지금 선택된 탭을 알립니다. 03·04·05·06·07·08은 탭 버튼에 is-active 클래스만 토글할 뿐 aria-current·aria-selected 같은 속성이 하나도 없어, 시각적으로는 선택 상태가 보여도 스크린리더로는 어느 탭이 활성인지 알 방법이 없습니다 — 실제 화면에 이 점을 그대로 옮기면 접근성이 01·02보다 떨어지니 aria-current 정도는 더해 주는 게 좋습니다. 06의 액션 버튼 세 개는 예외로 각각 aria-label("알림"·"메시지"·"좋아요")을 갖고 있어, 아이콘만으로는 구분되지 않는 의미를 텍스트로 보완합니다.
FAQ
값이 빠르게 여러 번 바뀌면 애니메이션이 항상 깨지나요?
아니요. 01·07처럼 목표 값이 계속 바뀌는 항목은 스프링 감쇠(SPRING_C)가 있어 튕겨나가지는 않고 방향만 계속 바뀌며 미끄러지듯 따라갑니다. 다만 06처럼 진행 값이 한 방향으로만 쌓이다가 중간에 멈추는 구조에서는 링이 절반쯤에서 되돌아가는 어색함이 그대로 드러납니다.
08처럼 INSET_ON 34px을 그대로 써도 되나요?
아니요. 08의 INSET_ON(34px)은 이 데모가 계산 구조를 보여주기 위해 가정한 값일 뿐, 실제 기기의 안전영역 실측값이 아닙니다. 실제 서비스에 쓸 때는 이 값을 고정하지 말고 각 기기·브라우저가 제공하는 안전영역 값으로 다시 재서 대입해야 합니다.
이 바를 처음 만들 때 아홉 개 중 어디부터 손대는 게 좋나요?
01이나 03부터 시작하는 걸 권합니다. 둘 다 목표 값 하나가 스프링 계산 한 가지로만 자리·크기를 바꾸기 때문에, 06처럼 선형 진행과 삼각함수 좌표가 같이 있거나 08처럼 인셋 값과 높이 값이 함께 바뀌는 구조보다 상수 하나가 결과에 미치는 영향을 눈으로 확인하기가 훨씬 쉽습니다.









