06길게 눌러 펼치기 전환 — 누름 지속 시간 값이 기준을 넘으면 그 자리 위로 펼침 정도 값이 커지며 추가 자리가 드러나는 방식
누름 지속 시간 값이 기준을 넘는 순간 그 자리 위쪽의 펼침 정도 값이 0에서 최대로 바뀝니다.
쓰임탭마다 하위 선택지를 숨겨 둔 내비게이션, 자주 쓰는 동작을 빠르게 꺼내는 바
FAN_ANGLESRING_CIRCstroke-dashoffset

<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 포함 · 모바일 하단 탭바 9종 — 상태 값으로 바뀌는 아홉 가지 전환
이 데모가 속한 글
모바일 하단 탭바 9종 — 상태 값으로 바뀌는 아홉 가지 전환모바일 하단 탭바는 화면 아래 고정된 채 상태 값 하나로 다시 계산해 그리는 자리인데, 아홉 가지 중 하나는 선택 값이 바뀌면 강조 표시의 위치와 너비 값이 새 자리로 함께 옮겨가고, 하나는 알림 수량 값이 올라가면 이 글의 데모 전부 보기


