컨텍스트 메뉴 UI 9종 — 클릭 좌표 값이 메뉴 위치를 정하는 방식
컨텍스트 메뉴 UI는 마우스 오른쪽 버튼이나 길게 누르기로 여는 작은 목록인데, 아홉 개 중 하나는 클릭한 화면 좌표 값이 메뉴가 나타날 위치 값을 그대로 정하고, 하나는 그 위치 값이 화면 가장자리에 가까운 상태가 되면 펼쳐지는 방향 값이 반대로 바뀌며, 또 하나는 하위 메뉴로
아홉 화면 다 실제 fetch 요청이나 clipboard 복사를 부르지 않는 값 계산 시뮬레이션이라, 메뉴 안 "복사"·"공유 → 메신저"·"링크 복사" 항목은 라벨만 있을 뿐 눌러도 실제로 무언가가 복사되지는 않습니다. 그래도 아홉 개 전부 리스너가 contextmenu·pointerdown·click·keydown 같은 실제 사용자 입력 그대로 등록돼 있어, 자동 재생 중이더라도 화면을 직접 우클릭하거나 눌러 보는 순간 그 자리에서 자동 루프가 멎고 손으로 넣은 값으로 같은 함수가 다시 계산합니다.
아홉 개를 가르는 기준은 값이 언제 어디서 나오느냐입니다 — 우클릭 메뉴가 뜨는 자리를 정하는 값(01·02·03), 손을 얼마나 오래 대고 있었는지를 재는 값(04·09), 순서나 상태가 바뀌면서 나오는 값(05·06·07·08)으로 나눴습니다.
01 context-menu-cursor-position-anchor
02 context-menu-viewport-edge-flip
03 context-menu-scale-fade-in
04 context-menu-submenu-cascade-open
05 context-menu-keyboard-arrow-highlight
06 context-menu-backdrop-dismiss-fade
08 context-menu-nested-depth-slide
09 context-menu-long-press-mobile-open
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 커서 위치 고정 — 클릭 좌표 값이 그대로 메뉴가 뜨는 자리 값이 되는 방식
- 02 화면 경계 반전 — 자리 값이 화면 끝에 닿으면 펼치는 방향 값이 반대로 바뀌는 방식
- 03 크기 페이드 등장 — 크기 값과 투명도 값이 함께 작은 값에서 커지며 나타나는 방식
- 04 하위 메뉴 순차 등장 — 상위 항목에 머무른 시간 값이 지나야 하위 목록이 펼쳐지는 방식
- 05 방향키 강조 이동 — 눌린 방향 값만큼 강조 자리 값이 목록 순서대로 옮겨가는 방식
- 06 바깥 클릭 닫힘 — 메뉴 밖을 클릭한 상태가 되면 투명도 값이 낮아지며 사라지는 방식
- 07 항목 순차 등장 — 각 줄의 순서 값만큼 지연된 뒤 자리 값이 채워지는 방식
- 08 깊이 밀림 전환 — 들어간 하위 단계 값만큼 메뉴 전체가 옆으로 밀려나는 방식
- 09 길게 누르기 등장 — 누른 시간 값이 기준을 넘으면 메뉴가 뜨는 방식
아래 표의 실측 값을 먼저 보면, 서브메뉴 드롭다운처럼 하위 목록이 딸린 04·08은 면적보다 계산이 언제 갈리는지로 재는 편이 더 정확하고, 06처럼 화면 전체를 덮는 backdrop이 있는 항목은 면적이 자연히 커집니다.
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
실측 한눈에
| 항목 | 초점 | 누적면적% | 강도 평균 | 움직인 프레임 |
|---|---|---|---|---|
| 01 커서 위치 고정 | 클릭 좌표 값 → positionAt() 재계산 | 51.25 | 118.5 | 7/23 |
| 02 화면 경계 반전 | 경계 초과 값 → flipFor() 재계산 | 45.69 | 112.7 | 8/23 |
| 03 크기 페이드 등장 | origin 비율 값 → openAt() 재계산 | 37.25 | 57.2 | 7/23 |
| 04 하위 메뉴 순차 등장 | 머문 시간 값 → scheduleSub() 재계산 | 15.37 | 65.9 | 6/23 |
| 05 방향키 강조 이동 | 순환 인덱스 값 → moveHighlight() 재계산 | 19.52 | 110.1 | 14/23 |
| 06 바깥 클릭 닫힘 | 경계 판정 값 → isInside() 재계산 | 88.41 | 46.0 | 8/23 |
| 07 항목 순차 등장 | 순서 지연 값 → applyStagger() 재계산 | 19.28 | 83.8 | 8/23 |
| 08 깊이 밀림 전환 | 깊이 값 → goTo() 재계산 | 15.82 | 94.8 | 8/23 |
| 09 길게 누르기 등장 | 경과 시간 값 → tickPress() 재계산 | 19.03 | 112.9 | 20/23 |
06 이 88.41%로 아홉 중 누적면적이 압도적으로 커 backdrop 전체가 페이드로 함께 움직인다는 뜻이고, 04 는 15.37%로 가장 작아 메뉴 상자 하나만 옆으로 늘어난다는 뜻입니다. 강도 평균은 01 이 118.5로 높은 축에 들어 메뉴가 나타나는 순간 배경과의 픽셀 값 차이가 크고, 06 은 46.0으로 가장 낮아 서서히 옅어지는 페이드라 픽셀 값 차이가 완만합니다. 움직인 프레임은 09 가 20/23으로 가장 높아 누르고 있는 내내 링이 매 프레임 값을 다시 그린다는 뜻이고, 04 는 6/23으로 가장 낮아 대기 시간이 끝난 뒤 짧게만 값이 바뀝니다.
클릭 좌표 값이 자리를 정하는 것 — 01·02·03
01 은 positionAt()이 클릭 좌표(x, y)를 GAP(8px)만큼만 화면 경계에서 물러나게 clamp 한 값을 그대로 translate()에 넣어, 메뉴 상자가 클릭한 자리에 거의 그대로 나타납니다. 02 는 flipFor()가 메뉴 오른쪽·아래쪽 끝 좌표가 화면 폭·높이 값을 넘어서는지부터 재고, 넘어서는 축만 클릭 좌표에서 메뉴 크기만큼 반대로 밀어 펼치는 방향 값을 뒤집습니다. 03 은 openAt()이 클릭 좌표를 중심으로 상자를 배치한 뒤, clamp 로 밀린 만큼을 되짚어 transform-origin 비율 값을 다시 계산해 항상 클릭한 지점에서 커지는 것처럼 보이게 합니다.
머문 시간 값이 등장을 가르는 것 — 04·09
04 는 scheduleSub()가 정렬 항목 위에 머무른 시간을 setTimeout(CASCADE_MS)로 재고, 그 시간을 다 채워야만 하위 목록을 연 자리(mr.right - 2, tr.top - 4)에 배치합니다. 09 는 tickPress()가 누른 시점부터 지난 시간을 HOLD_MS로 나눈 진행 값을 원둘레(CIRC)에 곱해 매 프레임 stroke-dashoffset을 다시 그리다가, 진행 값이 1이 되어야만 메뉴를 엽니다.
순서·상태 값이 등장을 가르는 것 — 05·06·07·08
05 는 방향키가 눌릴 때마다 (hi + dir + n) % n으로 강조 자리의 순환 인덱스 값을 다시 계산하고, 06 은 isInside()가 클릭 좌표를 메뉴의 경계 사각형과 견줘 바깥일 때만 backdrop 과 메뉴의 투명도 값을 함께 낮춥니다. 07 은 applyStagger()가 각 줄의 순서 값에 STAGGER_MS를 곱해 transitionDelay를 인라인으로 매기고, 08 은 goTo()가 깊이 값에 PANEL_W를 곱한 만큼 트랙을 translateX로 밀어 하위 목록으로 들어간 단계를 옆으로 이어 붙입니다.
패턴별 코드
01커서 위치 고정 — 클릭 좌표 값이 그대로 메뉴가 뜨는 자리 값이 되는 방식
클릭이 일어난 화면 좌표 값이 그대로 메뉴 상자의 왼쪽 위 자리 값으로 쓰이고, 메뉴가 닫힌 상태가 되면 그 자리 값이 지워집니다. 파일 목록 항목이나 에디터 안 텍스트를 우클릭하는 화면에 맞는 형태입니다.
GAP·clamp 로 클릭 좌표를 옮기는 positionAt()
function positionAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
// 계산 겹: 클릭 좌표 값을 그대로 쓰되, 메뉴 상자가 화면을 벗어나지 않도록 각 축을 독립적으로 clamp 한다.
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.transform = 'translate(' + px.toFixed(0) + 'px,' + py.toFixed(0) + 'px)';
}

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField">
<span class="cm__hint" id="cmHint">우클릭</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>
<span>이름 바꾸기</span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 클릭 좌표 값을 뷰포트 경계 안으로 clamp 한 뒤 그 값을 그대로 메뉴 위치로 쓴다 (positionAt()).
@include stage($stage-ink);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(180px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
display: grid;
place-items: center;
gap: $sp-1;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
padding: $sp-3;
box-sizing: border-box;
user-select: none;
}
.cm__hint { opacity: .7; font-weight: 500; font-size: 12px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 176px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
pointer-events: none;
transition: opacity $dur-quick $ease-out;
z-index: 5;
&.is-open { opacity: 1; pointer-events: auto; }
}
.cm__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&--danger { color: $error; }
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 148px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu { 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);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(180px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
display: grid;
place-items: center;
gap: 4px;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
padding: 12px;
box-sizing: border-box;
user-select: none;
}
.cm__hint {
opacity: 0.7;
font-weight: 500;
font-size: 12px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 176px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 220ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
pointer-events: auto;
}
.cm__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
}
.cm__item:hover, .cm__item:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--danger {
color: #c2361a;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 148px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var root = document.getElementById('cm');
var GAP = 8; // 화면 경계에서 남기는 여백
function positionAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
// 계산 겹: 클릭 좌표 값을 그대로 쓰되, 메뉴 상자가 화면을 벗어나지 않도록 각 축을 독립적으로 clamp 한다.
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.transform = 'translate(' + px.toFixed(0) + 'px,' + py.toFixed(0) + 'px)';
}
function open(x, y) {
positionAt(x, y);
menu.classList.add('is-open');
}
function close() { menu.classList.remove('is-open'); }
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
open(e.clientX, e.clientY);
});
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && e.target !== field) {
close();
}
});
menu.addEventListener('click', close);
// 자동 루프: 클릭 좌표 값이 다른 네 지점(모서리 근처 포함)을 돌며 같은 positionAt() 경로를 탄다.
var points = [
{ x: 90, y: 86 }, { x: 300, y: 54 }, { x: 56, y: 190 }, { x: 268, y: 214 }
];
var tick = 0, autoTimer = null;
function autoTick() {
var step = tick % (points.length * 2);
if (step % 2 === 0) {
var p = points[step / 2];
open(p.x, p.y);
} else {
close();
}
tick++;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 250); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./CursorPositionAnchor.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_RENAME = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type CursorPositionAnchorProps = {
color?: string;
};
export default function CursorPositionAnchor({
color = "#2f6df6",
}: CursorPositionAnchorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
if (!field || !menu) return;
const GAP = 6;
// 계산 겹: 클릭 좌표를 그대로 앵커로 쓰되, 메뉴 크기와 뷰포트 크기를 재서 넘치는 만큼만
// 안쪽으로 clamp 한다 — 항상 실제 클릭 지점에서 가장 가까운 안전한 좌표로 붙인다.
function positionAt(x: number, y: number) {
const mw = menu!.offsetWidth, mh = menu!.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const px = Math.max(GAP, Math.min(x, vw - mw - GAP));
const py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu!.style.transform = `translate(${px.toFixed(0)}px,${py.toFixed(0)}px)`;
}
function open(x: number, y: number) {
positionAt(x, y);
menu!.classList.add(styles.open);
}
function close() { menu!.classList.remove(styles.open); }
function onContextMenu(e: MouseEvent) {
e.preventDefault();
stopAutoplay();
open(e.clientX, e.clientY);
}
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && e.target !== field) close();
}
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("pointerdown", onDocDown);
menu.addEventListener("click", close);
const points = [{ x: 24, y: 20 }, { x: -24, y: 30 }, { x: 0, y: -20 }, { x: 32, y: -10 }];
let tick = 0;
let autoTimer: number | null = null;
function autoTick() {
const step = tick % (points.length * 2);
if (step % 2 === 0) {
const r = field!.getBoundingClientRect();
const p = points[step / 2];
open(r.left + r.width / 2 + p.x, r.top + r.height / 2 + p.y);
} else {
close();
}
tick++;
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 250); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("pointerdown", onDocDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field}>
<span className={styles.hint}>우클릭</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_RENAME }} />
<span>이름 바꾸기</span>
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
</div>
</div>
);
}01 · 커서 위치 고정 — 클릭 좌표 값이 그대로 메뉴가 뜨는 자리 값이 되는 방식 — 측정 누적면적 51.248% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02화면 경계 반전 — 자리 값이 화면 끝에 닿으면 펼치는 방향 값이 반대로 바뀌는 방식
메뉴 상자의 오른쪽 끝 좌표 값이 화면 너비 값을 넘어선 상태가 되면 펼치는 방향 값이 왼쪽으로 바뀌고, 아래쪽도 같은 방식으로 위쪽 방향 값으로 바뀝니다. 화면 오른쪽이나 아래쪽 끝 요소를 우클릭해도 메뉴가 화면 밖으로 나가지 않게 잡아 줍니다.
(x+mw)>vw 로 펼치는 방향을 뒤집는 flipFor()
function flipFor(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
// 계산 겹: 메뉴 상자의 오른쪽·아래쪽 끝(x+mw, y+mh)이 화면 폭·높이 값을 넘어서는지 먼저 재고,
// 넘어서는 축만 앵커에서 메뉴 크기만큼 반대로 밀어 펼치는 방향을 뒤집는다.
var flipX = (x + mw) > vw;
var flipY = (y + mh) > vh;
var px = flipX ? (x - mw) : x;
var py = flipY ? (y - mh) : y;
px = Math.max(GAP, Math.min(px, vw - mw - GAP));
py = Math.max(GAP, Math.min(py, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
menu.classList.toggle('flip-x', flipX);
menu.classList.toggle('flip-y', flipY);
}

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field cm__field--alt" id="cmFieldAlt">
<span class="cm__hint">왼쪽 위<br>우클릭</span>
</div>
<div class="cm__field" id="cmField">
<span class="cm__hint">가장자리에서<br>우클릭</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>
<span>이름 바꾸기</span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 메뉴 상자의 오른쪽·아래쪽 끝 좌표 값이 화면 폭·높이 값을 넘어서는지 먼저 계산하고,
// 넘어서면 펼치는 방향(transform-origin)과 앵커 좌표를 반대로 뒤집는다 (flipFor()).
@include stage($stage-yellow);
.cm {
position: relative;
width: 100%;
height: 100%;
}
.cm__field {
position: absolute;
right: $sp-6;
bottom: $sp-6;
width: 168px;
height: 108px;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
display: grid;
place-items: center;
text-align: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 13px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
user-select: none;
}
.cm__field--alt {
right: auto;
left: $sp-4;
top: $sp-4;
bottom: auto;
width: 120px;
height: 84px;
}
.cm__hint { opacity: .75; font-weight: 500; font-size: 11px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 172px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.94);
transform-origin: top left;
pointer-events: none;
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
z-index: 5;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
&.flip-x { transform-origin: top right; }
&.flip-y { transform-origin: bottom left; }
&.flip-x.flip-y { transform-origin: bottom right; }
}
.cm__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&--danger { color: $error; }
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
@media (max-width: 340px) {
.cm__field { width: 140px; height: 96px; right: $sp-3; bottom: $sp-3; font-size: 11px; }
.cm__field--alt { width: 104px; height: 76px; left: $sp-2; top: $sp-2; }
.cm__menu { min-width: 144px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu { 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);
}
.cm {
position: relative;
width: 100%;
height: 100%;
}
.cm__field {
position: absolute;
right: 24px;
bottom: 24px;
width: 168px;
height: 108px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
display: grid;
place-items: center;
text-align: center;
padding: 12px;
box-sizing: border-box;
font: 600 13px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
user-select: none;
}
.cm__field--alt {
right: auto;
left: 16px;
top: 16px;
bottom: auto;
width: 120px;
height: 84px;
}
.cm__hint {
opacity: 0.75;
font-weight: 500;
font-size: 11px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 172px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.94);
transform-origin: top left;
pointer-events: none;
transition: opacity 220ms ease-out, transform 220ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__menu.flip-x {
transform-origin: top right;
}
.cm__menu.flip-y {
transform-origin: bottom left;
}
.cm__menu.flip-x.flip-y {
transform-origin: bottom right;
}
.cm__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
}
.cm__item:hover, .cm__item:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--danger {
color: #c2361a;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
@media (max-width: 340px) {
.cm__field {
width: 140px;
height: 96px;
right: 12px;
bottom: 12px;
font-size: 11px;
}
.cm__field--alt {
width: 104px;
height: 76px;
left: 8px;
top: 8px;
}
.cm__menu {
min-width: 144px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var fieldAlt = document.getElementById('cmFieldAlt');
var menu = document.getElementById('cmMenu');
var root = document.getElementById('cm');
var GAP = 4;
function flipFor(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
// 계산 겹: 메뉴 상자의 오른쪽·아래쪽 끝(x+mw, y+mh)이 화면 폭·높이 값을 넘어서는지 먼저 재고,
// 넘어서는 축만 앵커에서 메뉴 크기만큼 반대로 밀어 펼치는 방향을 뒤집는다.
var flipX = (x + mw) > vw;
var flipY = (y + mh) > vh;
var px = flipX ? (x - mw) : x;
var py = flipY ? (y - mh) : y;
px = Math.max(GAP, Math.min(px, vw - mw - GAP));
py = Math.max(GAP, Math.min(py, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
menu.classList.toggle('flip-x', flipX);
menu.classList.toggle('flip-y', flipY);
}
function open(x, y) {
flipFor(x, y);
menu.classList.add('is-open');
}
function close() { menu.classList.remove('is-open'); }
function wireField(el) {
el.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
var r = el.getBoundingClientRect();
open(e.clientX - r.left > r.width / 2 ? r.right - 2 : r.left + 2, e.clientY);
});
}
wireField(field);
wireField(fieldAlt);
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && e.target !== field && e.target !== fieldAlt) {
close();
}
});
menu.addEventListener('click', close);
// 자동 루프: 왼쪽 위 필드(뒤집힘 없음)와 오른쪽 아래 필드(가로·세로 모두 뒤집힘)를 번갈아 열어
// 같은 flipFor() 경로가 두 경우 모두 다른 방향으로 뒤집는 것을 보여준다.
var seq = [
function () { var r = fieldAlt.getBoundingClientRect(); open(r.left + 8, r.top + 8); },
close,
function () { var r = field.getBoundingClientRect(); open(r.right - 8, r.bottom - 8); },
close
];
var si = 0, autoTimer = null;
function autoTick() { seq[si % seq.length](); si++; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 280); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ViewportEdgeFlip.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_RENAME = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type ViewportEdgeFlipProps = {
color?: string;
};
export default function ViewportEdgeFlip({
color = "#2f6df6",
}: ViewportEdgeFlipProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const fieldAlt = root.querySelector<HTMLDivElement>(`.${styles.fieldAlt}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
if (!field || !fieldAlt || !menu) return;
const GAP = 4;
// 계산 겹: 메뉴 상자의 오른쪽·아래쪽 끝이 뷰포트를 넘는지 먼저 재고, 넘는 축만 앵커에서
// 메뉴 크기만큼 반대로 밀어 펼치는 방향을 뒤집는다.
function flipFor(x: number, y: number) {
const mw = menu!.offsetWidth, mh = menu!.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const flipX = (x + mw) > vw;
const flipY = (y + mh) > vh;
let px = flipX ? (x - mw) : x;
let py = flipY ? (y - mh) : y;
px = Math.max(GAP, Math.min(px, vw - mw - GAP));
py = Math.max(GAP, Math.min(py, vh - mh - GAP));
menu!.style.left = px.toFixed(0) + "px";
menu!.style.top = py.toFixed(0) + "px";
menu!.classList.toggle(styles.flipX, flipX);
menu!.classList.toggle(styles.flipY, flipY);
}
function open(x: number, y: number) { flipFor(x, y); menu!.classList.add(styles.open); }
function close() { menu!.classList.remove(styles.open); }
function wireField(el: HTMLDivElement) {
const handler = (e: MouseEvent) => {
e.preventDefault();
stopAutoplay();
const r = el.getBoundingClientRect();
open(e.clientX - r.left > r.width / 2 ? r.right - 2 : r.left + 2, e.clientY);
};
el.addEventListener("contextmenu", handler);
return () => el.removeEventListener("contextmenu", handler);
}
const un1 = wireField(field);
const un2 = wireField(fieldAlt);
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && e.target !== field && e.target !== fieldAlt) close();
}
document.addEventListener("pointerdown", onDocDown);
menu.addEventListener("click", close);
const seq = [
() => { const r = fieldAlt.getBoundingClientRect(); open(r.left + 8, r.top + 8); },
close,
() => { const r = field.getBoundingClientRect(); open(r.right - 8, r.bottom - 8); },
close,
];
let si = 0;
let autoTimer: number | null = null;
function autoTick() { seq[si % seq.length](); si++; }
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 280); autoTick(); }
return () => {
stopAutoplay();
un1(); un2();
document.removeEventListener("pointerdown", onDocDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={`${styles.field} ${styles.fieldAlt}`}>
<span className={styles.hint}>왼쪽 위<br />우클릭</span>
</div>
<div className={styles.field}>
<span className={styles.hint}>가장자리에서<br />우클릭</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_RENAME }} />
<span>이름 바꾸기</span>
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
</div>
</div>
);
}02 · 화면 경계 반전 — 자리 값이 화면 끝에 닿으면 펼치는 방향 값이 반대로 바뀌는 방식 — 측정 누적면적 45.69% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03크기 페이드 등장 — 크기 값과 투명도 값이 함께 작은 값에서 커지며 나타나는 방식
메뉴가 열린 상태가 되면 크기 값이 작은 값에서 원래 값까지, 투명도 값이 0에서 1까지 같은 시간 동안 함께 바뀌고, 닫힌 상태가 되면 그 값들이 반대로 되돌아갑니다. 짧은 액션 메뉴나 표의 행을 눌렀을 때 여는 메뉴에 맞는 형태입니다.
클릭 좌표를 transform-origin 비율로 되짚는 openAt()
function openAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
// 계산 겹: 메뉴를 클릭 좌표가 중심에 오도록 배치한 뒤 화면 경계로 clamp 한다.
var left = Math.max(GAP, Math.min(x - mw / 2, vw - mw - GAP));
var top = Math.max(GAP, Math.min(y - mh / 2, vh - mh - GAP));
// 상자가 clamp 로 밀렸어도, 실제 클릭 점이 그 상자 안의 몇 % 지점인지 되짚어 origin 으로 쓴다.
var originX = Math.max(0, Math.min(100, ((x - left) / mw) * 100));
var originY = Math.max(0, Math.min(100, ((y - top) / mh) * 100));
menu.style.left = left.toFixed(0) + 'px';
menu.style.top = top.toFixed(0) + 'px';
menu.style.transformOrigin = originX.toFixed(1) + '% ' + originY.toFixed(1) + '%';
menu.classList.add('is-open');
}

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField">
<span class="cm__hint">우클릭</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>
<span>이름 바꾸기</span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 메뉴를 클릭 좌표 중심으로 배치한 뒤 화면 경계로 clamp 하고, 실제 클릭 점이 그 상자
// 안의 몇 % 지점에 놓였는지 되짚어(originFor()) transform-origin 으로 넘긴다 — 상자가 밀려도
// 확대는 항상 진짜 클릭 지점에서 시작한다.
@include stage($stage-orange);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint { opacity: .7; font-weight: 500; font-size: 12px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 176px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.8);
transform-origin: 50% 50%;
pointer-events: none;
transition: opacity $dur-quick $ease-pop, transform $dur-quick $ease-pop;
z-index: 5;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
}
.cm__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&--danger { color: $error; }
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 148px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu { 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);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint {
opacity: 0.7;
font-weight: 500;
font-size: 12px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 176px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.8);
transform-origin: 50% 50%;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
}
.cm__item:hover, .cm__item:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--danger {
color: #c2361a;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 148px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var root = document.getElementById('cm');
var GAP = 6;
function openAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
// 계산 겹: 메뉴를 클릭 좌표가 중심에 오도록 배치한 뒤 화면 경계로 clamp 한다.
var left = Math.max(GAP, Math.min(x - mw / 2, vw - mw - GAP));
var top = Math.max(GAP, Math.min(y - mh / 2, vh - mh - GAP));
// 상자가 clamp 로 밀렸어도, 실제 클릭 점이 그 상자 안의 몇 % 지점인지 되짚어 origin 으로 쓴다.
var originX = Math.max(0, Math.min(100, ((x - left) / mw) * 100));
var originY = Math.max(0, Math.min(100, ((y - top) / mh) * 100));
menu.style.left = left.toFixed(0) + 'px';
menu.style.top = top.toFixed(0) + 'px';
menu.style.transformOrigin = originX.toFixed(1) + '% ' + originY.toFixed(1) + '%';
menu.classList.add('is-open');
}
function close() { menu.classList.remove('is-open'); }
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
openAt(e.clientX, e.clientY);
});
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && e.target !== field) { close(); }
});
menu.addEventListener('click', close);
// 자동 루프: 필드 안의 서로 다른 지점을 클릭해 origin 값이 매번 달라지는 것을 보여준다.
var points = [{ x: -30, y: -20 }, { x: 30, y: 15 }, { x: -10, y: 25 }, { x: 15, y: -25 }];
var tick = 0, autoTimer = null;
function autoTick() {
var step = tick % (points.length * 2);
if (step % 2 === 0) {
var r = field.getBoundingClientRect();
var p = points[step / 2];
openAt(r.left + r.width / 2 + p.x, r.top + r.height / 2 + p.y);
} else {
close();
}
tick++;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 260); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScaleFadeIn.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_RENAME = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type ScaleFadeInProps = {
color?: string;
};
export default function ScaleFadeIn({
color = "#2f6df6",
}: ScaleFadeInProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
if (!field || !menu) return;
const GAP = 6;
// 계산 겹: 메뉴를 클릭 좌표 중심으로 배치한 뒤 화면 경계로 clamp 하고, 실제 클릭 점이 그
// 상자 안의 몇 % 지점에 놓였는지 되짚어 transform-origin 으로 넘긴다 — 상자가 밀려도
// 확대는 항상 진짜 클릭 지점에서 시작한다.
function openAt(x: number, y: number) {
const mw = menu!.offsetWidth, mh = menu!.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const left = Math.max(GAP, Math.min(x - mw / 2, vw - mw - GAP));
const top = Math.max(GAP, Math.min(y - mh / 2, vh - mh - GAP));
const originX = Math.max(0, Math.min(100, ((x - left) / mw) * 100));
const originY = Math.max(0, Math.min(100, ((y - top) / mh) * 100));
menu!.style.left = left.toFixed(0) + "px";
menu!.style.top = top.toFixed(0) + "px";
menu!.style.transformOrigin = originX.toFixed(1) + "% " + originY.toFixed(1) + "%";
menu!.classList.add(styles.open);
}
function close() { menu!.classList.remove(styles.open); }
function onContextMenu(e: MouseEvent) { e.preventDefault(); stopAutoplay(); openAt(e.clientX, e.clientY); }
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && e.target !== field) close();
}
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("pointerdown", onDocDown);
menu.addEventListener("click", close);
const points = [{ x: -30, y: -20 }, { x: 30, y: 15 }, { x: -10, y: 25 }, { x: 15, y: -25 }];
let tick = 0;
let autoTimer: number | null = null;
function autoTick() {
const step = tick % (points.length * 2);
if (step % 2 === 0) {
const r = field!.getBoundingClientRect();
const p = points[step / 2];
openAt(r.left + r.width / 2 + p.x, r.top + r.height / 2 + p.y);
} else {
close();
}
tick++;
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 260); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("pointerdown", onDocDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field}>
<span className={styles.hint}>우클릭</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_RENAME }} />
<span>이름 바꾸기</span>
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
</div>
</div>
);
}03 · 크기 페이드 등장 — 크기 값과 투명도 값이 함께 작은 값에서 커지며 나타나는 방식 — 측정 누적면적 37.249% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04하위 메뉴 순차 등장 — 상위 항목에 머무른 시간 값이 지나야 하위 목록이 펼쳐지는 방식
가리키고 있는 항목 위에 머무른 시간 값이 정해진 값을 넘은 상태가 되면 그 옆에 하위 목록이 나타나고, 다른 항목으로 옮겨간 상태가 되면 머무른 시간 값이 다시 0부터 셈해집니다. 여러 단계로 나뉜 편집 메뉴나 파일 형식별 하위 목록에 맞는 형태입니다.
CASCADE_MS 로 머문 시간을 재는 scheduleSub()
function scheduleSub() {
clearHover();
// 계산 겹: 정렬 항목 위에 머무른 시간을 CASCADE_MS 로 재고, 그 시간을 넘겨야만 서브메뉴를 연다
// (즉시 열지 않음 — 지나가는 커서에 반응하지 않게).
hoverTimer = setTimeout(function () {
var mr = menu.getBoundingClientRect();
var tr = trigger.getBoundingClientRect();
place(sub, mr.right - 2, tr.top - 4);
sub.classList.add('is-open');
}, CASCADE_MS);
}
trigger.addEventListener('pointerenter', scheduleSub);
trigger.addEventListener('pointerleave', function () { clearHover(); });

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField">
<span class="cm__hint">우클릭 → 정렬에 머무르기</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item cm__item--active" type="button" role="menuitem" id="cmSortTrigger">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M184 88v104a8 8 0 0 1-13.66 5.66l-32-32a8 8 0 0 1 0-11.32L170.34 122A8 8 0 0 1 184 88M72 88v104a8 8 0 0 0 13.66 5.66l32-32a8 8 0 0 0 0-11.32L85.66 122A8 8 0 0 0 72 88" opacity=".2"/><path d="M226.83 82.34a8 8 0 0 0-8.72 1.73l-32 32a8 8 0 0 0 0 11.32l32 32A8 8 0 0 0 232 154V90a8 8 0 0 0-5.17-7.66M216 134.69L197.31 116L216 97.31ZM37.86 116l32-32a8 8 0 0 1 13.66 5.66v64A8 8 0 0 1 69.86 159l-32-32a8 8 0 0 1 0-11m14.28 5.66L59.86 130V102Z"/></g></svg></span>
<span>정렬</span>
<span class="cm__caret"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M181.66 133.66l-80 80a8 8 0 0 1-11.32-11.32L164.69 128L90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32"/></svg></span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
<div class="cm__sub" id="cmSub" role="menu">
<button class="cm__item" type="button" role="menuitem"><span>이름순</span></button>
<button class="cm__item" type="button" role="menuitem"><span>날짜순</span></button>
<button class="cm__item" type="button" role="menuitem"><span>크기순</span></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 부모 항목 위에 머무른 시간이 CASCADE_MS 를 넘는지 타이머로 재고, 넘으면 그 항목의
// 오른쪽 경계 좌표 값을 하위 목록의 왼쪽 좌표 값으로 그대로 물려 옆에 이어 붙인다 (openSub()).
@include stage($stage-paper);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-blue-deep;
color: $subject-cream;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint { opacity: .85; font-weight: 500; font-size: 12px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 156px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.94);
transform-origin: top left;
pointer-events: none;
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
z-index: 5;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
}
.cm__sub {
position: fixed;
top: 0;
left: 0;
min-width: 132px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.9);
transform-origin: top left;
pointer-events: none;
transition: opacity $dur-quick $ease-pop, transform $dur-quick $ease-pop;
z-index: 6;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
}
.cm__item {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
&:hover, &:focus-visible, &--active { background: rgba($subject-ink, .08); }
&--danger { color: $error; }
}
.cm__caret { margin-left: auto; width: 12px; height: 12px; color: rgba($subject-ink, .55); }
.cm__caret svg { display: block; width: 100%; height: 100%; }
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 132px; }
.cm__sub { min-width: 112px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__sub { 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);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #0a4fe8;
color: #fff7e6;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint {
opacity: 0.85;
font-weight: 500;
font-size: 12px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 156px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.94);
transform-origin: top left;
pointer-events: none;
transition: opacity 220ms ease-out, transform 220ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__sub {
position: fixed;
top: 0;
left: 0;
min-width: 132px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.9);
transform-origin: top left;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
z-index: 6;
}
.cm__sub.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
}
.cm__item:hover, .cm__item:focus-visible, .cm__item--active {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--danger {
color: #c2361a;
}
.cm__caret {
margin-left: auto;
width: 12px;
height: 12px;
color: rgba(23, 20, 26, 0.55);
}
.cm__caret svg {
display: block;
width: 100%;
height: 100%;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 132px;
}
.cm__sub {
min-width: 112px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__sub {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var sub = document.getElementById('cmSub');
var trigger = document.getElementById('cmSortTrigger');
var root = document.getElementById('cm');
var GAP = 6;
var CASCADE_MS = 260;
function place(el, x, y) {
var w = el.offsetWidth, h = el.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var px = Math.max(GAP, Math.min(x, vw - w - GAP));
var py = Math.max(GAP, Math.min(y, vh - h - GAP));
el.style.left = px.toFixed(0) + 'px';
el.style.top = py.toFixed(0) + 'px';
return { left: px, top: py, right: px + w, width: w };
}
var hoverTimer = null;
function openMenu(x, y) {
menu.style.left = '0px'; menu.style.top = '0px';
var box = place(menu, x, y);
menu.classList.add('is-open');
}
function closeAll() {
menu.classList.remove('is-open');
sub.classList.remove('is-open');
clearHover();
}
function clearHover() { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
function scheduleSub() {
clearHover();
// 계산 겹: 정렬 항목 위에 머무른 시간을 CASCADE_MS 로 재고, 그 시간을 넘겨야만 서브메뉴를 연다
// (즉시 열지 않음 — 지나가는 커서에 반응하지 않게).
hoverTimer = setTimeout(function () {
var mr = menu.getBoundingClientRect();
var tr = trigger.getBoundingClientRect();
place(sub, mr.right - 2, tr.top - 4);
sub.classList.add('is-open');
}, CASCADE_MS);
}
trigger.addEventListener('pointerenter', scheduleSub);
trigger.addEventListener('pointerleave', function () { clearHover(); });
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
closeAll();
openMenu(e.clientX, e.clientY);
});
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && !sub.contains(e.target) && e.target !== field) {
closeAll();
}
});
// 자동 루프: 메뉴를 연 뒤 정렬 항목 위에 CASCADE_MS 이상 머물러 서브메뉴가 계단식으로 이어 붙는
// 것을 보여주고, 잠시 뒤 닫는다.
var step = 0, autoTimer = null;
function autoTick() {
var phase = step % 4;
if (phase === 0) {
var r = field.getBoundingClientRect();
openMenu(r.left + r.width * 0.5, r.top + r.height * 0.6);
} else if (phase === 1) {
scheduleSub();
} else if (phase === 3) {
closeAll();
}
step++;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } clearHover(); }
if (!reduce) { autoTimer = setInterval(autoTick, CASCADE_MS + 140); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./SubmenuCascadeOpen.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_SORT = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M184 88v104a8 8 0 0 1-13.66 5.66l-32-32a8 8 0 0 1 0-11.32L170.34 122A8 8 0 0 1 184 88M72 88v104a8 8 0 0 0 13.66 5.66l32-32a8 8 0 0 0 0-11.32L85.66 122A8 8 0 0 0 72 88" opacity=".2"/><path d="M226.83 82.34a8 8 0 0 0-8.72 1.73l-32 32a8 8 0 0 0 0 11.32l32 32A8 8 0 0 0 232 154V90a8 8 0 0 0-5.17-7.66M216 134.69L197.31 116L216 97.31ZM37.86 116l32-32a8 8 0 0 1 13.66 5.66v64A8 8 0 0 1 69.86 159l-32-32a8 8 0 0 1 0-11m14.28 5.66L59.86 130V102Z"/></g></svg>';
const ICON_CARET = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M181.66 133.66l-80 80a8 8 0 0 1-11.32-11.32L164.69 128L90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32"/></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type SubmenuCascadeOpenProps = {
color?: string;
};
export default function SubmenuCascadeOpen({
color = "#2f6df6",
}: SubmenuCascadeOpenProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
const sub = root.querySelector<HTMLDivElement>(`.${styles.sub}`);
const trigger = root.querySelector<HTMLButtonElement>(`.${styles.trigger}`);
if (!field || !menu || !sub || !trigger) return;
const GAP = 6;
const CASCADE_MS = 260;
function place(el: HTMLElement, x: number, y: number) {
const w = el.offsetWidth, h = el.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const px = Math.max(GAP, Math.min(x, vw - w - GAP));
const py = Math.max(GAP, Math.min(y, vh - h - GAP));
el.style.left = px.toFixed(0) + "px";
el.style.top = py.toFixed(0) + "px";
}
let hoverTimer: number | null = null;
function openMenu(x: number, y: number) {
menu!.style.left = "0px"; menu!.style.top = "0px";
place(menu!, x, y);
menu!.classList.add(styles.open);
}
function clearHover() { if (hoverTimer !== null) { window.clearTimeout(hoverTimer); hoverTimer = null; } }
function closeAll() { menu!.classList.remove(styles.open); sub!.classList.remove(styles.subOpen); clearHover(); }
// 계산 겹: 정렬 항목 위에 머무른 시간을 CASCADE_MS 로 재고, 그 시간을 넘겨야만 서브메뉴를
// 연다(즉시 열지 않음 — 지나가는 커서에 반응하지 않게).
function scheduleSub() {
clearHover();
hoverTimer = window.setTimeout(() => {
const mr = menu!.getBoundingClientRect();
const tr = trigger!.getBoundingClientRect();
place(sub!, mr.right - 2, tr.top - 4);
sub!.classList.add(styles.subOpen);
}, CASCADE_MS);
}
trigger.addEventListener("pointerenter", scheduleSub);
const onLeave = () => clearHover();
trigger.addEventListener("pointerleave", onLeave);
function onContextMenu(e: MouseEvent) { e.preventDefault(); stopAutoplay(); closeAll(); openMenu(e.clientX, e.clientY); }
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && !sub!.contains(e.target as Node) && e.target !== field) closeAll();
}
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("pointerdown", onDocDown);
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
const phase = step % 4;
if (phase === 0) {
const r = field!.getBoundingClientRect();
openMenu(r.left + r.width * 0.5, r.top + r.height * 0.6);
} else if (phase === 1) {
scheduleSub();
} else if (phase === 3) {
closeAll();
}
step++;
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } clearHover(); }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, CASCADE_MS + 140); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("pointerdown", onDocDown);
trigger.removeEventListener("pointerenter", scheduleSub);
trigger.removeEventListener("pointerleave", onLeave);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field}>
<span className={styles.hint}>우클릭 → 정렬에 머무르기</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={`${styles.item} ${styles.active} ${styles.trigger}`} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_SORT }} />
<span>정렬</span>
<span className={styles.caret} dangerouslySetInnerHTML={{ __html: ICON_CARET }} />
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
<div className={styles.sub} role="menu">
<button type="button" className={styles.item} role="menuitem"><span>이름순</span></button>
<button type="button" className={styles.item} role="menuitem"><span>날짜순</span></button>
<button type="button" className={styles.item} role="menuitem"><span>크기순</span></button>
</div>
</div>
</div>
);
}04 · 하위 메뉴 순차 등장 — 상위 항목에 머무른 시간 값이 지나야 하위 목록이 펼쳐지는 방식 — 측정 누적면적 15.367% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05방향키 강조 이동 — 눌린 방향 값만큼 강조 자리 값이 목록 순서대로 옮겨가는 방식
위아래 방향키가 눌릴 때마다 강조된 자리의 순서 값이 하나씩 늘거나 줄고, 그 순서 값이 목록의 끝을 넘어선 상태가 되면 처음 자리 값으로 되돌아갑니다. 마우스 없이 탐색하거나 접근성을 고려한 표의 메뉴에 맞는 형태입니다.
(hi+dir+n)%n 순환으로 강조 자리를 옮기는 moveHighlight()
function moveHighlight(dir) {
if (!menu.classList.contains('is-open')) return;
var n = items.length;
setHi((hi + dir + n) % n);
}
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
open(e.clientX, e.clientY);
});
document.addEventListener('keydown', function (e) {
if (!menu.classList.contains('is-open')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); root.classList.remove('is-demo'); stopAutoplay(); moveHighlight(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); root.classList.remove('is-demo'); stopAutoplay(); moveHighlight(-1); }
else if (e.key === 'Escape') { close(); }
});

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField" tabindex="0">
<span class="cm__hint">우클릭 → ↑↓ 방향키</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem" data-i="0">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem" data-i="1">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item" type="button" role="menuitem" data-i="2">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>
<span>이름 바꾸기</span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem" data-i="3">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 현재 강조 인덱스에 화살표 방향(+1/-1)을 더한 뒤 항목 개수로 나눈 나머지(모듈로)를
// 새 인덱스로 삼아 순환시키고, 그 인덱스의 항목에만 강조 클래스를 건다 (moveHighlight()).
@include stage($stage-ink);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-pink;
color: $subject-ink;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint { opacity: .75; font-weight: 500; font-size: 12px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.96);
transform-origin: top left;
pointer-events: none;
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
z-index: 5;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
}
.cm__item {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
transition: background $dur-quick $ease-out;
&--hi { background: $subject-blue; color: $subject-cream; }
&--danger { color: $error; }
&--hi.cm__item--danger { color: $subject-cream; background: $error; }
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
.cm__item--hi .cm__ic { color: $subject-cream; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 144px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__item { 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);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #ff9ebb;
color: #17141a;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint {
opacity: 0.75;
font-weight: 500;
font-size: 12px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.96);
transform-origin: top left;
pointer-events: none;
transition: opacity 220ms ease-out, transform 220ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
transition: background 220ms ease-out;
}
.cm__item--hi {
background: #2f6df6;
color: #fff7e6;
}
.cm__item--danger {
color: #c2361a;
}
.cm__item--hi.cm__item--danger {
color: #fff7e6;
background: #c2361a;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
.cm__item--hi .cm__ic {
color: #fff7e6;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 144px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__item {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var root = document.getElementById('cm');
var items = Array.prototype.slice.call(menu.querySelectorAll('.cm__item'));
var GAP = 6, hi = -1;
function positionAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
}
function setHi(i) {
if (hi >= 0) items[hi].classList.remove('cm__item--hi');
hi = i;
if (hi >= 0) items[hi].classList.add('cm__item--hi');
}
function open(x, y) {
positionAt(x, y);
menu.classList.add('is-open');
setHi(0);
}
function close() { menu.classList.remove('is-open'); setHi(-1); }
// 계산 겹: (현재 인덱스 + 방향 + 개수) % 개수 로 다음 인덱스를 구해 위/아래 끝에서 반대편으로
// 넘어가는 순환 이동을 만든다.
function moveHighlight(dir) {
if (!menu.classList.contains('is-open')) return;
var n = items.length;
setHi((hi + dir + n) % n);
}
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
open(e.clientX, e.clientY);
});
document.addEventListener('keydown', function (e) {
if (!menu.classList.contains('is-open')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); root.classList.remove('is-demo'); stopAutoplay(); moveHighlight(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); root.classList.remove('is-demo'); stopAutoplay(); moveHighlight(-1); }
else if (e.key === 'Escape') { close(); }
});
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && e.target !== field) close();
});
// 자동 루프: 메뉴를 연 뒤 방향키 이동을 흉내내 강조 막대가 위→아래로 순환 이동하는 것을 보여준다.
var step = 0, autoTimer = null;
function autoTick() {
if (step === 0) { var r = field.getBoundingClientRect(); open(r.left + 16, r.top + 16); }
else if (step < 6) { moveHighlight(1); }
else { close(); }
step = (step + 1) % 7;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 220); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./KeyboardArrowHighlight.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_RENAME = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type KeyboardArrowHighlightProps = {
color?: string;
};
export default function KeyboardArrowHighlight({
color = "#2f6df6",
}: KeyboardArrowHighlightProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
if (!field || !menu) return;
const items = Array.prototype.slice.call(menu.querySelectorAll(`.${styles.item}`)) as HTMLElement[];
const GAP = 6;
let hi = -1;
function positionAt(x: number, y: number) {
const mw = menu!.offsetWidth, mh = menu!.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const px = Math.max(GAP, Math.min(x, vw - mw - GAP));
const py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu!.style.left = px.toFixed(0) + "px";
menu!.style.top = py.toFixed(0) + "px";
}
function setHi(i: number) {
if (hi >= 0) items[hi].classList.remove(styles.hi);
hi = i;
if (hi >= 0) items[hi].classList.add(styles.hi);
}
function open(x: number, y: number) { positionAt(x, y); menu!.classList.add(styles.open); setHi(0); }
function close() { menu!.classList.remove(styles.open); setHi(-1); }
// 계산 겹: (현재 인덱스 + 방향 + 개수) % 개수 로 다음 인덱스를 구해 위/아래 끝에서 반대편으로
// 넘어가는 순환 이동을 만든다.
function moveHighlight(dir: number) {
if (!menu!.classList.contains(styles.open)) return;
const n = items.length;
setHi((hi + dir + n) % n);
}
function onContextMenu(e: MouseEvent) { e.preventDefault(); stopAutoplay(); open(e.clientX, e.clientY); }
function onKeyDown(e: KeyboardEvent) {
if (!menu!.classList.contains(styles.open)) return;
if (e.key === "ArrowDown") { e.preventDefault(); stopAutoplay(); moveHighlight(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); stopAutoplay(); moveHighlight(-1); }
else if (e.key === "Escape") { close(); }
}
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && e.target !== field) close();
}
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onDocDown);
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
if (step === 0) { const r = field!.getBoundingClientRect(); open(r.left + 16, r.top + 16); }
else if (step < 6) { moveHighlight(1); }
else { close(); }
step = (step + 1) % 7;
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 220); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onDocDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field} tabIndex={0}>
<span className={styles.hint}>우클릭 → ↑↓ 방향키</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_RENAME }} />
<span>이름 바꾸기</span>
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
</div>
</div>
);
}05 · 방향키 강조 이동 — 눌린 방향 값만큼 강조 자리 값이 목록 순서대로 옮겨가는 방식 — 측정 누적면적 19.524% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06바깥 클릭 닫힘 — 메뉴 밖을 클릭한 상태가 되면 투명도 값이 낮아지며 사라지는 방식
메뉴가 열린 상태에서 그 바깥 자리를 클릭한 상태가 되면 투명도 값이 정해진 시간 동안 낮아지다 0이 된 상태에서 화면에서 없어집니다. 일반적인 우클릭 메뉴 닫기나 모바일 길게 누르기 메뉴 닫기에 맞는 형태입니다.
경계 사각형으로 바깥 클릭을 가르는 isInside()
function positionAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
}
function open(x, y) {
positionAt(x, y);
menu.classList.add('is-open');
backdrop.classList.add('is-on');
}
function close() {
menu.classList.remove('is-open');
backdrop.classList.remove('is-on');
}
// 계산 겹: 클릭 좌표가 메뉴의 경계 사각형(getBoundingClientRect) 안에 드는지 네 부등식으로
// 판정해 바깥일 때만 backdrop 과 메뉴를 함께 페이드아웃한다.
function isInside(rect, x, y) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__backdrop" id="cmBackdrop"></div>
<div class="cm__field" id="cmField">
<span class="cm__hint">우클릭 → 바깥 클릭</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>
<span>이름 바꾸기</span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 바깥 클릭 좌표가 메뉴 상자(getBoundingClientRect) 밖인지 사각형 포함 판정으로 재고,
// 밖이면 배경(backdrop)의 불투명도를 0으로 되돌리는 것과 동시에 메뉴를 fade-out 시킨다.
@include stage($stage-orange);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
position: relative;
z-index: 1;
}
.cm__hint { opacity: .8; font-weight: 500; font-size: 12px; }
.cm__backdrop {
position: fixed;
inset: 0;
background: rgba($subject-ink, .28);
opacity: 0;
pointer-events: none;
transition: opacity $dur-base $ease-out;
z-index: 4;
&.is-on { opacity: 1; pointer-events: auto; }
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity $dur-base $ease-out, transform $dur-base $ease-out;
z-index: 5;
&.is-open { opacity: 1; transform: translateY(0); pointer-events: auto; }
}
.cm__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&--danger { color: $error; }
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 144px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__backdrop { 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);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
position: relative;
z-index: 1;
}
.cm__hint {
opacity: 0.8;
font-weight: 500;
font-size: 12px;
}
.cm__backdrop {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.28);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out;
z-index: 4;
}
.cm__backdrop.is-on {
opacity: 1;
pointer-events: auto;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 300ms ease-out, transform 300ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.cm__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
}
.cm__item:hover, .cm__item:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--danger {
color: #c2361a;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 144px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__backdrop {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var backdrop = document.getElementById('cmBackdrop');
var root = document.getElementById('cm');
var GAP = 6;
function positionAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
}
function open(x, y) {
positionAt(x, y);
menu.classList.add('is-open');
backdrop.classList.add('is-on');
}
function close() {
menu.classList.remove('is-open');
backdrop.classList.remove('is-on');
}
// 계산 겹: 클릭 좌표가 메뉴의 경계 사각형(getBoundingClientRect) 안에 드는지 네 부등식으로
// 판정해 바깥일 때만 backdrop 과 메뉴를 함께 페이드아웃한다.
function isInside(rect, x, y) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
open(e.clientX, e.clientY);
});
backdrop.addEventListener('pointerdown', function (e) {
var r = menu.getBoundingClientRect();
if (!isInside(r, e.clientX, e.clientY)) close();
});
menu.addEventListener('click', close);
// 자동 루프: 필드 위에 메뉴를 연 뒤 배경 바깥 지점을 클릭해 backdrop 과 메뉴가 함께
// 페이드아웃되는 것을 보여준다.
var step = 0, autoTimer = null;
function autoTick() {
if (step === 0) { var r = field.getBoundingClientRect(); open(r.left + r.width * 0.3, r.top + r.height * 0.3); }
else if (step === 2) { close(); }
step = (step + 1) % 4;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 300); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./BackdropDismissFade.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_RENAME = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type BackdropDismissFadeProps = {
color?: string;
};
export default function BackdropDismissFade({
color = "#2f6df6",
}: BackdropDismissFadeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
const backdrop = root.querySelector<HTMLDivElement>(`.${styles.backdrop}`);
if (!field || !menu || !backdrop) return;
const GAP = 6;
function positionAt(x: number, y: number) {
const mw = menu!.offsetWidth, mh = menu!.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const px = Math.max(GAP, Math.min(x, vw - mw - GAP));
const py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu!.style.left = px.toFixed(0) + "px";
menu!.style.top = py.toFixed(0) + "px";
}
function open(x: number, y: number) { positionAt(x, y); menu!.classList.add(styles.open); backdrop!.classList.add(styles.backdropOn); }
function close() { menu!.classList.remove(styles.open); backdrop!.classList.remove(styles.backdropOn); }
// 계산 겹: 클릭 좌표가 메뉴의 경계 사각형 안에 드는지 네 부등식으로 판정해 바깥일 때만
// backdrop 과 메뉴를 함께 페이드아웃한다.
function isInside(rect: DOMRect, x: number, y: number) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
function onContextMenu(e: MouseEvent) { e.preventDefault(); stopAutoplay(); open(e.clientX, e.clientY); }
function onBackdropDown(e: PointerEvent) {
const r = menu!.getBoundingClientRect();
if (!isInside(r, e.clientX, e.clientY)) close();
}
field.addEventListener("contextmenu", onContextMenu);
backdrop.addEventListener("pointerdown", onBackdropDown);
menu.addEventListener("click", close);
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
if (step === 0) { const r = field!.getBoundingClientRect(); open(r.left + r.width * 0.3, r.top + r.height * 0.3); }
else if (step === 2) { close(); }
step = (step + 1) % 4;
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 300); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("contextmenu", onContextMenu);
backdrop.removeEventListener("pointerdown", onBackdropDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.backdrop} />
<div className={styles.field}>
<span className={styles.hint}>우클릭 → 바깥 클릭</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_RENAME }} />
<span>이름 바꾸기</span>
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
</div>
</div>
);
}06 · 바깥 클릭 닫힘 — 메뉴 밖을 클릭한 상태가 되면 투명도 값이 낮아지며 사라지는 방식 — 측정 누적면적 88.409% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07항목 순차 등장 — 각 줄의 순서 값만큼 지연된 뒤 자리 값이 채워지는 방식
메뉴가 열린 상태가 되면 각 줄의 위치 값과 투명도 값이 자신의 순서 값에 비례한 시간만큼 늦게 원래 값으로 바뀌고, 순서 값이 큰 줄일수록 나중에 자리 값을 채웁니다. 항목이 많은 편집 메뉴나 색상·정렬 옵션이 섞인 메뉴에 맞는 형태입니다.
i*STAGGER_MS 지연을 매기는 applyStagger()
// 계산 겹: 항목 순서 i 에 STAGGER_MS 를 곱해 각 행마다 다른 transition-delay 를 인라인으로
// 매겨, 위에서부터 시간차를 두고 나타나는 순차 등장을 만든다.
function applyStagger() {
items.forEach(function (el, i) { el.style.transitionDelay = (i * STAGGER_MS) + 'ms'; });
}
function clearStagger() {
items.forEach(function (el) { el.style.transitionDelay = ''; });
}
<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField">
<span class="cm__hint">우클릭</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>
<span>이름 바꾸기</span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 항목의 순서 인덱스(i) 에 STAGGER_MS 를 곱해 각 행의 transition-delay 값을 코드로
// 계산해 매긴다 — CSS 로 고정 지연을 나열하지 않고 JS 가 인라인 style 로 채운다.
@include stage($stage-paper);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-blue-deep;
color: $subject-cream;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint { opacity: .85; font-weight: 500; font-size: 12px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
pointer-events: none;
transition: opacity $dur-quick $ease-out;
z-index: 5;
&.is-open { opacity: 1; pointer-events: auto; }
}
.cm__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
opacity: 0;
transform: translateX(-8px);
transition: opacity $dur-quick $ease-pop, transform $dur-quick $ease-pop;
.cm__menu.is-open & { opacity: 1; transform: translateX(0); }
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&--danger { color: $error; }
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 144px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__item { transition: none; }
.cm__item { opacity: 1; transform: none; transition-delay: 0ms !important; }
}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);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #0a4fe8;
color: #fff7e6;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint {
opacity: 0.85;
font-weight: 500;
font-size: 12px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 220ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
pointer-events: auto;
}
.cm__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
opacity: 0;
transform: translateX(-8px);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cm__menu.is-open .cm__item {
opacity: 1;
transform: translateX(0);
}
.cm__item:hover, .cm__item:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--danger {
color: #c2361a;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 144px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__item {
transition: none;
}
.cm__item {
opacity: 1;
transform: none;
transition-delay: 0ms !important;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var root = document.getElementById('cm');
var items = Array.prototype.slice.call(menu.querySelectorAll('.cm__item'));
var GAP = 6;
var STAGGER_MS = 55;
function positionAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
}
// 계산 겹: 항목 순서 i 에 STAGGER_MS 를 곱해 각 행마다 다른 transition-delay 를 인라인으로
// 매겨, 위에서부터 시간차를 두고 나타나는 순차 등장을 만든다.
function applyStagger() {
items.forEach(function (el, i) { el.style.transitionDelay = (i * STAGGER_MS) + 'ms'; });
}
function clearStagger() {
items.forEach(function (el) { el.style.transitionDelay = ''; });
}
function open(x, y) {
positionAt(x, y);
applyStagger();
menu.classList.add('is-open');
}
function close() {
menu.classList.remove('is-open');
clearStagger();
}
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
open(e.clientX, e.clientY);
});
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && e.target !== field) close();
});
menu.addEventListener('click', close);
// 자동 루프: 메뉴를 열어 항목이 위에서부터 순서대로(딜레이 i*STAGGER_MS) 나타나는 것을
// 반복해서 보여준다.
var step = 0, autoTimer = null;
function autoTick() {
if (step === 0) { var r = field.getBoundingClientRect(); open(r.left + 16, r.top + 16); }
else if (step === 2) { close(); }
step = (step + 1) % 4;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 320); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./IconLabelStagger.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_RENAME = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type IconLabelStaggerProps = {
color?: string;
};
export default function IconLabelStagger({
color = "#2f6df6",
}: IconLabelStaggerProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
if (!field || !menu) return;
const items = Array.prototype.slice.call(menu.querySelectorAll(`.${styles.item}`)) as HTMLElement[];
const GAP = 6;
const STAGGER_MS = 55;
function positionAt(x: number, y: number) {
const mw = menu!.offsetWidth, mh = menu!.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const px = Math.max(GAP, Math.min(x, vw - mw - GAP));
const py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu!.style.left = px.toFixed(0) + "px";
menu!.style.top = py.toFixed(0) + "px";
}
// 계산 겹: 항목의 순서 인덱스(i) 에 STAGGER_MS 를 곱해 각 행의 transition-delay 값을
// 코드로 계산해 매긴다 — CSS 로 고정 지연을 나열하지 않고 JS 가 인라인 style 로 채운다.
function applyStagger() {
items.forEach(function (el, i) {
el.style.transitionDelay = i * STAGGER_MS + "ms";
});
}
function clearStagger() {
items.forEach(function (el) {
el.style.transitionDelay = "";
});
}
function open(x: number, y: number) {
applyStagger();
positionAt(x, y);
menu!.classList.add(styles.open);
}
function close() {
menu!.classList.remove(styles.open);
clearStagger();
}
function onContextMenu(e: MouseEvent) { e.preventDefault(); stopAutoplay(); open(e.clientX, e.clientY); }
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && e.target !== field) close();
}
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("pointerdown", onDocDown);
let step = 0;
let autoTimer: number | null = null;
function autoTick() {
if (step === 0) { const r = field!.getBoundingClientRect(); open(r.left + r.width * 0.3, r.top + 16); }
else if (step === 2) { close(); }
step = (step + 1) % 4;
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 340); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("pointerdown", onDocDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field}>
<span className={styles.hint}>우클릭</span>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_RENAME }} />
<span>이름 바꾸기</span>
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
</div>
</div>
);
}07 · 항목 순차 등장 — 각 줄의 순서 값만큼 지연된 뒤 자리 값이 채워지는 방식 — 측정 누적면적 19.279% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08깊이 밀림 전환 — 들어간 하위 단계 값만큼 메뉴 전체가 옆으로 밀려나는 방식
하위 목록으로 들어간 깊이 값이 하나 늘어날 때마다 메뉴 상자의 가로 위치 값이 그 너비만큼 옆으로 밀려나고, 상위로 돌아간 상태가 되면 깊이 값이 줄며 위치 값이 되돌아갑니다. 여러 단계 하위 메뉴가 있는 도구나 모바일 화면의 계층형 메뉴에 맞는 형태입니다.
depth*PANEL_W 만큼 트랙을 미는 goTo()
// 계산 겹: 깊이(depth) 값에 패널 폭(PANEL_W)을 곱해 트랙을 옆으로 밀어, 깊이가 1 늘 때마다
// 정확히 패널 하나만큼 다음 화면이 이어 들어오게 한다.
function goTo(d) {
depth = d;
track.style.transform = 'translateX(' + (-depth * PANEL_W) + 'px)';
}
function open(x, y) { positionAt(x, y); goTo(0); menu.classList.add('is-open'); }
function close() { menu.classList.remove('is-open'); goTo(0); }

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField">
<span class="cm__hint">우클릭 → 공유 → 더보기</span>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<div class="cm__track" id="cmTrack">
<div class="cm__panel" data-depth="0">
<button class="cm__item" type="button" role="menuitem"><span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span><span>복사</span></button>
<button class="cm__item" type="button" role="menuitem" data-open="1"><span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M225.32 68.12L157.62 28a15.93 15.93 0 0 0-16.17.09l-67.7 40.72A16 16 0 0 0 66 82.55v78.06L30.68 181.79A16 16 0 0 0 30.6 209l67.7 40.72a15.93 15.93 0 0 0 16.17.08l67.7-40.72A16 16 0 0 0 190 195.36v-77.9l35.4-21.29a16 16 0 0 0 .08-27.19Z" opacity=".2"/><path d="M232.94 65a24 24 0 0 0-11.36-14.5L153.87 10.4a24 24 0 0 0-24.28.13L61.9 51.25A23.86 23.86 0 0 0 50 72.61v75.19l-9.94 6a24 24 0 0 0-2.62 39.29a1.4 1.4 0 0 0 .16.13l67.71 40.7a24 24 0 0 0 24.28-.13l67.7-40.72A23.85 23.85 0 0 0 209 174.6V99.34Z" opacity="0"/><path d="M136 128a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8m84.72-49.14L128 24 35.28 78.86A8 8 0 0 0 32 85.42V170.6a8 8 0 0 0 3.28 6.46L128 232l92.72-54.94a8 8 0 0 0 3.28-6.46V85.42a8 8 0 0 0-3.28-6.56"/></g></svg></span><span>공유</span><span class="cm__caret"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M181.66 133.66l-80 80a8 8 0 0 1-11.32-11.32L164.69 128L90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32"/></svg></span></button>
<button class="cm__item" type="button" role="menuitem" data-open="2"><span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><circle cx="128" cy="64" r="16" opacity=".2"/><circle cx="128" cy="192" r="16" opacity=".2"/><path d="M128 48a16 16 0 1 0 16 16a16 16 0 0 0-16-16m0 112a16 16 0 1 0 16 16a16 16 0 0 0-16-16m0-56a16 16 0 1 0 16 16a16 16 0 0 0-16-16"/></g></svg></span><span>더보기</span><span class="cm__caret"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M181.66 133.66l-80 80a8 8 0 0 1-11.32-11.32L164.69 128L90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32"/></svg></span></button>
</div>
<div class="cm__panel" data-depth="1">
<button class="cm__item cm__item--back" type="button" data-back="1">‹ 공유</button>
<button class="cm__item" type="button" role="menuitem"><span>메신저</span></button>
<button class="cm__item" type="button" role="menuitem"><span>링크 복사</span></button>
</div>
<div class="cm__panel" data-depth="2">
<button class="cm__item cm__item--back" type="button" data-back="1">‹ 더보기</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem"><span>삭제</span></button>
<button class="cm__item" type="button" role="menuitem"><span>속성</span></button>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 현재 드릴다운 깊이(depth) 에 패널 폭(PANEL_W)을 곱한 값만큼 트랙 전체를 옆으로
// translateX 시켜, 깊이가 늘 때마다 다음 패널이 같은 폭만큼 밀려 들어오게 만든다.
@include stage($stage-yellow);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-ink;
color: $subject-cream;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint { opacity: .8; font-weight: 500; font-size: 12px; }
.cm__menu {
position: fixed;
top: 0;
left: 0;
width: 168px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
opacity: 0;
pointer-events: none;
overflow: hidden;
transition: opacity $dur-quick $ease-out;
z-index: 5;
&.is-open { opacity: 1; pointer-events: auto; }
}
.cm__track {
display: flex;
width: 100%;
transition: transform $dur-base $ease-pop;
}
.cm__panel {
flex: none;
width: 168px;
box-sizing: border-box;
padding: $sp-1;
display: grid;
gap: $sp-1;
align-content: start;
}
.cm__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
width: 100%;
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&--back { color: rgba($subject-ink, .6); font-weight: 600; }
}
.cm__caret { margin-left: auto; width: 12px; height: 12px; color: rgba($subject-ink, .55); }
.cm__caret svg { display: block; width: 100%; height: 100%; }
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu, .cm__panel { width: 144px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #17141a;
color: #fff7e6;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
}
.cm__hint {
opacity: 0.8;
font-weight: 500;
font-size: 12px;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
width: 168px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
overflow: hidden;
transition: opacity 220ms ease-out;
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
pointer-events: auto;
}
.cm__track {
display: flex;
width: 100%;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cm__panel {
flex: none;
width: 168px;
box-sizing: border-box;
padding: 4px;
display: grid;
gap: 4px;
align-content: start;
}
.cm__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
width: 100%;
}
.cm__item:hover, .cm__item:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--back {
color: rgba(23, 20, 26, 0.6);
font-weight: 600;
}
.cm__caret {
margin-left: auto;
width: 12px;
height: 12px;
color: rgba(23, 20, 26, 0.55);
}
.cm__caret svg {
display: block;
width: 100%;
height: 100%;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu, .cm__panel {
width: 144px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu, .cm__track {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var track = document.getElementById('cmTrack');
var root = document.getElementById('cm');
var GAP = 6, PANEL_W = 168, depth = 0;
function positionAt(x, y) {
var mw = menu.offsetWidth;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var mh = menu.offsetHeight || 160;
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
}
// 계산 겹: 깊이(depth) 값에 패널 폭(PANEL_W)을 곱해 트랙을 옆으로 밀어, 깊이가 1 늘 때마다
// 정확히 패널 하나만큼 다음 화면이 이어 들어오게 한다.
function goTo(d) {
depth = d;
track.style.transform = 'translateX(' + (-depth * PANEL_W) + 'px)';
}
function open(x, y) { positionAt(x, y); goTo(0); menu.classList.add('is-open'); }
function close() { menu.classList.remove('is-open'); goTo(0); }
menu.addEventListener('click', function (e) {
var openBtn = e.target.closest('[data-open]');
var backBtn = e.target.closest('[data-back]');
if (openBtn) goTo(parseInt(openBtn.getAttribute('data-open'), 10));
else if (backBtn) goTo(0);
});
field.addEventListener('contextmenu', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
open(e.clientX, e.clientY);
});
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && e.target !== field) close();
});
// 자동 루프: 메뉴를 연 뒤 depth 0 → 1(공유) → 0 → 2(더보기) 순으로 이동해 트랙이 패널 폭만큼
// 밀렸다가 되돌아오는 것을 보여준다.
var seq = [0, 1, 0, 2, 0];
var si = 0, autoTimer = null, opened = false;
function autoTick() {
if (!opened) { var r = field.getBoundingClientRect(); open(r.left + 16, r.top + 16); opened = true; return; }
goTo(seq[si % seq.length]);
si++;
if (si > seq.length + 1) { close(); opened = false; si = 0; }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 340); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./NestedDepthSlide.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_SHARE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M225.32 68.12L157.62 28a15.93 15.93 0 0 0-16.17.09l-67.7 40.72A16 16 0 0 0 66 82.55v78.06L30.68 181.79A16 16 0 0 0 30.6 209l67.7 40.72a15.93 15.93 0 0 0 16.17.08l67.7-40.72A16 16 0 0 0 190 195.36v-77.9l35.4-21.29a16 16 0 0 0 .08-27.19Z" opacity=".2"/><path d="M232.94 65a24 24 0 0 0-11.36-14.5L153.87 10.4a24 24 0 0 0-24.28.13L61.9 51.25A23.86 23.86 0 0 0 50 72.61v75.19l-9.94 6a24 24 0 0 0-2.62 39.29a1.4 1.4 0 0 0 .16.13l67.71 40.7a24 24 0 0 0 24.28-.13l67.7-40.72A23.85 23.85 0 0 0 209 174.6V99.34Z" opacity="0"/><path d="M136 128a8 8 0 0 1-8 8h-8v8a8 8 0 0 1-16 0v-8h-8a8 8 0 0 1 0-16h8v-8a8 8 0 0 1 16 0v8h8a8 8 0 0 1 8 8m84.72-49.14L128 24 35.28 78.86A8 8 0 0 0 32 85.42V170.6a8 8 0 0 0 3.28 6.46L128 232l92.72-54.94a8 8 0 0 0 3.28-6.46V85.42a8 8 0 0 0-3.28-6.56"/></g></svg>';
const ICON_MORE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><circle cx="128" cy="64" r="16" opacity=".2"/><circle cx="128" cy="192" r="16" opacity=".2"/><path d="M128 48a16 16 0 1 0 16 16a16 16 0 0 0-16-16m0 112a16 16 0 1 0 16 16a16 16 0 0 0-16-16m0-56a16 16 0 1 0 16 16a16 16 0 0 0-16-16"/></g></svg>';
const ICON_CARET = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="currentColor" d="M181.66 133.66l-80 80a8 8 0 0 1-11.32-11.32L164.69 128L90.34 53.66a8 8 0 0 1 11.32-11.32l80 80a8 8 0 0 1 0 11.32"/></svg>';
export type NestedDepthSlideProps = {
color?: string;
};
export default function NestedDepthSlide({
color = "#2f6df6",
}: NestedDepthSlideProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
if (!field || !menu || !track) return;
const GAP = 6, PANEL_W = 168;
let depth = 0;
function positionAt(x: number, y: number) {
const mw = menu!.offsetWidth;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const mh = menu!.offsetHeight || 160;
const px = Math.max(GAP, Math.min(x, vw - mw - GAP));
const py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu!.style.left = px.toFixed(0) + "px";
menu!.style.top = py.toFixed(0) + "px";
}
// 계산 겹: 깊이(depth) 값에 패널 폭(PANEL_W)을 곱해 트랙을 옆으로 밀어, 깊이가 1 늘 때마다
// 정확히 패널 하나만큼 다음 화면이 이어 들어오게 한다.
function goTo(d: number) {
depth = d;
track!.style.transform = "translateX(" + -depth * PANEL_W + "px)";
}
function open(x: number, y: number) { positionAt(x, y); goTo(0); menu!.classList.add(styles.open); }
function close() { menu!.classList.remove(styles.open); goTo(0); }
function onTrackClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const openBtn = target.closest("[data-open]") as HTMLElement | null;
const backBtn = target.closest("[data-back]") as HTMLElement | null;
if (openBtn) goTo(parseInt(openBtn.getAttribute("data-open") || "0", 10));
else if (backBtn) goTo(0);
}
function onContextMenu(e: MouseEvent) { e.preventDefault(); stopAutoplay(); open(e.clientX, e.clientY); }
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && e.target !== field) close();
}
track.addEventListener("click", onTrackClick);
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("pointerdown", onDocDown);
// 자동 루프: 메뉴를 연 뒤 depth 0 → 1(공유) → 0 → 2(더보기) 순으로 이동해 트랙이 패널 폭만큼
// 밀렸다가 되돌아오는 것을 보여준다.
const seq = [0, 1, 0, 2, 0];
let si = 0, autoTimer: number | null = null, opened = false;
function autoTick() {
if (!opened) { const r = field!.getBoundingClientRect(); open(r.left + 16, r.top + 16); opened = true; return; }
goTo(seq[si % seq.length]);
si++;
if (si > seq.length + 1) { close(); opened = false; si = 0; }
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 340); autoTick(); }
return () => {
stopAutoplay();
track.removeEventListener("click", onTrackClick);
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("pointerdown", onDocDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field}>
<span className={styles.hint}>우클릭 → 공유 → 더보기</span>
</div>
<div className={styles.menu} role="menu">
<div className={styles.track}>
<div className={styles.panel} data-depth="0">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem" data-open="1">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_SHARE }} />
<span>공유</span>
<span className={styles.caret} dangerouslySetInnerHTML={{ __html: ICON_CARET }} />
</button>
<button type="button" className={styles.item} role="menuitem" data-open="2">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_MORE }} />
<span>더보기</span>
<span className={styles.caret} dangerouslySetInnerHTML={{ __html: ICON_CARET }} />
</button>
</div>
<div className={styles.panel} data-depth="1">
<button type="button" className={`${styles.item} ${styles.back}`} data-back="1">‹ 공유</button>
<button type="button" className={styles.item} role="menuitem"><span>메신저</span></button>
<button type="button" className={styles.item} role="menuitem"><span>링크 복사</span></button>
</div>
<div className={styles.panel} data-depth="2">
<button type="button" className={`${styles.item} ${styles.back}`} data-back="1">‹ 더보기</button>
<button type="button" className={styles.item} role="menuitem"><span>삭제</span></button>
<button type="button" className={styles.item} role="menuitem"><span>속성</span></button>
</div>
</div>
</div>
</div>
</div>
);
}08 · 깊이 밀림 전환 — 들어간 하위 단계 값만큼 메뉴 전체가 옆으로 밀려나는 방식 — 측정 누적면적 15.822% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09길게 누르기 등장 — 누른 시간 값이 기준을 넘으면 메뉴가 뜨는 방식
화면을 누른 시간 값이 정해진 기준 값을 넘은 상태가 되면 누른 좌표 값을 기준으로 메뉴가 나타나고, 기준에 못 미친 채 손을 뗀 상태가 되면 아무 메뉴도 뜨지 않습니다. 모바일 목록 항목이나 터치 화면 이미지를 길게 누르는 화면에 맞는 형태입니다.
HOLD_MS 진행률로 링을 채우는 tickPress()
// 계산 겹: (경과시간 / HOLD_MS) 를 0~1 로 clamp 한 진행률을 원둘레(CIRC)에 곱해
// stroke-dashoffset 값을 매 프레임 다시 계산한다 — 시간이 곧 링이 채워지는 양이 된다.
function tickPress(ts) {
var elapsed = performance.now() - pressStart;
var prog = Math.max(0, Math.min(1, elapsed / HOLD_MS));
ringFg.setAttribute('stroke-dashoffset', (CIRC * (1 - prog)).toFixed(1));
if (prog >= 1) { finishPress(); return; }
rafId = requestAnimationFrame(tickPress);
}

<main class="stage">
<div class="cm is-demo" id="cm">
<div class="cm__field" id="cmField">
<span class="cm__hint">길게 누르기</span>
<svg class="cm__ring" id="cmRing" viewBox="0 0 40 40">
<circle class="cm__ring-bg" cx="20" cy="20" r="16"></circle>
<circle class="cm__ring-fg" id="cmRingFg" cx="20" cy="20" r="16" stroke-dasharray="100.5" stroke-dashoffset="100.5"></circle>
</svg>
</div>
<div class="cm__menu" id="cmMenu" role="menu">
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span>복사</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg></span>
<span>붙여넣기</span>
</button>
<button class="cm__item" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>
<span>이름 바꾸기</span>
</button>
<button class="cm__item cm__item--danger" type="button" role="menuitem">
<span class="cm__ic"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
<span>삭제</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 포인터가 눌려 있는 시간을 requestAnimationFrame 으로 매 프레임 재서 0~1 진행률로
// 정규화하고, 그 값을 링 SVG 의 stroke-dashoffset 에 그대로 대입해 원형 진행 표시를 그린다.
@include stage($stage-ink);
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
position: relative;
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: $r-card;
background: $subject-mint;
color: $subject-ink;
display: grid;
place-items: center;
padding: $sp-3;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
overflow: hidden;
}
.cm__hint { opacity: .75; font-weight: 500; font-size: 12px; }
.cm__ring {
position: absolute;
width: 40px;
height: 40px;
transform: rotate(-90deg);
opacity: 0;
circle {
fill: none;
stroke-width: 4;
}
.cm__ring-bg { stroke: rgba($subject-ink, .18); }
.cm__ring-fg { stroke: $subject-ink; stroke-linecap: round; }
&.is-active { opacity: 1; }
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: $stage-paper;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
display: grid;
gap: $sp-1;
opacity: 0;
transform: scale(.9);
transform-origin: center;
pointer-events: none;
transition: opacity $dur-quick $ease-pop, transform $dur-quick $ease-pop;
z-index: 5;
&.is-open { opacity: 1; transform: scale(1); pointer-events: auto; }
}
.cm__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 0;
background: none;
border-radius: $r-xs;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
cursor: pointer;
text-align: left;
&:hover, &:focus-visible { background: rgba($subject-ink, .08); }
&--danger { color: $error; }
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: $subject-blue;
svg { display: block; width: 100%; height: 100%; }
}
.cm__item--danger .cm__ic { color: $error; }
@media (max-width: 340px) {
.cm__field { width: 176px; height: 108px; font-size: 12px; padding: $sp-2; }
.cm__menu { min-width: 144px; }
.cm__item { padding: $sp-1 $sp-2; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.cm__menu { 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);
}
.cm {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.cm__field {
position: relative;
width: clamp(190px, 60vw, 260px);
height: 130px;
border-radius: 16px;
background: #4cd4a6;
color: #17141a;
display: grid;
place-items: center;
padding: 12px;
box-sizing: border-box;
font: 600 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
user-select: none;
overflow: hidden;
}
.cm__hint {
opacity: 0.75;
font-weight: 500;
font-size: 12px;
}
.cm__ring {
position: absolute;
width: 40px;
height: 40px;
transform: rotate(-90deg);
opacity: 0;
}
.cm__ring circle {
fill: none;
stroke-width: 4;
}
.cm__ring .cm__ring-bg {
stroke: rgba(23, 20, 26, 0.18);
}
.cm__ring .cm__ring-fg {
stroke: #17141a;
stroke-linecap: round;
}
.cm__ring.is-active {
opacity: 1;
}
.cm__menu {
position: fixed;
top: 0;
left: 0;
min-width: 168px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
display: grid;
gap: 4px;
opacity: 0;
transform: scale(0.9);
transform-origin: center;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
z-index: 5;
}
.cm__menu.is-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.cm__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 0;
background: none;
border-radius: 4px;
font: 500 13px/1.2 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
cursor: pointer;
text-align: left;
}
.cm__item:hover, .cm__item:focus-visible {
background: rgba(23, 20, 26, 0.08);
}
.cm__item--danger {
color: #c2361a;
}
.cm__ic {
width: 16px;
height: 16px;
flex: none;
color: #2f6df6;
}
.cm__ic svg {
display: block;
width: 100%;
height: 100%;
}
.cm__item--danger .cm__ic {
color: #c2361a;
}
@media (max-width: 340px) {
.cm__field {
width: 176px;
height: 108px;
font-size: 12px;
padding: 8px;
}
.cm__menu {
min-width: 144px;
}
.cm__item {
padding: 4px 8px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__menu {
transition: none;
}
}(function () {
var field = document.getElementById('cmField');
var menu = document.getElementById('cmMenu');
var ring = document.getElementById('cmRing');
var ringFg = document.getElementById('cmRingFg');
var root = document.getElementById('cm');
var GAP = 6;
var HOLD_MS = 620;
var CIRC = 100.5;
function positionAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var px = Math.max(GAP, Math.min(x, vw - mw - GAP));
var py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu.style.left = px.toFixed(0) + 'px';
menu.style.top = py.toFixed(0) + 'px';
}
function open(x, y) { positionAt(x, y); menu.classList.add('is-open'); }
function close() { menu.classList.remove('is-open'); }
var pressStart = 0, rafId = null, pressX = 0, pressY = 0;
// 계산 겹: (경과시간 / HOLD_MS) 를 0~1 로 clamp 한 진행률을 원둘레(CIRC)에 곱해
// stroke-dashoffset 값을 매 프레임 다시 계산한다 — 시간이 곧 링이 채워지는 양이 된다.
function tickPress(ts) {
var elapsed = performance.now() - pressStart;
var prog = Math.max(0, Math.min(1, elapsed / HOLD_MS));
ringFg.setAttribute('stroke-dashoffset', (CIRC * (1 - prog)).toFixed(1));
if (prog >= 1) { finishPress(); return; }
rafId = requestAnimationFrame(tickPress);
}
function startPress(x, y) {
pressX = x; pressY = y;
pressStart = performance.now();
ring.classList.add('is-active');
ringFg.setAttribute('stroke-dashoffset', CIRC.toFixed(1));
rafId = requestAnimationFrame(tickPress);
}
function cancelPress() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
ring.classList.remove('is-active');
ringFg.setAttribute('stroke-dashoffset', CIRC.toFixed(1));
}
function finishPress() {
cancelPress();
open(pressX, pressY);
}
field.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
startPress(e.clientX, e.clientY);
});
field.addEventListener('pointerup', cancelPress);
field.addEventListener('pointerleave', cancelPress);
field.addEventListener('contextmenu', function (e) { e.preventDefault(); });
document.addEventListener('pointerdown', function (e) {
if (menu.classList.contains('is-open') && !menu.contains(e.target) && e.target !== field) close();
});
menu.addEventListener('click', close);
// 자동 루프: 필드 중앙을 누른 채 HOLD_MS 만큼 링이 차오르는 것을 흉내내고, 다 차면 메뉴를 연 뒤
// 잠시 후 닫는다.
var phase = 0, autoTimer = null, simStart = 0;
function autoTick() {
var r = field.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
if (phase === 0) { simStart = performance.now(); pressStart = simStart; pressX = cx; pressY = cy; ring.classList.add('is-active'); phase = 1; }
else if (phase === 1) {
var elapsed = performance.now() - simStart;
var prog = Math.max(0, Math.min(1, elapsed / HOLD_MS));
ringFg.setAttribute('stroke-dashoffset', (CIRC * (1 - prog)).toFixed(1));
if (prog >= 1) { ring.classList.remove('is-active'); open(cx, cy); phase = 2; }
} else if (phase === 2) {
phase = 3;
} else { close(); ringFg.setAttribute('stroke-dashoffset', CIRC.toFixed(1)); phase = 0; }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (rafId) { cancelAnimationFrame(rafId); rafId = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 130); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./LongPressMobileOpen.module.scss";
const ICON_COPY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>';
const ICON_PASTE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 48v168a8 8 0 0 1-8 8H56a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a39.83 39.83 0 0 0-8 24v8h80v-8a39.83 39.83 0 0 0-8-24h40a8 8 0 0 1 8 8" opacity=".2"/><path d="M200 32h-36.26a47.92 47.92 0 0 0-71.48 0H56a16 16 0 0 0-16 16v168a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m-72 0a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m72 184H56V48h26.75A47.9 47.9 0 0 0 80 64v8a8 8 0 0 0 8 8h80a8 8 0 0 0 8-8v-8a47.9 47.9 0 0 0-2.75-16H200Z"/></g></svg>';
const ICON_RENAME = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="m221.66 90.34l-56 56l-40-40l56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>';
const ICON_TRASH = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>';
export type LongPressMobileOpenProps = {
color?: string;
};
export default function LongPressMobileOpen({
color = "#2f6df6",
}: LongPressMobileOpenProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const field = root.querySelector<HTMLDivElement>(`.${styles.field}`);
const menu = root.querySelector<HTMLDivElement>(`.${styles.menu}`);
const ring = root.querySelector<SVGSVGElement>(`.${styles.ring}`);
const ringFg = root.querySelector<SVGCircleElement>(`.${styles.ringFg}`);
if (!field || !menu || !ring || !ringFg) return;
const GAP = 6, HOLD_MS = 620, CIRC = 100.5;
function positionAt(x: number, y: number) {
const mw = menu!.offsetWidth, mh = menu!.offsetHeight;
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const px = Math.max(GAP, Math.min(x, vw - mw - GAP));
const py = Math.max(GAP, Math.min(y, vh - mh - GAP));
menu!.style.left = px.toFixed(0) + "px";
menu!.style.top = py.toFixed(0) + "px";
}
function open(x: number, y: number) { positionAt(x, y); menu!.classList.add(styles.open); }
function close() { menu!.classList.remove(styles.open); }
let pressStart = 0, rafId: number | null = null, pressX = 0, pressY = 0;
// 계산 겹: (경과시간 / HOLD_MS) 를 0~1 로 clamp 한 진행률을 원둘레(CIRC)에 곱해
// stroke-dashoffset 값을 매 프레임 다시 계산한다 — 시간이 곧 링이 채워지는 양이 된다.
function tickPress() {
const elapsed = performance.now() - pressStart;
const prog = Math.max(0, Math.min(1, elapsed / HOLD_MS));
ringFg!.setAttribute("stroke-dashoffset", (CIRC * (1 - prog)).toFixed(1));
if (prog >= 1) { finishPress(); return; }
rafId = requestAnimationFrame(tickPress);
}
function startPress(x: number, y: number) {
pressX = x; pressY = y;
pressStart = performance.now();
ring!.classList.add(styles.ringOn);
ringFg!.setAttribute("stroke-dashoffset", CIRC.toFixed(1));
rafId = requestAnimationFrame(tickPress);
}
function cancelPress() {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
ring!.classList.remove(styles.ringOn);
ringFg!.setAttribute("stroke-dashoffset", CIRC.toFixed(1));
}
function finishPress() {
cancelPress();
open(pressX, pressY);
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); startPress(e.clientX, e.clientY); }
function onContextMenu(e: MouseEvent) { e.preventDefault(); }
function onDocDown(e: PointerEvent) {
if (menu!.classList.contains(styles.open) && !menu!.contains(e.target as Node) && e.target !== field) close();
}
field.addEventListener("pointerdown", onPointerDown);
field.addEventListener("pointerup", cancelPress);
field.addEventListener("pointerleave", cancelPress);
field.addEventListener("contextmenu", onContextMenu);
document.addEventListener("pointerdown", onDocDown);
menu.addEventListener("click", close);
// 자동 루프: 필드 중앙을 누른 채 HOLD_MS 만큼 링이 차오르는 것을 흉내내고, 다 차면 메뉴를 연 뒤
// 잠시 후 닫는다.
let phase = 0, autoTimer: number | null = null, simStart = 0;
function autoTick() {
const r = field!.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
if (phase === 0) { simStart = performance.now(); pressStart = simStart; pressX = cx; pressY = cy; ring!.classList.add(styles.ringOn); phase = 1; }
else if (phase === 1) {
const elapsed = performance.now() - simStart;
const prog = Math.max(0, Math.min(1, elapsed / HOLD_MS));
ringFg!.setAttribute("stroke-dashoffset", (CIRC * (1 - prog)).toFixed(1));
if (prog >= 1) { ring!.classList.remove(styles.ringOn); open(cx, cy); phase = 2; }
} else if (phase === 2) {
phase = 3;
} else { close(); ringFg!.setAttribute("stroke-dashoffset", CIRC.toFixed(1)); phase = 0; }
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 130); autoTick(); }
return () => {
stopAutoplay();
field.removeEventListener("pointerdown", onPointerDown);
field.removeEventListener("pointerup", cancelPress);
field.removeEventListener("pointerleave", cancelPress);
field.removeEventListener("contextmenu", onContextMenu);
document.removeEventListener("pointerdown", onDocDown);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.cm}>
<div className={styles.field}>
<span className={styles.hint}>길게 누르기</span>
<svg className={styles.ring} viewBox="0 0 40 40">
<circle className={styles.ringBg} cx={20} cy={20} r={16} />
<circle className={styles.ringFg} cx={20} cy={20} r={16} strokeDasharray="100.5" strokeDashoffset="100.5" />
</svg>
</div>
<div className={styles.menu} role="menu">
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_COPY }} />
<span>복사</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_PASTE }} />
<span>붙여넣기</span>
</button>
<button type="button" className={styles.item} role="menuitem">
<span className={styles.ic} style={{ color }} dangerouslySetInnerHTML={{ __html: ICON_RENAME }} />
<span>이름 바꾸기</span>
</button>
<button type="button" className={`${styles.item} ${styles.danger}`} role="menuitem">
<span className={styles.ic} dangerouslySetInnerHTML={{ __html: ICON_TRASH }} />
<span>삭제</span>
</button>
</div>
</div>
</div>
);
}09 · 길게 누르기 등장 — 누른 시간 값이 기준을 넘으면 메뉴가 뜨는 방식 — 측정 누적면적 19.026% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 패턴을 바꾸는 곳 — 위치·시간·깊이 값의 경계
실제로 이런 메뉴를 만들 때 위치·시간·깊이 값을 어디서 조정하면 되는지 세 개만 짚어도 손에 잡히는 느낌이 크게 갈립니다.
위치 여백 기준 — 01 GAP
01 의 GAP(8)은 메뉴 상자가 화면 가장자리에서 남기는 최소 여백 값이라, 0으로 낮추면 클릭 지점이 화면 끝에 딱 붙어도 메뉴가 경계에 바짝 붙어 잘려 보이는 인상을 주고, 24 대로 올리면 클릭 지점과 메뉴 사이에 여백이 넉넉해 화면 끝 클릭에서도 안정적으로 보이는 대신 클릭한 자리에서 메뉴가 살짝 떨어져 나는 느낌이 커집니다.
하위 메뉴 대기 시간 기준 — 04 CASCADE_MS
04 의 CASCADE_MS(260)는 정렬 항목 위에 머물러야 하위 목록이 열리는 대기 시간 값이라, 100 대로 낮추면 커서가 스쳐 지나가기만 해도 하위 목록이 바로 따라 열려 오작동이 늘고, 500 대로 올리면 확실히 머물러야만 열려 신중해지는 대신 반응이 느리게 느껴집니다.
중첩 깊이 폭 기준 — 08 PANEL_W
08 의 PANEL_W(168)는 깊이 값이 하나 늘 때마다 트랙이 옆으로 밀리는 폭 값이라, 100 대로 줄이면 다음 패널이 짧은 거리만 밀려 들어와 계층 전환이 좁고 빠르게 느껴지고, 240 대로 늘리면 패널이 완전히 다른 화면처럼 크게 밀려 들어와 계층이 뚜렷하게 구분되는 대신 전환에 걸리는 체감 거리가 길어집니다.
어디서 깨지는가 — 빠른 연속 클릭·좁은 화면·중간에 닫기
아홉 개 다 결정형 계산이라 같은 입력을 넣으면 항상 같은 값이 나오지만, 입력을 극단으로 밀어붙이면 계산의 경계가 그대로 드러나는 자리가 있습니다.
빠른 연속 클릭 — 09
09 는 finishPress()가 elapsed / HOLD_MS 진행 값이 1이 되어야만 호출되므로, 화면을 짧게 여러 번 두드리는 빠른 연속 클릭에서는 매번 pointerup이 cancelPress()를 불러 진행 값을 0으로 되돌립니다 — 누른 시간 값이 HOLD_MS(620)를 한 번도 채우지 못한 채 끝나, 아무리 여러 번 눌러도 메뉴가 한 번도 뜨지 않습니다.
좁은 화면 — 02
02 는 flipFor()가 px = Math.max(GAP, Math.min(px, vw - mw - GAP))로 clamp 하는데, 화면 너비 값(vw)이 메뉴 너비 값(mw)보다 좁아 vw - mw - GAP가 GAP보다 작아지면 Math.max가 그 작은 값 대신 GAP를 골라 메뉴 오른쪽 끝이 화면 밖으로 남습니다. ndf4j3ah 안 데모에서 화면 폭을 직접 좁혀 가며 이 넘침 값이 재현되는지 눈으로 확인할 수 있습니다 — 반전 로직 자체는 맞게 동작해도 뷰포트가 메뉴보다 좁은 상태에서는 clamp 값의 우선순위 때문에 여백이 깨집니다.
중간에 닫기 — 08
08 은 close()가 menu.classList.remove('is-open'); goTo(0);을 곧바로 실행해 깊이 값을 0으로 되돌리므로, 하위 목록으로 두세 단계 들어간 상태에서 바깥을 클릭해 메뉴 전체를 닫으면 트랙이 최상위 패널까지 한 번에 되감기며 지나온 단계 값이 화면에 스쳐 보입니다 — 이 메뉴를 다시 열면 open()이 다시 goTo(0)부터 시작하므로 직전에 몇 단계 들어가 있었는지는 값으로 남지 않습니다.
변형 세 가지와 접근성
이 아홉 가지를 직접 만들 때는 값 세 가지만 바꿔도 손에 잡히는 인상이 크게 달라집니다.
| 이름 | 바뀐 값 | 느낌 |
|---|---|---|
| 하위 메뉴 대기 시간 | 04 CASCADE_MS 260ms → 120ms | 260ms 로 두면 항목 위를 스쳐 지나갈 때는 하위 메뉴가 열리지 않고 실제로 머무를 때만 열려 오작동이 적고, 120ms 로 줄이면 살짝만 스쳐도 하위 메뉴가 바로 따라 열려 반응은 빠르지만 의도치 않게 열리는 일이 늘어난다 |
| 항목 등장 간격 | 07 STAGGER_MS 55ms → 90ms | 55ms 로 두면 항목들이 거의 동시에 훑듯이 나타나고, 90ms 로 늘리면 한 줄씩 뚜렷하게 시간차를 두고 나타나 항목 수가 눈에 잘 들어오는 대신 메뉴가 다 채워지기까지 더 오래 걸린다 |
| 길게 누르기 기준 시간 | 09 HOLD_MS 620ms → 900ms | 620ms 로 두면 살짝만 오래 눌러도 메뉴가 열려 스크롤 중 손을 떼기 전에 실수로 열리기 쉽고, 900ms 로 늘리면 확실히 누르고 있어야만 열려 실수는 줄지만 의도한 사용자도 더 기다려야 한다 |
아홉 개 모두 role="menu"·role="menuitem"을 메뉴와 항목에 붙여 두었지만, 실제로 키보드만으로 메뉴를 열고 항목 사이를 옮겨 다닐 수 있는 것은 방향키를 등록해 둔 05 하나뿐이고 나머지 여덟 개는 마우스 우클릭이나 터치가 유일한 입력 경로입니다. prefers-reduced-motion: reduce가 참인 상태가 되면 아홉 개 다 자동 재생 setInterval 루프만 걸리지 않을 뿐 contextmenu·pointerdown·keydown 리스너는 그대로 남아 있어 직접 조작하는 값 계산은 막히지 않지만, 상태가 바뀔 때 알리는 aria-live나 서브메뉴가 열렸는지 알리는 aria-expanded는 아홉 개 어디에도 없습니다 — 실제 서비스에 쓸 때는 contextmenu 이벤트 문서를 참고해 04·08 의 서브메뉴에 aria-expanded를, 05 외 나머지에도 방향키 대체 경로를 얹어야 합니다.
FAQ
실제 컨텍스트 메뉴를 붙인다면 어떤 조합부터 시작하면 되나요?
좌표 값을 그대로 옮기는 01(커서 위치 고정)과 화면 경계에서 값을 뒤집는 02(화면 경계 반전)를 함께 쓰면 클릭한 자리에 메뉴가 뜨면서도 화면 밖으로 나가지 않아 가장 무난합니다. 06(바깥 클릭 닫힘)의 backdrop 판정을 더하면 메뉴 바깥을 눌렀을 때 자연스럽게 닫히는 동작까지 갖춰집니다.
컨텍스트 메뉴에 하위 메뉴를 넣을 때 무엇부터 정해야 하나요?
04 처럼 머문 시간 값(CASCADE_MS)부터 정해 지나가는 커서에 반응하지 않게 하고, 08 처럼 깊이 값에 비례한 이동 폭(PANEL_W)을 정해 몇 단계까지 들어가도 트랙이 어색하게 겹치지 않게 해야 합니다. 두 값을 먼저 정하고 나서 메뉴 항목 수와 텍스트 길이를 채우면 값끼리 서로 부딪히지 않습니다.
모바일 웹에서는 우클릭이 없는데 이런 메뉴를 어떻게 여나요?
09(길게 누르기 등장)처럼 pointerdown 시점부터 지난 시간을 재는 방식으로 길게 누르기를 우클릭 대신 씁니다. 터치 길게 누르기에서 브라우저가 contextmenu 이벤트를 대신 보내주는 경우가 있어, e.preventDefault()로 기본 메뉴를 막고 커스텀 메뉴만 보여주면 됩니다.
더 읽을거리
- CSS 툴팁 말풍선 9종 — 한 줄 복붙으로 — 021 은 가리키는 요소의 자리를 기준으로 말풍선 위치를 계산하는 데 비해, 이 편은 클릭한 좌표 값 자체를 자리 값으로 쓰는 계산을 다룹니다.
- CSS 화면 전환 효과 9종 — 화면이 바뀌는 순간 모음 — 218 은 화면 전체가 다른 화면으로 넘어가는 전환 값을 다루는 데 비해, 이 편은 뷰포트 경계에 닿았는지 값 하나로 메뉴 한 조각의 방향을 뒤집는 계산을 다룹니다.
- 토스트 알림 UI 9종 — 쌓이고 밀리고 사라지는 스택 — 255 는 알림이 쌓이는 순서 값으로 위치를 계산하는 데 비해, 이 편은 하위 메뉴로 들어간 깊이 값으로 옆으로 밀리는 위치를 계산합니다.









