コンテキストメニュー UI 9選 — クリック座標の値でメニューが決まる仕組み
9つのうち1つはクリックした画面座標の値がメニューの現れる位置の値をそのまま決め、1つはその位置の値が画面端に近い状態になると開く方向の値が反対に変わり、もう1つはサブメニューに入った深さの値だけメニューが横にずれて重なります。コンテキストメニュー UIを自作したい人のために、9パターンぶんの実装をまとめました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カーソル位置固定 — クリックした座標の値がそのままメニューの現れる位置になる方式
- 02 画面端反転 — 位置の値が画面の端に達すると開く方向の値が反対に変わる方式
- 03 スケールフェードイン — 大きさの値と透明度の値が小さい値から一緒に広がる方式
- 04 サブメニュー順次表示 — 項目に留まった時間の値が基準を超えるとサブメニューが開く方式
- 05 矢印キー強調移動 — 押された方向の値だけ強調位置の値が一覧の順に移る方式
- 06 外側クリック終了 — メニューの外をクリックした状態になると透明度の値が下がって消える方式
- 07 項目順次表示 — 各行の順番の値だけ遅れて位置の値が満ちる方式
- 08 深さ移動切り替え — 入った階層の値だけメニュー全体が横にずれる方式
- 09 長押し表示 — 押した時間の値が基準を超えるとメニューが現れる方式
9つとも実際のファイル操作やクリップボード操作には触れていません。「コピー」「名前変更」「削除」ボタンはすべて CSSのクラスを切り替えるだけで、09番の長押しリングもネイティブのタッチAPIではなく requestAnimationFrame によるローカルなループで動いています。9つとも自動再生だけで動くのではなく、必ず本物のユーザー入力に応答します。8個は preventDefault() を呼んでから実際の contextmenu イベントで開き、09番だけは pointerdown・pointerup・pointerleave を組み合わせた長押しのタイマーで開きます — 8足す1で9つとも実際のトリガーです。
9つのパターンの解剖 — どの値がいつ変わるか
9つは、メニューが開いたあとクリック座標の値を実際に何へ使うかで分かれます。この分かれ方があるからこそ、右クリックメニュー デザインの集まりが「同じ箱が9回出てくるだけ」に見えずに済みます。
なぜ3つはクリック座標の値をメニューの位置だけに使うのか
01・02・03番はサブメニューもキー操作の順番もタイマーも扱いません。3つとも、1回のクリック座標からメニューの箱がどこに現れるかだけを決めます。01番の positionAt() は clientX・clientY の値をそのまま translate() に渡し、軸ごとに GAP の余白を超えないようクランプします。02番の flipFor() も同じクランプを行いますが、その前にメニューの右端・下端の値が画面の端を超えるかどうかを判定し、超えた軸だけ開く方向の値を反転させます。03番の openAt() はクリック座標を中心にメニューを配置したあと、クランプでずれた分を逆算して transformOrigin のパーセント値を求めます — 同じクリック座標が、位置とスケールアニメーションが広がる方向の値を両方とも決めています。
なぜ3つは空間ではなく時間や深さの値でメニューを変えるのか
04・07・08番は、メニューが開いたあとは座標の値にまったく触れません。それぞれ、時間か深さの値を基準にメニューの形を変えます。04番の scheduleSub() は、ポインターが並び替え行に入った瞬間に CASCADE_MS を基準にした setTimeout を開始し、clearHover() はポインターが離れた瞬間にそれを取り消します。そのタイマーより長く留まった値のホバーだけがサブメニューの place() を実際に呼び出す仕組みです。07番の applyStagger() は各行の順番の値に STAGGER_MS を掛けて、その行自身の transitionDelay にそのまま書き込みます — 実際の表示は CSS 側の遅延の値に任せます。08番の goTo() は depth というカウンターの値に固定の PANEL_W を掛けて、トラック全体の translateX() に一度だけ適用します。どのサブメニューへ何段階進んでも、動く距離の値は同じです。
なぜ3つはマウス以外の別の実入力に置き換えるのか
05・06・09番は、通常のクリックで開いてクリックで閉じるという流れの一部を別の入力に置き換えています。05番の moveHighlight() はポインターの位置をまったく読みません — document.keydown が (hi + dir + n) % n という値を計算し、上下の矢印キーで強調位置の値が一覧の端から反対側へ折り返します。06番の isInside() はポインターの座標を読みますが、それは全画面の背景で起きた pointerdown がメニュー自身の getBoundingClientRect() の外側かどうかを判定するためだけで、外側だった場合はメニューと背景の透明度の値が一緒に下がって消えます。09番は contextmenu というトリガー自体を置き換えています — 指が押されている間、tickPress() が経過時間の値からアニメーションフレームごとに stroke-dashoffset の値を再計算し、その進み具合の値が1に達したときだけ finishPress() が呼ばれます。
パターン別コード
01カーソル位置固定 — クリックした座標の値がそのままメニューの現れる位置になる方式
右クリックが起きた画面座標の値が、そのままメニューの箱自身の位置の値になり、途中で何も変換されません。メニューが閉じるとその位置の値は捨てられます — ファイル一覧の行やエディタのテキスト選択で使われる固定のしかたです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、01番は7フレームで値が動き、累積面積は51.25%・平均強度は118.5です — 9つの中で累積面積が最も高い値になっています。自動再生のループがフィールド上の4つの離れた地点でメニューの箱を開き直すため、毎回480×300のステージの違う領域が塗り替わるからです。
var GAP = 8; // 画面端に保つ余白
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.transform = 'translate(' + px.toFixed(0) + 'px,' + py.toFixed(0) + 'px)';
}
02画面端反転 — 位置の値が画面の端に達すると開く方向の値が反対に変わる方式
メニューの箱の右端が画面幅の値を超えると予測されると、開く方向の値がクリック地点の左側へ反転し、下端でも同じように上向きの方向の値に変わります — 幅の広いテーブルやカードグリッドの端で右クリックしたときに役立ちます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、02番は8フレームで値が動き、累積面積は45.69%・平均強度は112.7です — 9つの中で2番目に高い面積です。自動再生のループが反転のない左上のフィールドと、flip-x・flip-y の両方が適用される右下のフィールドを交互に切り替えるため、箱のスケール兼フェードのアニメーションがステージの両端を順番に塗り替えます。
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);
}
03スケールフェードイン — 大きさの値と透明度の値が小さい値から一緒に広がる方式
メニューが開くと、大きさの値は小さい値から実寸まで、透明度の値は0から1まで同じ時間で一緒に育ちます。閉じるときは同じ値をそのまま逆に戻します — 短い操作メニューやテーブル行の右クリックメニューがこの動きです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、03番は7フレームで値が動き、累積面積は37.25%・平均強度は57.2です — 9つの中で平均強度が最も低い値です。スケールのアニメーションは箱全体のピクセルを少しずつ変えるだけで、位置の切り替えや色の変化のような1フレームごとの急な変化を作らないためです。
var GAP = 6;
function openAt(x, y) {
var mw = menu.offsetWidth, mh = menu.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
// 計算層: クリック座標がメニューの中心に来るよう配置してから
// 画面端でクランプする。
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));
// クランプで箱の位置がずれたあとでも、実際のクリックが箱のどのパーセント
// 位置に落ちたかを逆算し、その値を原点として使う。
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');
}
04サブメニュー順次表示 — 項目に留まった時間の値が基準を超えるとサブメニューが開く方式
並び替え行にポインターが留まった時間の値が CASCADE_MS を超えないと、そのサブメニューは横に現れません。別の行へ移ると留まった時間の値は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">
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、04番は6フレームだけ値が動き、累積面積は15.37%・平均強度は65.9です — 9つの中で動いたフレーム数が最も少ない値です。自動再生のほとんどの時間は、CASCADE_MS のカウントダウンの間ただポインターが並び替え行の上で留まっているだけで、サブメニューが実際に現れるまで画面上に変化がないためです。
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);
}
並び替え行のリスナーは pointerenter・pointerleave で登録されており、旧来の mouseenter・mouseleave ではありません。マウスとタッチの両方の入力から同じポインターイベントで留まった時間の値を測っています。
05矢印キー強調移動 — 押された方向の値だけ強調位置の値が一覧の順に移る方式
上下の矢印キーを押すたびに、強調される行の順番の値が1ステップ動き、その値が一覧の端を超えると反対側の端へ折り返します — マウスを使わずに操作するメニューや、データテーブルのアクセシブルな右クリックメニュー向けの作りです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、05番は14フレームで値が動き、累積面積は19.52%・平均強度は110.1です — 面積のわりに動いたフレーム数が多い値です。6回のキー操作それぞれが1行分の強調背景の色を変えるだけで、何かを移動させたり大きさを変えたりしないため、面積は小さいままフレーム数だけが1回ずつ積み上がります。
// 計算層: (現在のインデックス + 方向 + 個数) % 個数 という値で次のインデックスを
// 求め、一覧の上端・下端を超えたら反対側へ折り返す移動を作る。
function moveHighlight(dir) {
if (!menu.classList.contains('is-open')) return;
var n = items.length;
setHi((hi + dir + n) % n);
}
06外側クリック終了 — メニューの外をクリックした状態になると透明度の値が下がって消える方式
開いたメニューの外をクリックすると、メニューとその背後の全画面の背景の透明度の値が一定の時間で一緒に0まで下がり、メニューは画面から消えます — 右クリックメニューやモバイルの長押しメニューが閉じる、ごく普通のしかたです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、06番は8フレームで値が動き、累積面積は88.41%・平均強度は46.0です — 9つの中で累積面積が最も高く、平均強度が最も低い組み合わせです。480×300のステージのほぼ全体を覆う背景がメニューと一緒にフェードするため、フレームのほとんど全域を覆いながらも、1回あたりは浅くゆるやかな透明度の変化にとどまります。
function close() {
menu.classList.remove('is-open');
backdrop.classList.remove('is-on');
}
// 計算層: クリック座標が (getBoundingClientRect) で得たメニューの矩形の
// 内側にあるかを4つの不等式で判定し、外側だったときだけ背景をメニューと
// 一緒にフェードアウトさせる。
function isInside(rect, x, y) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
07項目順次表示 — 各行の順番の値だけ遅れて位置の値が満ちる方式
メニューが開くと、各行の位置の値と透明度の値は、その行自身の順番の値に比例した遅れをともなって満ちていきます。一覧の下の行ほど遅れて収まります — 行数の多い編集メニューや、色と並び替えを混ぜたメニュー向けの作りです。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、07番は8フレームで値が動き、累積面積は19.28%・平均強度は83.8です — 中程度の強度で、これは4つの行が同じフレームでまとめて収まるのではなく、translateX(-8px) から0へ順番にスライドインするため、変化するピクセルが行ごとにずれて分散していることから来ています。
// 計算層: 各行自身の順番のインデックス(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();
}
08深さ移動切り替え — 入った階層の値だけメニュー全体が横にずれる方式
サブメニューに入った深さの値が1増えるたびに、メニューの水平位置の値がそのサブメニュー自身の幅の値ぶんだけ横にずれ、親メニューへ戻ると深さの値が減って位置の値も逆に戻ります — 段階の多いツールや、モバイル画面の階層メニューに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、08番は8フレームで値が動き、累積面積は15.82%・平均強度は94.8です — 9つの中で累積面積が最も小さい値です。どのフレームでも変わるのはトラック自身の translateX() のスライドだけで、それがずれる幅の値(PANEL_W、168px)は、他の項目がフェードしたり位置を変えたりするメニュー全体の箱よりも狭いためです。
// 計算層: 深さの値にパネル幅(PANEL_W)を掛けてトラックを横にずらし、
// 深さが1増えるたびに次の画面がちょうどパネル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); }
09長押し表示 — 押した時間の値が基準を超えるとメニューが現れる方式
画面を押した時間の値が HOLD_MS を超えると、押した座標の値を基準にメニューが現れます。その基準に達する前に指を離すと、メニューはまったく現れません — モバイルで一覧の行を長押ししたり、タッチ画面で画像を長押ししたりするときの作りです。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレーム中、09番は20フレームで値が動き、累積面積は19.03%・平均強度は112.9です — 9つの中で動いたフレーム数が最も多い値です。tickPress() が長押しの間ずっと requestAnimationFrame のティックごとにリングの stroke-dashoffset の値を再計算し続けるため、リング自体は小さいままでも、追跡した23フレームのほとんどで何らかの変化が現れるからです。
// 計算層: (経過時間 / HOLD_MS) を 0〜1 の進み具合の値にクランプし、
// それを円周の値(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);
}
値がパターンを変える場所 — 位置・時間・深さの境界
なぜ04番の CASCADE_MS はサブメニューの見た目ではなく誤操作のしやすさだけを変えるのか
CASCADE_MS は place() が呼ばれるタイミングだけを決め、その値が経過したあとのサブメニュー自身のスケール兼フェードの動きや位置の計算には何も影響しません。260msでは並び替え行にカーソルがある程度の時間留まらないとサブメニューが確定しないため、通り過ぎるだけのカーソルには寛容です。120msでは軽く触れただけでも開いてしまうため、css 右クリックメニューとしては速く感じられる一方、意図しない場面でも開きやすくなります。
なぜ07番の STAGGER_MS は表示される行数ではなく間隔の感じ方だけを変えるのか
STAGGER_MS は固定された行のインデックスに掛け合わされる値で、表示される4つの行そのものは変わらず、それぞれの transitionDelay の値がどれだけ間隔を空けて並ぶかだけが変わります。55msでは4つの行がほぼひとまとまりに見え、90msでは各行がはっきり前の行のあとに収まるため、行数は数えやすくなりますが、メニュー全体が満ちるまでの時間は長くなります。
なぜ09番の HOLD_MS はメニューの位置ではなく押す意図の確かさだけを変えるのか
HOLD_MS は tickPress() が経過時間を割る分母の値を変えるだけで、startPress() の時点で一度だけ取得される pressX・pressY には何の影響もありません。620msではスクロール中の少し長めのタップでも指が離れる前にメニューが誤って開くことがあり、900msでは押し続ける意図がはっきり必要になるため、誤操作は減りますが、意図した長押しでも待つ時間は長くなります。
どこで壊れるか — 連続クリック・狭い画面・途中で閉じる
メニューが開いたまま連続で右クリックするとどうなるか
01・02・03・06・07番には、is-open の値が立ったままの状態で2回目の contextmenu が来ることへの防御がありません。連続クリックはただ positionAt()・flipFor()・openAt() を呼び直すだけで、is-open のクラスが一度も外れていないため、透明度やスケールの遷移をもう一度再生することなく、すでに見えている箱を新しい座標へ瞬時にスナップさせます。04番だけは例外で、ハンドラが再度開く前に closeAll() を呼ぶため、連続クリックは開いていたサブメニューもたたみます。09番も、すでにメニューが開いている状態への pointerdown の防御はなく、2回目の押下はその上に新しい長押しの進行を重ねて始めます。
狭い画面の端にフィールドがあるとどうなるか
9つとも Math.max・Math.min で位置を画面の GAP 内にクランプしているため、各デモが用意している @media (max-width: 340px) のレイアウトでも見える範囲の外へはみ出すことはありません。しかし、端に近づいたときに実際に開く方向の値を反転させるのは02番だけで、残りの8つはクランプが当たった境界へただ押し付けられるだけです。そのため狭い画面では、開くきっかけになった座標からかなり離れた位置にメニューが固定されることもあります。
メニュー自身のアニメーションの途中で閉じるとどうなるか
scheduleSub() のタイマーがまだ残っている状態で04番を閉じると、pointerleave で clearHover() が呼ばれ、CASCADE_MS に届いていなかったサブメニューはそのまま二度と開かずに終わります。09番を長押しの途中で閉じると、pointerup または pointerleave から cancelPress() が呼ばれ、進行中のアニメーションフレームを取り消して stroke-dashoffset の値をすぐに満円の状態へ戻します — リングが最後まで満ちるのを待たずに終わる形です。07番を表示の途中で閉じると clearStagger() が呼ばれ、各行の transitionDelay の値がすべて空に戻るため、次に開いたときは途中からではなく最初から順に満ちていきます。
このセットが CSS ツールチップ 吹き出しアニメーション9選 一行でコピペOK・CSS 画面切り替え アニメーション9選・トースト通知 UI 9選 — 積み上がるスタック実装 と違うのは、9つを開くきっかけと、座標という値が持つ意味です。ツールチップのセットはホバーやフォーカスで開き、クリックした座標ではなくトリガー要素自身の矩形を基準にするため、外側をクリックして閉じたときに何が起きるかを判断する必要すらありません。画面切り替えのセットはナビゲーションのたびに画面全体を別の画面へ入れ替えるので、9つの値は1つの座標に固定された小さな箱ではなく、画面全体の受け渡しを表しています。トーストのセットは、どこがクリックされたかに関係なく、決まった隅にタイマーで自動的に消える通知をいくつも積み重ねますが、こちらの9つはすべて、クリックまたは押した1点の座標が箱の位置・方向・深さのいずれかを決めることに特化しています。この右クリックメニュー デザインのセットのソースはすべて、パスワードndf4j3ahで解除すると、デモの自動再生ループを外すだけで実際の右クリックメニューや長押しメニューにそのまま組み込めます。
3つのバリエーションとアクセシビリティ
この9つのコンテキストメニュー UI パターンのうち、実際に出荷用のバリエーションへ変えるだけの値を持つのは3つだけです。ほかの6つに調整できる値がないわけではなく、CASCADE_MS・STAGGER_MS・HOLD_MS の3つが、1つの値を変えるだけで操作の寛容さ・間隔・確かさの感じ方が最もはっきり変わる値だからです。
| バリエーション | 何が変わるか | 何を引き換えにするか |
|---|---|---|
| 04番の CASCADE_MS を260msから120msへ | 並び替え行にカーソルが留まってからサブメニューが確定するまでの時間 | 260msではただ通り過ぎるカーソルではサブメニューが開かず誤操作が少ない。120msでは軽く触れただけでもすぐ開き、速く感じる一方で意図しない場面でも開きやすくなる |
| 07番の STAGGER_MS を55msから90msへ | 各行の表示がどれだけ間隔を空けて収まるか | 55msでは4つの行がほぼひとまとまりに届く。90msでは各行が前の行のあとにはっきり収まり、行数は数えやすくなるがメニュー全体が満ちるまでの時間は長くなる |
| 09番の HOLD_MS を620msから900msへ | メニューが現れるまでに押し続けなければならない時間 | 620msではスクロール中の少し長めのタップでも指が離れる前に開いてしまうことがある。900msでは押し続ける意図がはっきり必要になり、誤操作は減るが意図した長押しでも待つ時間が延びる |
9つとも role="menu"・role="menuitem" はメニューの箱とボタンに設定されていますが、aria-label を設定したり aria-live の領域を用意したりしているものは1つもありません。メニューが開いたとき、サブメニューが順次表示されたとき、05番のキーボードによる強調位置が動いたときも、スクリーンリーダーへの自動的な読み上げは起きません。各項目自身の @media (prefers-reduced-motion: reduce) の指定は .cm__menu の CSS トランジションをまるごと止めます(07番はさらに全ての行の transition-delay を0msへ強制的に戻します)。この設定のもとでは実際の開閉がフェードもスケールもスライドもなく一瞬で切り替わります — それとは別に、各デモ自身の JS の reduce 判定は自動再生プレビューの setInterval ループだけを止めるもので、実際の contextmenu・pointerdown・keydown・長押しのリスナーはどちらの設定でも同じように動き続けます。9つの中で矢印キーによる操作を用意しているのは05番だけです。
FAQ
ブラウザ標準の右クリックメニューはこの9つの下に残ったままなのですか
いいえ。9つとも contextmenu のハンドラの中で open() を呼ぶ前に e.preventDefault() を呼んでおり、これがブラウザ標準のメニューがその場所に現れるのを止めています。09番自身の開き方は長押しですが、contextmenu に対しても同じ preventDefault() を呼んでいます。
この9つは本当にコピー・共有・削除を実行するのですか
いいえ。「コピー」「貼り付け」「名前変更」「削除」「共有」「並び替え」のボタンはすべて CSSのクラスを切り替えるか、04番のサブメニューであれば place() を呼ぶだけで、9つのどれも Clipboard API や fetch など実際の操作を呼び出していません。javascript 右クリックメニュー 自作としてこの形を使う場合は、貼り付けたあとに実際のハンドラを自分で組み込む前提です。
モバイルの長押しメニューはデスクトップの右クリックメニューと同じコードなのですか
完全には同じではありません。09番は、ほかの8つが使う contextmenu イベントの代わりに、tickPress() を通じたタイマー付きの pointerdown・pointerup のサイクルに置き換えています。タッチ画面には別途聞き分けるべき右クリックの操作がないためです。開いたあとの09番自身のメニューの箱は、ほかの8つと同じ positionAt() 系のクランプを共有しているので、違うのはトリガーだけで、箱が画面に収まる計算は変わりません。
Sources: MDN — HTMLElement: contextmenu event · MDN — Element.getBoundingClientRect() · MDN — transform-function: translate() · MDN — prefers-reduced-motion









