クリックポップオーバー

「⋮」ボタンをクリックすると矢印付きのメニューが開くポップオーバー。外側クリックやEscで閉じ、矢印キーで項目を選べます。

#css#js#popover

ライブデモ

コード

HTML
<!-- クリックポップオーバー: 「⋮」ボタンでメニュー型ポップオーバーが開く。外側クリック/Escで閉じ、矢印キーで項目移動 -->
<section class="cp-stage" aria-label="クリックポップオーバーの見本">
  <p class="cp-eyebrow">CLICK POPOVER</p>
  <h2 class="cp-title">クリックポップオーバー</h2>
  <p class="cp-lead">右上の「⋮」ボタンを押すとメニューが開きます。外側クリックや <kbd>Esc</kbd> で閉じます。</p>

  <div class="cp-card">
    <div class="cp-card__thumb" aria-hidden="true"><span>然</span></div>
    <div class="cp-card__info">
      <h3 class="cp-card__title">静かな朝の器</h3>
      <p class="cp-card__meta">陶芸家・野々村 湊 / 2024</p>
    </div>

    <div class="cp-menu-wrap">
      <button
        type="button"
        class="cp-trigger"
        id="cp-trigger"
        aria-haspopup="menu"
        aria-expanded="false"
        aria-controls="cp-menu"
        aria-label="この作品のその他の操作"
      >⋮</button>

      <div class="cp-menu" id="cp-menu" role="menu" aria-labelledby="cp-trigger" aria-hidden="true">
        <button type="button" class="cp-menu__item" role="menuitem" tabindex="-1" data-action="edit">編集する</button>
        <button type="button" class="cp-menu__item" role="menuitem" tabindex="-1" data-action="share">共有リンクを作成</button>
        <button type="button" class="cp-menu__item" role="menuitem" tabindex="-1" data-action="archive">アーカイブに移動</button>
        <button type="button" class="cp-menu__item cp-menu__item--danger" role="menuitem" tabindex="-1" data-action="delete">削除する</button>
      </div>
    </div>
  </div>

  <p class="cp-hint">選択した操作: <span class="cp-hint__val" data-role="result">まだ何も選んでいません</span></p>
