DEMO
右クリック・長押し・Shift+F10、または「⋯」ボタンでメニューを開きます
CODE
<div class="demo-stage ctx-stage" id="ctx-stage">
<div class="ctx-file" id="ctx-file" tabindex="0" role="group" aria-label="ファイル design-notes.fig" aria-describedby="ctx-hint">
<span class="ctx-icon" aria-hidden="true">▤</span>
<span class="ctx-name">design-notes.fig</span>
<button type="button" class="ctx-more" id="ctx-more" aria-haspopup="menu" aria-expanded="false" aria-controls="ctx-menu" aria-label="design-notes.fig のメニュー">⋯</button>
</div>
<p class="ctx-hint" id="ctx-hint">右クリック・長押し・Shift+F10、または「⋯」ボタンでメニューを開きます</p>
<p class="ctx-status" id="ctx-status" role="status" aria-live="polite"></p>
<ul class="ctx-menu" id="ctx-menu" role="menu" aria-label="ファイルの操作" hidden>
<li class="ctx-item" role="menuitem" tabindex="-1">開く</li>
<li class="ctx-item" role="menuitem" tabindex="-1">名前を変更</li>
<li class="ctx-item" role="menuitem" tabindex="-1">複製</li>
<li class="ctx-sep" role="separator"></li>
<li class="ctx-item ctx-danger" role="menuitem" tabindex="-1">削除</li>
</ul>
</div>
.ctx-stage { position:relative; overflow:hidden; display:flex; flex-direction:column; align-items:center; justify-content:flex-start; gap:.8rem; padding:2rem 1.2rem; background:#0B0B10; min-height:340px; }
.ctx-file { display:flex; align-items:center; gap:.7rem; width:100%; max-width:300px; box-sizing:border-box; padding:.4rem .4rem .4rem 1rem; border-radius:12px; background:#15151B; border:1px solid rgba(255,255,255,.1); color:#EDEDED; font-size:.86rem; outline:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
.ctx-file:focus-visible { box-shadow:0 0 0 2px #E91B89; }
.ctx-icon { color:#F472B6; font-size:1.1rem; }
.ctx-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ctx-more { width:44px; height:44px; border:0; border-radius:10px; background:transparent; color:#EDEDED; font-size:1.1rem; cursor:pointer; }
.ctx-more:focus-visible { outline:2px solid #E91B89; outline-offset:-2px; }
.ctx-hint { margin:0; max-width:300px; color:#A1A1AA; font-size:.74rem; line-height:1.6; text-align:center; }
.ctx-status { margin:0; min-height:1.2em; color:#C9C9D1; font-size:.8rem; }
.ctx-menu { position:absolute; top:0; left:0; z-index:5; min-width:180px; margin:0; padding:.35rem; list-style:none; border-radius:12px; background:#1E1E26; border:1px solid rgba(255,255,255,.12); box-shadow:0 14px 34px rgba(0,0,0,.5);
transform-origin:var(--ctx-ox, 0) var(--ctx-oy, 0); animation: ctx-in .14s cubic-bezier(.22,1,.36,1); }
.ctx-menu[hidden] { display:none; }
.ctx-item { display:flex; align-items:center; min-height:40px; padding:0 .8rem; border-radius:8px; color:#EDEDED; font-size:.84rem; cursor:pointer; outline:none; }
.ctx-item:focus { background:#D1177A; color:#fff; }
.ctx-danger { color:#FCA5A5; }
.ctx-danger:focus { background:#DC2626; color:#fff; }
.ctx-sep { height:1px; margin:.3rem .4rem; background:rgba(255,255,255,.12); }
@keyframes ctx-in { from { opacity:0; transform:scale(.94); } }
@media (prefers-reduced-motion: reduce) {
.ctx-menu { animation:none; }
}
(function(){
var stage = document.getElementById('ctx-stage');
var file = document.getElementById('ctx-file');
var more = document.getElementById('ctx-more');
var menu = document.getElementById('ctx-menu');
var status = document.getElementById('ctx-status');
if (!stage || !file || !more || !menu || !status) return;
var items = Array.prototype.slice.call(menu.querySelectorAll('[role="menuitem"]'));
var opener = null, pressTimer = 0, keyOpened = 0;
function openAt(x, y, from, upY){
opener = from;
menu.hidden = false;
var w = menu.offsetWidth, h = menu.offsetHeight, sw = stage.clientWidth, sh = stage.clientHeight;
var left = Math.max(8, Math.min(x, sw - w - 8));
var top = y + h > sh - 8 ? Math.max(8, (upY === undefined ? y : upY) - h) : y;
menu.style.left = left + 'px'; menu.style.top = top + 'px';
menu.style.setProperty('--ctx-ox', (x - left) + 'px');
menu.style.setProperty('--ctx-oy', (y - top) + 'px');
menu.style.animation = 'none'; void menu.offsetWidth; menu.style.animation = '';
more.setAttribute('aria-expanded', 'true');
items[0].focus({ preventScroll: true });
}
function openBelow(el, from){
var s = stage.getBoundingClientRect(), b = el.getBoundingClientRect();
openAt(b.left - s.left, b.bottom - s.top + 4, from, b.top - s.top - 4);
}
function close(restore){
if (menu.hidden) return;
menu.hidden = true;
more.setAttribute('aria-expanded', 'false');
if (restore && opener) opener.focus({ preventScroll: true });
opener = null;
}
function choose(it){ status.textContent = '「' + it.textContent + '」を選びました'; close(true); }
file.addEventListener('contextmenu', function(e){
e.preventDefault();
clearTimeout(pressTimer);
if (Date.now() - keyOpened < 300) return;
var s = stage.getBoundingClientRect();
if (e.clientX || e.clientY) openAt(e.clientX - s.left, e.clientY - s.top, file);
else openBelow(file, file);
});
file.addEventListener('keydown', function(e){
if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') {
e.preventDefault();
keyOpened = Date.now();
openBelow(file, e.target === more ? more : file);
}
});
file.addEventListener('pointerdown', function(e){
if (e.pointerType !== 'touch' || e.target === more) return;
clearTimeout(pressTimer);
var s = stage.getBoundingClientRect(), x = e.clientX - s.left, y = e.clientY - s.top;
pressTimer = setTimeout(function(){ openAt(x, y, file); }, 500);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function(t){ file.addEventListener(t, function(){ clearTimeout(pressTimer); }); });
more.addEventListener('click', function(){
if (!menu.hidden) { close(true); return; }
openBelow(more, more);
});
menu.addEventListener('keydown', function(e){
var i = items.indexOf(document.activeElement), n = items.length, k = e.key;
if (k === 'ArrowDown') items[(i + 1) % n].focus();
else if (k === 'ArrowUp') items[(i - 1 + n) % n].focus();
else if (k === 'Home') items[0].focus();
else if (k === 'End') items[n - 1].focus();
else if (k === 'Tab') { close(true); return; }
else if (k === 'Escape') close(true);
else if (k === 'Enter' || k === ' ') { if (i >= 0) choose(items[i]); }
else return;
e.preventDefault();
});
menu.addEventListener('mousedown', function(e){ if (!e.target.closest('[role="menuitem"]')) e.preventDefault(); });
menu.addEventListener('contextmenu', function(e){ e.preventDefault(); });
items.forEach(function(it){
it.addEventListener('click', function(){ choose(it); });
it.addEventListener('pointermove', function(){ if (document.activeElement !== it) it.focus({ preventScroll: true }); });
});
document.addEventListener('pointerdown', function(e){
if (!menu.hidden && !menu.contains(e.target) && e.target !== more) close(false);
});
window.addEventListener('resize', function(){ close(menu.contains(document.activeElement)); });
})();
AI PROMPT
右クリックした場所に、ふわっと拡大しながら開く独自のメニュー(Context Menu)を作って。対象の要素(ファイルの行)の contextmenu イベントで preventDefault してブラウザ標準のメニューを止め、デモの枠(position:relative・overflow:hidden)からの相対座標にメニューを position:absolute で開く。メニューが枠の右や下からはみ出す時は、左へずらすか上向きに開く(ボタンやキーボードで要素の下に開く場合は、上向きの時は要素の上端より上に置き、開いた要素を覆わない)。transform-origin をカスタムプロパティで「押した点」に合わせ、scale(.94)・透明から .14秒で現れるようにする(開くたびに animation を付け直す)。メニューは role="menu"(aria-label 付き)の ul、項目は role="menuitem"・tabindex="-1" の li、区切りは role="separator"。開いたら最初の項目にフォーカスし、上下の矢印キーで移動(端で反対側へ回り込む)、Home と End で最初と最後、Enter か Space で実行、Esc で閉じて開く前の要素へフォーカスを戻す。Tab は開く前の要素へフォーカスを戻してから preventDefault せずに通し、標準どおり次の要素へ進める。メニューの区切りや余白を押してもフォーカスが外れないよう、項目以外の mousedown は preventDefault する。メニューの外を押したら閉じる。メニュー自体の contextmenu も preventDefault する(キーボードで開くと、キーを離した時の contextmenu がメニューの項目に届くため)。開く手段は右クリックのほか、キーボードの Shift+F10 とコンテキストメニューキー(キーボードで開いた時は要素の左下に開く)、タッチの長押し(500ミリ秒)、行の中に置く「⋯」ボタン(aria-haspopup="menu"・aria-expanded・aria-controls)の4つを用意する。行には -webkit-touch-callout:none と user-select:none を付けて、長押しで文字選択が出ないようにする。選んだ結果は role="status" の段落で伝える。@media (prefers-reduced-motion: reduce) では出現の動きを止める。 Acceptance criteria: works with prefers-reduced-motion (animation disabled), zero console errors, zero layout shift
AIエージェントでの実装ガイド → Claude Code でWebアニメーションを実装する
BOOKS · PR · AMAZON
※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。
学びを止めない · AMAZONサブスク
※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。
CREATOR TOOLS · スクール(PR)
※本サイトはアフィリエイトリンクを含みます。リンク経由のご購入・お申し込みで運営者が紹介料を受け取る場合があります。
ファイルの一覧やデザインツールのように、要素を右クリックした場所へ、その要素に対する操作をまとめたメニューを開く部品です。MDN の説明では、contextmenu イベントは利用者がコンテキストメニューを開こうとした時に発生し、主にマウスの右ボタンやキーボードのコンテキストメニューキーで起こります。
当サイトの Popover API Menu は popover 属性と popovertarget だけで JavaScript なしに開閉するポップオーバー、Command Palette は ⌘K で開いて入力で候補を絞り込むコマンドパレット、Tooltip はホバーで補足情報が現れる吹き出しです。本ページは右クリックした位置そのものにメニューを出し、押した点から広がって見せる別テーマです。
メニューを開いた後の操作は、WAI-ARIA Authoring Practices Guide のメニューのパターンに合わせています。矢印キーで項目を移り、Esc で閉じると開く前の要素へフォーカスが戻り、Tab ではメニューを閉じて次の要素へ進むため、キーボードだけでも迷わず使えます。
右クリックのメニューを置き換えても問題ありませんか?
ブラウザ標準のメニューには「新しいタブで開く」「コピー」など、利用者が普段使う機能が入っています。ページ全体で置き換えると、それらが使えなくなって不便です。本実装はファイルの行という特定の要素だけで標準のメニューを止め、その要素に対する操作だけを出しています。標準のメニューの機能が必要な場所では、置き換えないでください。
マウス以外ではどう開けますか?
MDN によると contextmenu イベントは、マウスの右ボタンのほか、キーボードのコンテキストメニューキーでも発生します。本実装はこれに加えて Shift+F10、タッチの長押し、行の中の「⋯」ボタンからも開けるようにしています。長押しは見ただけでは分からない操作なので、同じメニューを開けるボタンを見える場所に置いておくと、誰でも使えるメニューになります。
コードは無料で使えますか?
はい。Motion LabのコードスニペットとAIプロンプトは、クレジット表記なしで自由に使えます。コピーしてそのままプロジェクトに利用できます。