クリックポップオーバー
「⋮」ボタンをクリックすると矢印付きのメニューが開くポップオーバー。外側クリックや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();
})();
🤖 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。