</section>
CSS
/* クリックポップオーバー: 生成りのカードから、クリックで黒檀のメニューがせり出す */
:root{
  --bg-1:#f7f1e6;
  --bg-2:#ecdfc6;
  --surface:#fffdf9;
  --line:#e6dac2;
  --ink:#2a2420;
  --muted:#8b7d6a;
  --gold:#a9803f;
  --gold-soft:#caa25f;
  --tip-bg:#211b15;
  --tip-ink:#f6efe1;
}
*{box-sizing:border-box}
body{
  margin:0;
  min-height:100vh;
  display:grid;
  place-items:center;
  padding:44px 20px;
  overflow-x:hidden;
  overflow-y:auto;
  font-family:"Segoe UI",system-ui,-apple-system,"Hiragino Kaku Gothic ProN",sans-serif;
  color:var(--ink);
  background:
    radial-gradient(560px 300px at 12% 0%,#fffaf0 0%,transparent 60%),
    radial-gradient(520px 280px at 100% 100%,#f2e2c4 0%,transparent 55%),
    linear-gradient(180deg,var(--bg-1),var(--bg-2));
}

.cp-stage{
  width:min(420px,100%);
  background:var(--surface);
  border:1px solid var(--line);
  border-radius:20px;
  box-shadow:0 26px 60px -30px rgba(60,42,10,.35),0 2px 10px rgba(60,42,10,.06);
  padding:32px 28px;
  text-align:center;
}
.cp-eyebrow{margin:0 0 6px;font-size:.7rem;letter-spacing:.16em;color:var(--gold);font-weight:700}
.cp-title{margin:0 0 10px;font-size:1.26rem;font-family:Georgia,"Yu Mincho","Hiragino Mincho ProN",serif}
.cp-lead{margin:0 0 26px;font-size:.85rem;line-height:1.6;color:var(--muted)}
.cp-lead kbd{
  font-family:inherit;font-size:.78em;padding:1px 6px;border-radius:5px;
  background:#f1e9d8;border:1px solid var(--line);color:var(--ink);
}

.cp-card{
  position:relative;
  display:flex;
  align-items:center;
  gap:14px;
  padding:16px;
  background:#fbf6ec;
  border:1px solid var(--line);
  border-radius:16px;
  text-align:left;
}
.cp-card__thumb{
  flex:0 0 46px;width:46px;height:46px;border-radius:11px;
  display:grid;place-items:center;
  background:linear-gradient(155deg,#3a2c1c,#1c140c);
  color:var(--gold-soft);font-family:Georgia,"Yu Mincho",serif;font-size:1.15rem;
}
.cp-card__info{min-width:0}
.cp-card__title{margin:0 0 2px;font-size:.95rem}
.cp-card__meta{margin:0;font-size:.76rem;color:var(--muted)}

.cp-menu-wrap{position:relative;margin-left:auto;flex:0 0 auto}

.cp-trigger{
  width:34px;height:34px;border-radius:50%;
  display:grid;place-items:center;
  background:var(--surface);border:1px solid var(--line);
  color:var(--muted);font-size:1.15rem;line-height:1;
  cursor:pointer;
  transition:background .15s ease,border-color .15s ease,color .15s ease;
}
.cp-trigger:hover{border-color:var(--gold-soft);color:var(--ink)}
.cp-trigger:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.cp-trigger[aria-expanded="true"]{background:var(--tip-bg);color:var(--tip-ink);border-color:var(--tip-bg)}
.cp-trigger.is-hint{animation:cp-ring 1.5s ease-out 2}
@keyframes cp-ring{
  0%{box-shadow:0 0 0 0 rgba(169,128,63,.4)}
  70%{box-shadow:0 0 0 11px rgba(169,128,63,0)}
  100%{box-shadow:0 0 0 0 rgba(169,128,63,0)}
}

.cp-menu{
  position:absolute;
  top:calc(100% + 12px);
  right:0;
  width:200px;
  padding:8px;
  background:var(--tip-bg);
  border:1px solid rgba(201,162,92,.28);
  border-radius:14px;
  box-shadow:0 30px 60px -22px rgba(15,10,3,.6);
  opacity:0;
  transform:translateY(-6px) scale(.98);
  transform-origin:top right;
  pointer-events:none;
  transition:opacity .16s ease,transform .2s cubic-bezier(.22,1,.36,1);
  z-index:50;
}
.cp-menu.is-open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto}
.cp-menu::before{
  content:"";position:absolute;top:-6px;right:18px;
  border:6px solid transparent;border-bottom-color:var(--tip-bg);
}

.cp-menu__item{
  all:unset;
  display:block;width:100%;box-sizing:border-box;
  padding:9px 12px;border-radius:9px;
  font-size:.82rem;color:var(--tip-ink);
  cursor:pointer;
}
.cp-menu__item:hover,.cp-menu__item:focus-visible{background:rgba(201,162,92,.16)}
.cp-menu__item:focus-visible{outline:1px solid var(--gold-soft);outline-offset:-1px}
.cp-menu__item--danger{color:#e2a99a}
.cp-menu__item--danger:hover,.cp-menu__item--danger:focus-visible{background:rgba(193,102,91,.18)}

.cp-hint{margin:20px 0 0;font-size:.8rem;color:var(--muted)}
.cp-hint__val{color:var(--ink);font-weight:600}

@media (prefers-reduced-motion:reduce){
  .cp-trigger.is-hint{animation:none}
  .cp-menu{transition:opacity .12s ease}
}
JavaScript
// クリックポップオーバー: 「⋮」ボタンでメニューを開閉する。
// 外側クリック/Escで閉じ、矢印キー・Home・Endで項目間を移動する(ロービングtabindex)。
(() => {
  const stage = document.querySelector('.cp-stage');
  if (!stage) return; // null安全

  const trigger = stage.querySelector('#cp-trigger');
  const menu = stage.querySelector('#cp-menu');
  const result = stage.querySelector('[data-role="result"]');
  if (!trigger || !menu) return;

  const items = Array.from(menu.querySelectorAll('.cp-menu__item'));
  let isOpen = false;

  const setRoving = (index) => {
    items.forEach((it, i) => { it.tabIndex = i === index ? 0 : -1; });
  };

  const onOutside = (e) => {
    if (menu.contains(e.target) || trigger.contains(e.target)) return;
    closeMenu({ refocusTrigger: false });
  };

  function openMenu() {
    if (isOpen || !items.length) return;
    isOpen = true;
    menu.classList.add('is-open');
    menu.setAttribute('aria-hidden', 'false');
    trigger.setAttribute('aria-expanded', 'true');
    setRoving(0);
    items[0].focus();
    document.addEventListener('pointerdown', onOutside, true);
  }

  function closeMenu({ refocusTrigger = true } = {}) {
    if (!isOpen) return;
    isOpen = false;
    if (menu.contains(document.activeElement)) document.activeElement.blur();
    menu.classList.remove('is-open');
    menu.setAttribute('aria-hidden', 'true');
    trigger.setAttribute('aria-expanded', 'false');
    document.removeEventListener('pointerdown', onOutside, true);
    if (refocusTrigger) trigger.focus();
  }

  trigger.addEventListener('click', () => { isOpen ? closeMenu() : openMenu(); });

  menu.addEventListener('keydown', (e) => {
    const idx = items.indexOf(document.activeElement);
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      const next = items[(idx + 1 + items.length) % items.length];
      setRoving(items.indexOf(next)); next.focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      const prev = items[(idx - 1 + items.length) % items.length];
      setRoving(items.indexOf(prev)); prev.focus();
    } else if (e.key === 'Home') {
      e.preventDefault(); setRoving(0); items[0].focus();
    } else if (e.key === 'End') {
      e.preventDefault();
      const last = items.length - 1;
      setRoving(last); items[last].focus();
    } else if (e.key === 'Escape') {
      e.preventDefault(); closeMenu();
    } else if (e.key === 'Tab') {
      closeMenu({ refocusTrigger: false });
    }
  });

  const labels = { edit: '編集する', share: '共有リンクを作成', archive: 'アーカイブに移動', delete: '削除する' };
  items.forEach((item) => {
    item.addEventListener('click', () => {
      if (result) result.textContent = `「${labels[item.dataset.action] || item.textContent.trim()}」を選択しました`;
      closeMenu();
    });
  });

  // --- 待機時のヒント演出: メニューは開かず、トリガーにそっと注意を引く輪を1〜2回だけ ---
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (reduce) return;

  let idleTimer = null;

  const scheduleIdle = () => {
    window.clearTimeout(idleTimer);
    idleTimer = window.setTimeout(() => {
      if (!isOpen) trigger.classList.add('is-hint');
    }, 3200);
  };
  trigger.addEventListener('animationend', () => {
    trigger.classList.remove('is-hint');
    scheduleIdle();
  });
  const cancelIdle = () => {
    window.clearTimeout(idleTimer);
    trigger.classList.remove('is-hint');
    scheduleIdle();
  };
  stage.addEventListener('pointerenter', cancelIdle, true);
  stage.addEventListener('focusin', cancelIdle);
  scheduleIdle();
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「クリックポップオーバー」の効果を追加してください。

# 追加してほしい効果
クリックポップオーバー(ツールチップ & メニュー)
「⋮」ボタンをクリックすると矢印付きのメニューが開くポップオーバー。外側クリックやEscで閉じ、矢印キーで項目を選べます。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- クリックポップオーバー: 「⋮」ボタンでメニュー型ポップオーバーが開く。外側クリック/Escで閉じ、矢印キーで項目移動 -->
<section class="cp-stage" aria-label="クリックポップオーバーの見本">
  <p class="cp-eyebrow">CLICK POPOVER</p>
  <h2 class="cp-title">クリックポップオーバー</h2>
  <p class="cp-lead">右上の「⋮」ボタンを押すとメニューが開きます。外側クリックや <kbd>Esc</kbd> で閉じます。</p>

  <div class="cp-card">
    <div class="cp-card__thumb" aria-hidden="true"><span>然</span></div>
    <div class="cp-card__info">
      <h3 class="cp-card__title">静かな朝の器</h3>
      <p class="cp-card__meta">陶芸家・野々村 湊 / 2024</p>
    </div>

    <div class="cp-menu-wrap">
      <button
        type="button"
        class="cp-trigger"
        id="cp-trigger"
        aria-haspopup="menu"
        aria-expanded="false"
        aria-controls="cp-menu"
        aria-label="この作品のその他の操作"
      >⋮</button>

      <div class="cp-menu" id="cp-menu" role="menu" aria-labelledby="cp-trigger" aria-hidden="true">
        <button type="button" class="cp-menu__item" role="menuitem" tabindex="-1" data-action="edit">編集する</button>
        <button type="button" class="cp-menu__item" role="menuitem" tabindex="-1" data-action="share">共有リンクを作成</button>
        <button type="button" class="cp-menu__item" role="menuitem" tabindex="-1" data-action="archive">アーカイブに移動</button>
        <button type="button" class="cp-menu__item cp-menu__item--danger" role="menuitem" tabindex="-1" data-action="delete">削除する</button>
      </div>
    </div>
  </div>

  <p class="cp-hint">選択した操作: <span class="cp-hint__val" data-role="result">まだ何も選んでいません</span></p>
</section>

【CSS】
/* クリックポップオーバー: 生成りのカードから、クリックで黒檀のメニューがせり出す */
:root{
  --bg-1:#f7f1e6;
  --bg-2:#ecdfc6;
  --surface:#fffdf9;
  --line:#e6dac2;
  --ink:#2a2420;
  --muted:#8b7d6a;
  --gold:#a9803f;
  --gold-soft:#caa25f;
  --tip-bg:#211b15;
  --tip-ink:#f6efe1;
}
*{box-sizing:border-box}
body{
  margin:0;
  min-height:100vh;
  display:grid;
  place-items:center;
  padding:44px 20px;
  overflow-x:hidden;
  overflow-y:auto;
  font-family:"Segoe UI",system-ui,-apple-system,"Hiragino Kaku Gothic ProN",sans-serif;
  color:var(--ink);
  background:
    radial-gradient(560px 300px at 12% 0%,#fffaf0 0%,transparent 60%),
    radial-gradient(520px 280px at 100% 100%,#f2e2c4 0%,transparent 55%),
    linear-gradient(180deg,var(--bg-1),var(--bg-2));
}

.cp-stage{
  width:min(420px,100%);
  background:var(--surface);
  border:1px solid var(--line);
  border-radius:20px;
  box-shadow:0 26px 60px -30px rgba(60,42,10,.35),0 2px 10px rgba(60,42,10,.06);
  padding:32px 28px;
  text-align:center;
}
.cp-eyebrow{margin:0 0 6px;font-size:.7rem;letter-spacing:.16em;color:var(--gold);font-weight:700}
.cp-title{margin:0 0 10px;font-size:1.26rem;font-family:Georgia,"Yu Mincho","Hiragino Mincho ProN",serif}
.cp-lead{margin:0 0 26px;font-size:.85rem;line-height:1.6;color:var(--muted)}
.cp-lead kbd{
  font-family:inherit;font-size:.78em;padding:1px 6px;border-radius:5px;
  background:#f1e9d8;border:1px solid var(--line);color:var(--ink);
}

.cp-card{
  position:relative;
  display:flex;
  align-items:center;
  gap:14px;
  padding:16px;
  background:#fbf6ec;
  border:1px solid var(--line);
  border-radius:16px;
  text-align:left;
}
.cp-card__thumb{
  flex:0 0 46px;width:46px;height:46px;border-radius:11px;
  display:grid;place-items:center;
  background:linear-gradient(155deg,#3a2c1c,#1c140c);
  color:var(--gold-soft);font-family:Georgia,"Yu Mincho",serif;font-size:1.15rem;
}
.cp-card__info{min-width:0}
.cp-card__title{margin:0 0 2px;font-size:.95rem}
.cp-card__meta{margin:0;font-size:.76rem;color:var(--muted)}

.cp-menu-wrap{position:relative;margin-left:auto;flex:0 0 auto}

.cp-trigger{
  width:34px;height:34px;border-radius:50%;
  display:grid;place-items:center;
  background:var(--surface);border:1px solid var(--line);
  color:var(--muted);font-size:1.15rem;line-height:1;
  cursor:pointer;
  transition:background .15s ease,border-color .15s ease,color .15s ease;
}
.cp-trigger:hover{border-color:var(--gold-soft);color:var(--ink)}
.cp-trigger:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.cp-trigger[aria-expanded="true"]{background:var(--tip-bg);color:var(--tip-ink);border-color:var(--tip-bg)}
.cp-trigger.is-hint{animation:cp-ring 1.5s ease-out 2}
@keyframes cp-ring{
  0%{box-shadow:0 0 0 0 rgba(169,128,63,.4)}
  70%{box-shadow:0 0 0 11px rgba(169,128,63,0)}
  100%{box-shadow:0 0 0 0 rgba(169,128,63,0)}
}

.cp-menu{
  position:absolute;
  top:calc(100% + 12px);
  right:0;
  width:200px;
  padding:8px;
  background:var(--tip-bg);
  border:1px solid rgba(201,162,92,.28);
  border-radius:14px;
  box-shadow:0 30px 60px -22px rgba(15,10,3,.6);
  opacity:0;
  transform:translateY(-6px) scale(.98);
  transform-origin:top right;
  pointer-events:none;
  transition:opacity .16s ease,transform .2s cubic-bezier(.22,1,.36,1);
  z-index:50;
}
.cp-menu.is-open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto}
.cp-menu::before{
  content:"";position:absolute;top:-6px;right:18px;
  border:6px solid transparent;border-bottom-color:var(--tip-bg);
}

.cp-menu__item{
  all:unset;
  display:block;width:100%;box-sizing:border-box;
  padding:9px 12px;border-radius:9px;
  font-size:.82rem;color:var(--tip-ink);
  cursor:pointer;
}
.cp-menu__item:hover,.cp-menu__item:focus-visible{background:rgba(201,162,92,.16)}
.cp-menu__item:focus-visible{outline:1px solid var(--gold-soft);outline-offset:-1px}
.cp-menu__item--danger{color:#e2a99a}
.cp-menu__item--danger:hover,.cp-menu__item--danger:focus-visible{background:rgba(193,102,91,.18)}

.cp-hint{margin:20px 0 0;font-size:.8rem;color:var(--muted)}
.cp-hint__val{color:var(--ink);font-weight:600}

@media (prefers-reduced-motion:reduce){
  .cp-trigger.is-hint{animation:none}
  .cp-menu{transition:opacity .12s ease}
}

【JavaScript】
// クリックポップオーバー: 「⋮」ボタンでメニューを開閉する。
// 外側クリック/Escで閉じ、矢印キー・Home・Endで項目間を移動する(ロービングtabindex)。
(() => {
  const stage = document.querySelector('.cp-stage');
  if (!stage) return; // null安全

  const trigger = stage.querySelector('#cp-trigger');
  const menu = stage.querySelector('#cp-menu');
  const result = stage.querySelector('[data-role="result"]');
  if (!trigger || !menu) return;

  const items = Array.from(menu.querySelectorAll('.cp-menu__item'));
  let isOpen = false;

  const setRoving = (index) => {
    items.forEach((it, i) => { it.tabIndex = i === index ? 0 : -1; });
  };

  const onOutside = (e) => {
    if (menu.contains(e.target) || trigger.contains(e.target)) return;
    closeMenu({ refocusTrigger: false });
  };

  function openMenu() {
    if (isOpen || !items.length) return;
    isOpen = true;
    menu.classList.add('is-open');
    menu.setAttribute('aria-hidden', 'false');
    trigger.setAttribute('aria-expanded', 'true');
    setRoving(0);
    items[0].focus();
    document.addEventListener('pointerdown', onOutside, true);
  }

  function closeMenu({ refocusTrigger = true } = {}) {
    if (!isOpen) return;
    isOpen = false;
    if (menu.contains(document.activeElement)) document.activeElement.blur();
    menu.classList.remove('is-open');
    menu.setAttribute('aria-hidden', 'true');
    trigger.setAttribute('aria-expanded', 'false');
    document.removeEventListener('pointerdown', onOutside, true);
    if (refocusTrigger) trigger.focus();
  }

  trigger.addEventListener('click', () => { isOpen ? closeMenu() : openMenu(); });

  menu.addEventListener('keydown', (e) => {
    const idx = items.indexOf(document.activeElement);
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      const next = items[(idx + 1 + items.length) % items.length];
      setRoving(items.indexOf(next)); next.focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      const prev = items[(idx - 1 + items.length) % items.length];
      setRoving(items.indexOf(prev)); prev.focus();
    } else if (e.key === 'Home') {
      e.preventDefault(); setRoving(0); items[0].focus();
    } else if (e.key === 'End') {
      e.preventDefault();
      const last = items.length - 1;
      setRoving(last); items[last].focus();
    } else if (e.key === 'Escape') {
      e.preventDefault(); closeMenu();
    } else if (e.key === 'Tab') {
      closeMenu({ refocusTrigger: false });
    }
  });

  const labels = { edit: '編集する', share: '共有リンクを作成', archive: 'アーカイブに移動', delete: '削除する' };
  items.forEach((item) => {
    item.addEventListener('click', () => {
      if (result) result.textContent = `「${labels[item.dataset.action] || item.textContent.trim()}」を選択しました`;
      closeMenu();
    });
  });

  // --- 待機時のヒント演出: メニューは開かず、トリガーにそっと注意を引く輪を1〜2回だけ ---
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (reduce) return;

  let idleTimer = null;

  const scheduleIdle = () => {
    window.clearTimeout(idleTimer);
    idleTimer = window.setTimeout(() => {
      if (!isOpen) trigger.classList.add('is-hint');
    }, 3200);
  };
  trigger.addEventListener('animationend', () => {
    trigger.classList.remove('is-hint');
    scheduleIdle();
  });
  const cancelIdle = () => {
    window.clearTimeout(idleTimer);
    trigger.classList.remove('is-hint');
    scheduleIdle();
  };
  stage.addEventListener('pointerenter', cancelIdle, true);
  stage.addEventListener('focusin', cancelIdle);
  scheduleIdle();
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。