GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

コンテキストメニュー UI 9選 — クリック座標の値でメニューが決まる仕組み

9つのうち1つはクリックした画面座標の値がメニューの現れる位置の値をそのまま決め、1つはその位置の値が画面端に近い状態になると開く方向の値が反対に変わり、もう1つはサブメニューに入った深さの値だけメニューが横にずれて重なります。コンテキストメニュー UIを自作したい人のために、9パターンぶんの実装をまとめました。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

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カーソル位置固定 — クリックした座標の値がそのままメニューの現れる位置になる方式

右クリックが起きた画面座標の値が、そのままメニューの箱自身の位置の値になり、途中で何も変換されません。メニューが閉じるとその位置の値は捨てられます — ファイル一覧の行やエディタのテキスト選択で使われる固定のしかたです。

カーソル位置固定 — クリックした座標の値がそのままメニューの現れる位置になる方式
新しいタブで開く

01 · カーソル位置固定 — クリックした座標の値がそのままメニューの現れる位置になる方式 — 計測 累積面積 51.248% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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画面端反転 — 位置の値が画面の端に達すると開く方向の値が反対に変わる方式

メニューの箱の右端が画面幅の値を超えると予測されると、開く方向の値がクリック地点の左側へ反転し、下端でも同じように上向きの方向の値に変わります — 幅の広いテーブルやカードグリッドの端で右クリックしたときに役立ちます。

画面端反転 — 位置の値が画面の端に達すると開く方向の値が反対に変わる方式
新しいタブで開く

02 · 画面端反転 — 位置の値が画面の端に達すると開く方向の値が反対に変わる方式 — 計測 累積面積 45.69% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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まで同じ時間で一緒に育ちます。閉じるときは同じ値をそのまま逆に戻します — 短い操作メニューやテーブル行の右クリックメニューがこの動きです。

スケールフェードイン — 大きさの値と透明度の値が小さい値から一緒に広がる方式
新しいタブで開く

03 · スケールフェードイン — 大きさの値と透明度の値が小さい値から一緒に広がる方式 — 計測 累積面積 37.249% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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に戻ります — 多段の編集メニューやファイル種別のサブメニューが、ただ通り過ぎるカーソルに反応しないための作りです。

サブメニュー順次表示 — 項目に留まった時間の値が基準を超えるとサブメニューが開く方式
新しいタブで開く

04 · サブメニュー順次表示 — 項目に留まった時間の値が基準を超えるとサブメニューが開く方式 — 計測 累積面積 15.367% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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ステップ動き、その値が一覧の端を超えると反対側の端へ折り返します — マウスを使わずに操作するメニューや、データテーブルのアクセシブルな右クリックメニュー向けの作りです。

矢印キー強調移動 — 押された方向の値だけ強調位置の値が一覧の順に移る方式
新しいタブで開く

05 · 矢印キー強調移動 — 押された方向の値だけ強調位置の値が一覧の順に移る方式 — 計測 累積面積 19.524% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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まで下がり、メニューは画面から消えます — 右クリックメニューやモバイルの長押しメニューが閉じる、ごく普通のしかたです。

外側クリック終了 — メニューの外をクリックした状態になると透明度の値が下がって消える方式
新しいタブで開く

06 · 外側クリック終了 — メニューの外をクリックした状態になると透明度の値が下がって消える方式 — 計測 累積面積 88.409% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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項目順次表示 — 各行の順番の値だけ遅れて位置の値が満ちる方式

メニューが開くと、各行の位置の値と透明度の値は、その行自身の順番の値に比例した遅れをともなって満ちていきます。一覧の下の行ほど遅れて収まります — 行数の多い編集メニューや、色と並び替えを混ぜたメニュー向けの作りです。

項目順次表示 — 各行の順番の値だけ遅れて位置の値が満ちる方式
新しいタブで開く

07 · 項目順次表示 — 各行の順番の値だけ遅れて位置の値が満ちる方式 — 計測 累積面積 19.279% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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増えるたびに、メニューの水平位置の値がそのサブメニュー自身の幅の値ぶんだけ横にずれ、親メニューへ戻ると深さの値が減って位置の値も逆に戻ります — 段階の多いツールや、モバイル画面の階層メニューに向いています。

深さ移動切り替え — 入った階層の値だけメニュー全体が横にずれる方式
新しいタブで開く

08 · 深さ移動切り替え — 入った階層の値だけメニュー全体が横にずれる方式 — 計測 累積面積 15.822% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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 を超えると、押した座標の値を基準にメニューが現れます。その基準に達する前に指を離すと、メニューはまったく現れません — モバイルで一覧の行を長押ししたり、タッチ画面で画像を長押ししたりするときの作りです。

長押し表示 — 押した時間の値が基準を超えるとメニューが現れる方式
新しいタブで開く

09 · 長押し表示 — 押した時間の値が基準を超えるとメニューが現れる方式 — 計測 累積面積 19.026% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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

数字が語ること

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

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。