GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

컨텍스트 메뉴 UI 9종 — 클릭 좌표 값이 메뉴 위치를 정하는 방식

컨텍스트 메뉴 UI는 마우스 오른쪽 버튼이나 길게 누르기로 여는 작은 목록인데, 아홉 개 중 하나는 클릭한 화면 좌표 값이 메뉴가 나타날 위치 값을 그대로 정하고, 하나는 그 위치 값이 화면 가장자리에 가까운 상태가 되면 펼쳐지는 방향 값이 반대로 바뀌며, 또 하나는 하위 메뉴로

아홉 화면 다 실제 fetch 요청이나 clipboard 복사를 부르지 않는 값 계산 시뮬레이션이라, 메뉴 안 "복사"·"공유 → 메신저"·"링크 복사" 항목은 라벨만 있을 뿐 눌러도 실제로 무언가가 복사되지는 않습니다. 그래도 아홉 개 전부 리스너가 contextmenu·pointerdown·click·keydown 같은 실제 사용자 입력 그대로 등록돼 있어, 자동 재생 중이더라도 화면을 직접 우클릭하거나 눌러 보는 순간 그 자리에서 자동 루프가 멎고 손으로 넣은 값으로 같은 함수가 다시 계산합니다.

아홉 개를 가르는 기준은 값이 언제 어디서 나오느냐입니다 — 우클릭 메뉴가 뜨는 자리를 정하는 값(01·02·03), 손을 얼마나 오래 대고 있었는지를 재는 값(04·09), 순서나 상태가 바뀌면서 나오는 값(05·06·07·08)으로 나눴습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아래 표의 실측 값을 먼저 보면, 서브메뉴 드롭다운처럼 하위 목록이 딸린 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)';
}
커서 위치 고정 — 클릭 좌표 값이 그대로 메뉴가 뜨는 자리 값이 되는 방식
새 창에서 열기

01 · 커서 위치 고정 — 클릭 좌표 값이 그대로 메뉴가 뜨는 자리 값이 되는 방식 — 측정 누적면적 51.248% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

positionAt(x, y)GAP = 8menu.style.transform = translate(px,py)

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);
}
화면 경계 반전 — 자리 값이 화면 끝에 닿으면 펼치는 방향 값이 반대로 바뀌는 방식
새 창에서 열기

02 · 화면 경계 반전 — 자리 값이 화면 끝에 닿으면 펼치는 방향 값이 반대로 바뀌는 방식 — 측정 누적면적 45.69% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

flipFor(x, y)flip-x / flip-y(x + mw) > vw

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');
}
크기 페이드 등장 — 크기 값과 투명도 값이 함께 작은 값에서 커지며 나타나는 방식
새 창에서 열기

03 · 크기 페이드 등장 — 크기 값과 투명도 값이 함께 작은 값에서 커지며 나타나는 방식 — 측정 누적면적 37.249% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

openAt(x, y)transform-origin: originX% originY%((x - left) / mw) * 100

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(); });
하위 메뉴 순차 등장 — 상위 항목에 머무른 시간 값이 지나야 하위 목록이 펼쳐지는 방식
새 창에서 열기

04 · 하위 메뉴 순차 등장 — 상위 항목에 머무른 시간 값이 지나야 하위 목록이 펼쳐지는 방식 — 측정 누적면적 15.367% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scheduleSub()CASCADE_MS = 260place(sub, mr.right - 2, tr.top - 4)

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(); }
});
방향키 강조 이동 — 눌린 방향 값만큼 강조 자리 값이 목록 순서대로 옮겨가는 방식
새 창에서 열기

05 · 방향키 강조 이동 — 눌린 방향 값만큼 강조 자리 값이 목록 순서대로 옮겨가는 방식 — 측정 누적면적 19.524% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

moveHighlight(dir)(hi + dir + n) % nArrowUp / ArrowDown

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;
}
바깥 클릭 닫힘 — 메뉴 밖을 클릭한 상태가 되면 투명도 값이 낮아지며 사라지는 방식
새 창에서 열기

06 · 바깥 클릭 닫힘 — 메뉴 밖을 클릭한 상태가 되면 투명도 값이 낮아지며 사라지는 방식 — 측정 누적면적 88.409% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

isInside(rect, x, y)backdrop pointerdownopacity 1 → 0 fade

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 = ''; });
}
항목 순차 등장 — 각 줄의 순서 값만큼 지연된 뒤 자리 값이 채워지는 방식
새 창에서 열기

07 · 항목 순차 등장 — 각 줄의 순서 값만큼 지연된 뒤 자리 값이 채워지는 방식 — 측정 누적면적 19.279% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

applyStagger()STAGGER_MS = 55el.style.transitionDelay = i * STAGGER_MS

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); }
깊이 밀림 전환 — 들어간 하위 단계 값만큼 메뉴 전체가 옆으로 밀려나는 방식
새 창에서 열기

08 · 깊이 밀림 전환 — 들어간 하위 단계 값만큼 메뉴 전체가 옆으로 밀려나는 방식 — 측정 누적면적 15.822% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

goTo(d)PANEL_W = 168translateX(-depth * PANEL_W)

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);
}
길게 누르기 등장 — 누른 시간 값이 기준을 넘으면 메뉴가 뜨는 방식
새 창에서 열기

09 · 길게 누르기 등장 — 누른 시간 값이 기준을 넘으면 메뉴가 뜨는 방식 — 측정 누적면적 19.026% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

tickPress(ts)HOLD_MS = 620 / CIRC = 100.5stroke-dashoffset = CIRC * (1 - prog)

값이 패턴을 바꾸는 곳 — 위치·시간·깊이 값의 경계

실제로 이런 메뉴를 만들 때 위치·시간·깊이 값을 어디서 조정하면 되는지 세 개만 짚어도 손에 잡히는 느낌이 크게 갈립니다.

위치 여백 기준 — 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()로 기본 메뉴를 막고 커스텀 메뉴만 보여주면 됩니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.