No.182
MODAL CSSJS

Context Menu

右クリックした場所に、ふわっと拡大しながら独自のメニューが開く演出。長押し・Shift+F10・メニューボタンからも開け、矢印キーでの移動・Esc で閉じてフォーカスを戻すところまで作っています。

MY COLLECTION RANDOM ↗

DEMO

design-notes.fig

右クリック・長押し・Shift+F10、または「⋯」ボタンでメニューを開きます

CODE

HTML
<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>
CSS
.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; }
}
JS
(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

Claude Code Cursor v0
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

📖 なかしまぁ先生のCSSアニメーション入門 📖 マイクロインタラクション ―UI/UXデザインの神が宿る細部 📖 Fundamentals of Web Animation with GSAP

※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。

学びを止めない · AMAZONサブスク

📚 Kindle Unlimited — 技術書・デザイン書が読み放題。30日無料体験 → 🎧 Audible — 移動中に耳で学ぶオーディオブック。30日無料体験 → 📦 Amazon Prime — Prime Reading・配送特典つき。30日無料体験 → 🎵 Music Unlimited — 作業用BGMに。1億曲が聴き放題・30日無料体験 →

※当サイトはAmazonアソシエイトプログラムの参加者です。リンクから商品をご購入いただくと、当方に紹介料が発生します。

CREATOR TOOLS · スクール(PR)

🎬 Adobe Creative Cloud — After Effects / Photoshop 業界標準ツール → 💻 SkillHacks — プログラミング・動画編集オンラインスクール → 🚀 デイトラ — Web制作スクール・実案件レベルまで習得 →

※本サイトはアフィリエイトリンクを含みます。リンク経由のご購入・お申し込みで運営者が紹介料を受け取る場合があります。

RELATED EFFECTS

↗ MORE
→ POPOVER API MENU → COMMAND PALETTE → TOOLTIP

Context Menu(独自の右クリックメニュー)とは

ファイルの一覧やデザインツールのように、要素を右クリックした場所へ、その要素に対する操作をまとめたメニューを開く部品です。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プロンプトは、クレジット表記なしで自由に使えます。コピーしてそのままプロジェクトに利用できます。