ユーザーホバーカード
コメント欄の名前やアバターにカーソルを合わせると、肩書き・統計・フォローボタンを備えたプロフィールカードがふわっと現れます。
ライブデモ
コード
HTML
<!-- ユーザーホバーカード: 名前/アバターにカーソルを合わせる、またはフォーカスするとプロフィールカードがふわっと現れる -->
<section class="hc-stage" aria-label="ユーザーホバーカードの見本">
<p class="hc-eyebrow">HOVERCARD</p>
<h2 class="hc-title">コメント欄の名前から、人物像へ</h2>
<p class="hc-lead">名前にカーソルを合わせる、または <kbd>Tab</kbd> で選ぶとプロフィールが現れます。</p>
<ul class="hc-feed">
<li class="hc-comment">
<button type="button" class="hc-name" aria-haspopup="dialog" aria-expanded="false" aria-controls="hc-card-mio">
<span class="hc-avatar" aria-hidden="true">澪</span>
<span class="hc-name__text">水瀬 澪</span>
</button>
さんが「このやわらかい配色、好きです」とコメントしました。
</li>
<li class="hc-comment">
<button type="button" class="hc-name" aria-haspopup="dialog" aria-expanded="false" aria-controls="hc-card-ren">
<span class="hc-avatar hc-avatar--alt" aria-hidden="true">蓮</span>
<span class="hc-name__text">霜月 蓮</span>
</button>
さんが「このレイアウト、参考にさせてください」とコメントしました。
</li>
</ul>
<div class="hc-card" id="hc-card-mio" role="dialog" aria-modal="false" aria-labelledby="hc-card-mio-title" aria-hidden="true">
<div class="hc-card__head">
<span class="hc-card__avatar" aria-hidden="true">澪</span>
<div class="hc-card__id">
<h3 class="hc-card__name" id="hc-card-mio-title">水瀬 澪</h3>
<p class="hc-card__role">プロダクトデザイナー / 京都</p>
</div>
</div>
<p class="hc-card__bio">余白と色の呼吸を大切にしています。週末は器を集めています。</p>
<dl class="hc-card__stats">
<div><dt>投稿</dt><dd>128</dd></div>
<div><dt>フォロワー</dt><dd>3,204</dd></div>
<div><dt>フォロー中</dt><dd>96</dd></div>
</dl>
<button type="button" class="hc-follow" data-role="follow" aria-pressed="false" tabindex="-1">フォローする</button>
</div>
<div class="hc-card" id="hc-card-ren" role="dialog" aria-modal="false" aria-labelledby="hc-card-ren-title" aria-hidden="true">
<div class="hc-card__head">
<span class="hc-card__avatar hc-card__avatar--alt" aria-hidden="true">蓮</span>
<div class="hc-card__id">
<h3 class="hc-card__name" id="hc-card-ren-title">霜月 蓮</h3>
<p class="hc-card__role">フロントエンドエンジニア / 東京</p>
</div>
</div>
<p class="hc-card__bio">動きの気持ちよさを数値で詰めるのが好きです。猫を飼っています。</p>
<dl class="hc-card__stats">
<div><dt>投稿</dt><dd>342</dd></div>
<div><dt>フォロワー</dt><dd>1,890</dd></div>
<div><dt>フォロー中</dt><dd>211</dd></div>
</dl>
<button type="button" class="hc-follow" data-role="follow" aria-pressed="false" tabindex="-1">フォローする</button>
</div>
</section>
CSS
/* ユーザーホバーカード: 生成りのコメント欄から、名前にhoverするとプロフィールカードがふわっと現れる */
:root{
--bg-1:#f7f1e6;
--bg-2:#ecdfc6;
--surface:#fffdf9;
--line:#e6dac2;
--ink:#2a2420;
--muted:#8b7d6a;
--gold:#a9803f;
--gold-soft:#caa25f;
--tip-bg:#211b15;
}
*{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));
}
.hc-stage{
position:relative;
width:min(480px,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 36px;
}
.hc-eyebrow{margin:0 0 6px;font-size:.7rem;letter-spacing:.16em;color:var(--gold);font-weight:700;text-align:center}
.hc-title{margin:0 0 10px;font-size:1.22rem;text-align:center;font-family:Georgia,"Yu Mincho","Hiragino Mincho ProN",serif}
.hc-lead{margin:0 0 22px;font-size:.84rem;line-height:1.6;color:var(--muted);text-align:center}
.hc-lead kbd{
font-family:inherit;font-size:.78em;padding:1px 6px;border-radius:5px;
background:#f1e9d8;border:1px solid var(--line);color:var(--ink);
}
.hc-feed{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:14px}
.hc-comment{
font-size:.9rem;line-height:1.9;color:var(--ink);
padding:14px 16px;background:#fbf6ec;border:1px solid var(--line);border-radius:14px;
}
.hc-name{
all:unset;
display:inline-flex;
align-items:center;
gap:7px;
cursor:pointer;
padding:2px 8px 2px 2px;
border-radius:999px;
font-weight:700;
vertical-align:middle;
transition:background .15s ease;
}
.hc-name:hover,.hc-name:focus-visible,.hc-name[aria-expanded="true"]{background:rgba(169,128,63,.14)}
.hc-name:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.hc-avatar{
width:26px;height:26px;border-radius:50%;
display:grid;place-items:center;flex:0 0 auto;
background:radial-gradient(circle at 35% 30%,#3a2c1c,var(--tip-bg));
color:var(--gold-soft);
font-family:Georgia,"Yu Mincho",serif;font-size:.82rem;
}
.hc-avatar--alt{background:radial-gradient(circle at 35% 30%,#233226,#12190f);color:#bcd1a8}
.hc-name__text{color:var(--ink)}
.hc-card{
position:absolute;
top:0;left:0;
width:min(280px,84vw);
padding:18px 18px 16px;
background:var(--surface);
border:1px solid var(--line);
border-radius:18px;
box-shadow:0 32px 64px -26px rgba(40,28,8,.42),0 4px 14px rgba(40,28,8,.08);
opacity:0;
transform:translateY(8px);
pointer-events:none;
transition:opacity .22s ease,transform .3s cubic-bezier(.22,1,.36,1);
z-index:40;
text-align:left;
}
.hc-card.is-open{opacity:1;transform:translateY(0);pointer-events:auto}
.hc-card::before{
content:"";position:absolute;top:-6px;left:24px;
border:6px solid transparent;border-bottom-color:var(--surface);
}
.hc-card__head{display:flex;align-items:center;gap:12px;margin-bottom:10px}
.hc-card__avatar{
width:48px;height:48px;border-radius:50%;flex:0 0 auto;
display:grid;place-items:center;
background:radial-gradient(circle at 35% 30%,#3a2c1c,var(--tip-bg));
color:var(--gold-soft);font-family:Georgia,"Yu Mincho",serif;font-size:1.3rem;
}
.hc-card__avatar--alt{background:radial-gradient(circle at 35% 30%,#233226,#12190f);color:#bcd1a8}
.hc-card__id{min-width:0}
.hc-card__name{margin:0;font-size:1rem;font-family:Georgia,"Yu Mincho",serif}
.hc-card__role{margin:2px 0 0;font-size:.76rem;color:var(--muted)}
.hc-card__bio{margin:0 0 14px;font-size:.8rem;line-height:1.6;color:var(--ink)}
.hc-card__stats{
display:grid;grid-template-columns:repeat(3,1fr);
gap:4px;margin:0 0 14px;padding:10px 0;
border-top:1px solid var(--line);border-bottom:1px solid var(--line);
}
.hc-card__stats div{text-align:center}
.hc-card__stats dt{margin:0;font-size:.68rem;color:var(--muted)}
.hc-card__stats dd{margin:2px 0 0;font-size:.92rem;font-weight:700;color:var(--ink)}
.hc-follow{
width:100%;
font:inherit;font-weight:700;font-size:.82rem;
color:#fff;
background:linear-gradient(135deg,#3a2c1c,var(--tip-bg));
border:1px solid var(--tip-bg);
border-radius:10px;
padding:9px 12px;
cursor:pointer;
transition:opacity .15s ease,transform .15s ease;
}
.hc-follow:hover{opacity:.9}
.hc-follow:active{transform:translateY(1px)}
.hc-follow:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.hc-follow[aria-pressed="true"]{
color:var(--ink);
background:var(--surface);
border-color:var(--line);
}
@media (prefers-reduced-motion:reduce){
.hc-card{transition:opacity .15s ease}
}
JavaScript
// ユーザーホバーカード: 名前にhoverしてしばらく待つとプロフィールカードを表示。
// クリックで固定表示、フォーカスでも表示、Escや外側クリックで閉じる。フォローボタンはトグル動作。
(() => {
const stage = document.querySelector('.hc-stage');
if (!stage) return; // null安全
const names = Array.from(stage.querySelectorAll('.hc-name'));
if (!names.length) return;
const SHOW_DELAY = 320;
const HIDE_DELAY = 220;
let showTimer = null;
let hideTimer = null;
let current = null; // { trigger, card, pinned }
const clearTimers = () => {
window.clearTimeout(showTimer);
window.clearTimeout(hideTimer);
};
const position = (trigger, card) => {
const tr = trigger.getBoundingClientRect();
const sr = stage.getBoundingClientRect();
const top = tr.bottom - sr.top + 12;
const cardRect = card.getBoundingClientRect();
const cardWidth = cardRect.width || 280;
const margin = 14;
const rawLeft = tr.left - sr.left - 8;
const maxLeft = Math.max(margin, sr.width - cardWidth - margin);
const left = Math.min(Math.max(rawLeft, margin), maxLeft);
card.style.top = `${top}px`;
card.style.left = `${left}px`;
};
const setInteractive = (card, on) => {
const follow = card.querySelector('.hc-follow');
if (follow) follow.tabIndex = on ? 0 : -1;
};
const closeCurrent = () => {
if (!current) return;
const { trigger, card } = current;
if (card.contains(document.activeElement)) document.activeElement.blur();
card.classList.remove('is-open');
card.setAttribute('aria-hidden', 'true');
trigger.setAttribute('aria-expanded', 'false');
setInteractive(card, false);
current = null;
};
const openFor = (trigger, card, { pinned = false } = {}) => {
clearTimers();
if (current && current.card !== card) closeCurrent();
position(trigger, card);
card.classList.add('is-open');
card.setAttribute('aria-hidden', 'false');
trigger.setAttribute('aria-expanded', 'true');
setInteractive(card, true);
current = { trigger, card, pinned };
};
names.forEach((trigger) => {
const id = trigger.getAttribute('aria-controls');
const card = id ? document.getElementById(id) : null;
if (!card) return;
const follow = card.querySelector('.hc-follow');
if (follow) {
follow.addEventListener('click', () => {
const pressed = follow.getAttribute('aria-pressed') === 'true';
follow.setAttribute('aria-pressed', String(!pressed));
follow.textContent = !pressed ? 'フォロー中' : 'フォローする';
});
}
trigger.addEventListener('mouseenter', () => {
clearTimers();
showTimer = window.setTimeout(() => openFor(trigger, card), SHOW_DELAY);
});
trigger.addEventListener('mouseleave', () => {
window.clearTimeout(showTimer);
if (current && current.pinned) return;
hideTimer = window.setTimeout(closeCurrent, HIDE_DELAY);
});
trigger.addEventListener('focus', () => {
clearTimers();
openFor(trigger, card);
});
trigger.addEventListener('blur', () => {
if (current && current.pinned) return;
hideTimer = window.setTimeout(() => {
if (!card.matches(':hover') && !card.contains(document.activeElement)) closeCurrent();
}, HIDE_DELAY);
});
trigger.addEventListener('click', () => {
clearTimers();
if (current && current.card === card && current.pinned) {
closeCurrent();
} else {
openFor(trigger, card, { pinned: true });
}
});
card.addEventListener('mouseenter', () => window.clearTimeout(hideTimer));
card.addEventListener('mouseleave', () => {
if (current && current.pinned) return;
hideTimer = window.setTimeout(closeCurrent, HIDE_DELAY);
});
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && current) {
const t = current.trigger;
t.focus();
closeCurrent();
}
});
document.addEventListener('pointerdown', (e) => {
if (!current) return;
if (e.target.closest('.hc-name') || e.target.closest('.hc-card')) return;
closeCurrent();
});
window.addEventListener('resize', () => {
if (current) position(current.trigger, current.card);
});
// --- 待機時オートプレビュー: 誰も操作していない間、最初のカードを一度だけそっと見せる ---
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !names[0]) return;
const firstTrigger = names[0];
const firstCard = document.getElementById(firstTrigger.getAttribute('aria-controls'));
let autoShown = false;
const idleTimer = window.setTimeout(() => {
if (autoShown || current || !firstCard) return;
autoShown = true;
openFor(firstTrigger, firstCard);
window.setTimeout(() => {
if (current && current.trigger === firstTrigger && !current.pinned) closeCurrent();
}, 2400);
}, 2800);
stage.addEventListener('pointerenter', () => window.clearTimeout(idleTimer), { once: true });
})();
🤖 AIエージェント用プロンプト
このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「ユーザーホバーカード」の効果を追加してください。
# 追加してほしい効果
ユーザーホバーカード(ツールチップ & メニュー)
コメント欄の名前やアバターにカーソルを合わせると、肩書き・統計・フォローボタンを備えたプロフィールカードがふわっと現れます。
# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】
# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- ユーザーホバーカード: 名前/アバターにカーソルを合わせる、またはフォーカスするとプロフィールカードがふわっと現れる -->
<section class="hc-stage" aria-label="ユーザーホバーカードの見本">
<p class="hc-eyebrow">HOVERCARD</p>
<h2 class="hc-title">コメント欄の名前から、人物像へ</h2>
<p class="hc-lead">名前にカーソルを合わせる、または <kbd>Tab</kbd> で選ぶとプロフィールが現れます。</p>
<ul class="hc-feed">
<li class="hc-comment">
<button type="button" class="hc-name" aria-haspopup="dialog" aria-expanded="false" aria-controls="hc-card-mio">
<span class="hc-avatar" aria-hidden="true">澪</span>
<span class="hc-name__text">水瀬 澪</span>
</button>
さんが「このやわらかい配色、好きです」とコメントしました。
</li>
<li class="hc-comment">
<button type="button" class="hc-name" aria-haspopup="dialog" aria-expanded="false" aria-controls="hc-card-ren">
<span class="hc-avatar hc-avatar--alt" aria-hidden="true">蓮</span>
<span class="hc-name__text">霜月 蓮</span>
</button>
さんが「このレイアウト、参考にさせてください」とコメントしました。
</li>
</ul>
<div class="hc-card" id="hc-card-mio" role="dialog" aria-modal="false" aria-labelledby="hc-card-mio-title" aria-hidden="true">
<div class="hc-card__head">
<span class="hc-card__avatar" aria-hidden="true">澪</span>
<div class="hc-card__id">
<h3 class="hc-card__name" id="hc-card-mio-title">水瀬 澪</h3>
<p class="hc-card__role">プロダクトデザイナー / 京都</p>
</div>
</div>
<p class="hc-card__bio">余白と色の呼吸を大切にしています。週末は器を集めています。</p>
<dl class="hc-card__stats">
<div><dt>投稿</dt><dd>128</dd></div>
<div><dt>フォロワー</dt><dd>3,204</dd></div>
<div><dt>フォロー中</dt><dd>96</dd></div>
</dl>
<button type="button" class="hc-follow" data-role="follow" aria-pressed="false" tabindex="-1">フォローする</button>
</div>
<div class="hc-card" id="hc-card-ren" role="dialog" aria-modal="false" aria-labelledby="hc-card-ren-title" aria-hidden="true">
<div class="hc-card__head">
<span class="hc-card__avatar hc-card__avatar--alt" aria-hidden="true">蓮</span>
<div class="hc-card__id">
<h3 class="hc-card__name" id="hc-card-ren-title">霜月 蓮</h3>
<p class="hc-card__role">フロントエンドエンジニア / 東京</p>
</div>
</div>
<p class="hc-card__bio">動きの気持ちよさを数値で詰めるのが好きです。猫を飼っています。</p>
<dl class="hc-card__stats">
<div><dt>投稿</dt><dd>342</dd></div>
<div><dt>フォロワー</dt><dd>1,890</dd></div>
<div><dt>フォロー中</dt><dd>211</dd></div>
</dl>
<button type="button" class="hc-follow" data-role="follow" aria-pressed="false" tabindex="-1">フォローする</button>
</div>
</section>
【CSS】
/* ユーザーホバーカード: 生成りのコメント欄から、名前にhoverするとプロフィールカードがふわっと現れる */
:root{
--bg-1:#f7f1e6;
--bg-2:#ecdfc6;
--surface:#fffdf9;
--line:#e6dac2;
--ink:#2a2420;
--muted:#8b7d6a;
--gold:#a9803f;
--gold-soft:#caa25f;
--tip-bg:#211b15;
}
*{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));
}
.hc-stage{
position:relative;
width:min(480px,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 36px;
}
.hc-eyebrow{margin:0 0 6px;font-size:.7rem;letter-spacing:.16em;color:var(--gold);font-weight:700;text-align:center}
.hc-title{margin:0 0 10px;font-size:1.22rem;text-align:center;font-family:Georgia,"Yu Mincho","Hiragino Mincho ProN",serif}
.hc-lead{margin:0 0 22px;font-size:.84rem;line-height:1.6;color:var(--muted);text-align:center}
.hc-lead kbd{
font-family:inherit;font-size:.78em;padding:1px 6px;border-radius:5px;
background:#f1e9d8;border:1px solid var(--line);color:var(--ink);
}
.hc-feed{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:14px}
.hc-comment{
font-size:.9rem;line-height:1.9;color:var(--ink);
padding:14px 16px;background:#fbf6ec;border:1px solid var(--line);border-radius:14px;
}
.hc-name{
all:unset;
display:inline-flex;
align-items:center;
gap:7px;
cursor:pointer;
padding:2px 8px 2px 2px;
border-radius:999px;
font-weight:700;
vertical-align:middle;
transition:background .15s ease;
}
.hc-name:hover,.hc-name:focus-visible,.hc-name[aria-expanded="true"]{background:rgba(169,128,63,.14)}
.hc-name:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.hc-avatar{
width:26px;height:26px;border-radius:50%;
display:grid;place-items:center;flex:0 0 auto;
background:radial-gradient(circle at 35% 30%,#3a2c1c,var(--tip-bg));
color:var(--gold-soft);
font-family:Georgia,"Yu Mincho",serif;font-size:.82rem;
}
.hc-avatar--alt{background:radial-gradient(circle at 35% 30%,#233226,#12190f);color:#bcd1a8}
.hc-name__text{color:var(--ink)}
.hc-card{
position:absolute;
top:0;left:0;
width:min(280px,84vw);
padding:18px 18px 16px;
background:var(--surface);
border:1px solid var(--line);
border-radius:18px;
box-shadow:0 32px 64px -26px rgba(40,28,8,.42),0 4px 14px rgba(40,28,8,.08);
opacity:0;
transform:translateY(8px);
pointer-events:none;
transition:opacity .22s ease,transform .3s cubic-bezier(.22,1,.36,1);
z-index:40;
text-align:left;
}
.hc-card.is-open{opacity:1;transform:translateY(0);pointer-events:auto}
.hc-card::before{
content:"";position:absolute;top:-6px;left:24px;
border:6px solid transparent;border-bottom-color:var(--surface);
}
.hc-card__head{display:flex;align-items:center;gap:12px;margin-bottom:10px}
.hc-card__avatar{
width:48px;height:48px;border-radius:50%;flex:0 0 auto;
display:grid;place-items:center;
background:radial-gradient(circle at 35% 30%,#3a2c1c,var(--tip-bg));
color:var(--gold-soft);font-family:Georgia,"Yu Mincho",serif;font-size:1.3rem;
}
.hc-card__avatar--alt{background:radial-gradient(circle at 35% 30%,#233226,#12190f);color:#bcd1a8}
.hc-card__id{min-width:0}
.hc-card__name{margin:0;font-size:1rem;font-family:Georgia,"Yu Mincho",serif}
.hc-card__role{margin:2px 0 0;font-size:.76rem;color:var(--muted)}
.hc-card__bio{margin:0 0 14px;font-size:.8rem;line-height:1.6;color:var(--ink)}
.hc-card__stats{
display:grid;grid-template-columns:repeat(3,1fr);
gap:4px;margin:0 0 14px;padding:10px 0;
border-top:1px solid var(--line);border-bottom:1px solid var(--line);
}
.hc-card__stats div{text-align:center}
.hc-card__stats dt{margin:0;font-size:.68rem;color:var(--muted)}
.hc-card__stats dd{margin:2px 0 0;font-size:.92rem;font-weight:700;color:var(--ink)}
.hc-follow{
width:100%;
font:inherit;font-weight:700;font-size:.82rem;
color:#fff;
background:linear-gradient(135deg,#3a2c1c,var(--tip-bg));
border:1px solid var(--tip-bg);
border-radius:10px;
padding:9px 12px;
cursor:pointer;
transition:opacity .15s ease,transform .15s ease;
}
.hc-follow:hover{opacity:.9}
.hc-follow:active{transform:translateY(1px)}
.hc-follow:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
.hc-follow[aria-pressed="true"]{
color:var(--ink);
background:var(--surface);
border-color:var(--line);
}
@media (prefers-reduced-motion:reduce){
.hc-card{transition:opacity .15s ease}
}
【JavaScript】
// ユーザーホバーカード: 名前にhoverしてしばらく待つとプロフィールカードを表示。
// クリックで固定表示、フォーカスでも表示、Escや外側クリックで閉じる。フォローボタンはトグル動作。
(() => {
const stage = document.querySelector('.hc-stage');
if (!stage) return; // null安全
const names = Array.from(stage.querySelectorAll('.hc-name'));
if (!names.length) return;
const SHOW_DELAY = 320;
const HIDE_DELAY = 220;
let showTimer = null;
let hideTimer = null;
let current = null; // { trigger, card, pinned }
const clearTimers = () => {
window.clearTimeout(showTimer);
window.clearTimeout(hideTimer);
};
const position = (trigger, card) => {
const tr = trigger.getBoundingClientRect();
const sr = stage.getBoundingClientRect();
const top = tr.bottom - sr.top + 12;
const cardRect = card.getBoundingClientRect();
const cardWidth = cardRect.width || 280;
const margin = 14;
const rawLeft = tr.left - sr.left - 8;
const maxLeft = Math.max(margin, sr.width - cardWidth - margin);
const left = Math.min(Math.max(rawLeft, margin), maxLeft);
card.style.top = `${top}px`;
card.style.left = `${left}px`;
};
const setInteractive = (card, on) => {
const follow = card.querySelector('.hc-follow');
if (follow) follow.tabIndex = on ? 0 : -1;
};
const closeCurrent = () => {
if (!current) return;
const { trigger, card } = current;
if (card.contains(document.activeElement)) document.activeElement.blur();
card.classList.remove('is-open');
card.setAttribute('aria-hidden', 'true');
trigger.setAttribute('aria-expanded', 'false');
setInteractive(card, false);
current = null;
};
const openFor = (trigger, card, { pinned = false } = {}) => {
clearTimers();
if (current && current.card !== card) closeCurrent();
position(trigger, card);
card.classList.add('is-open');
card.setAttribute('aria-hidden', 'false');
trigger.setAttribute('aria-expanded', 'true');
setInteractive(card, true);
current = { trigger, card, pinned };
};
names.forEach((trigger) => {
const id = trigger.getAttribute('aria-controls');
const card = id ? document.getElementById(id) : null;
if (!card) return;
const follow = card.querySelector('.hc-follow');
if (follow) {
follow.addEventListener('click', () => {
const pressed = follow.getAttribute('aria-pressed') === 'true';
follow.setAttribute('aria-pressed', String(!pressed));
follow.textContent = !pressed ? 'フォロー中' : 'フォローする';
});
}
trigger.addEventListener('mouseenter', () => {
clearTimers();
showTimer = window.setTimeout(() => openFor(trigger, card), SHOW_DELAY);
});
trigger.addEventListener('mouseleave', () => {
window.clearTimeout(showTimer);
if (current && current.pinned) return;
hideTimer = window.setTimeout(closeCurrent, HIDE_DELAY);
});
trigger.addEventListener('focus', () => {
clearTimers();
openFor(trigger, card);
});
trigger.addEventListener('blur', () => {
if (current && current.pinned) return;
hideTimer = window.setTimeout(() => {
if (!card.matches(':hover') && !card.contains(document.activeElement)) closeCurrent();
}, HIDE_DELAY);
});
trigger.addEventListener('click', () => {
clearTimers();
if (current && current.card === card && current.pinned) {
closeCurrent();
} else {
openFor(trigger, card, { pinned: true });
}
});
card.addEventListener('mouseenter', () => window.clearTimeout(hideTimer));
card.addEventListener('mouseleave', () => {
if (current && current.pinned) return;
hideTimer = window.setTimeout(closeCurrent, HIDE_DELAY);
});
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && current) {
const t = current.trigger;
t.focus();
closeCurrent();
}
});
document.addEventListener('pointerdown', (e) => {
if (!current) return;
if (e.target.closest('.hc-name') || e.target.closest('.hc-card')) return;
closeCurrent();
});
window.addEventListener('resize', () => {
if (current) position(current.trigger, current.card);
});
// --- 待機時オートプレビュー: 誰も操作していない間、最初のカードを一度だけそっと見せる ---
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce || !names[0]) return;
const firstTrigger = names[0];
const firstCard = document.getElementById(firstTrigger.getAttribute('aria-controls'));
let autoShown = false;
const idleTimer = window.setTimeout(() => {
if (autoShown || current || !firstCard) return;
autoShown = true;
openFor(firstTrigger, firstCard);
window.setTimeout(() => {
if (current && current.trigger === firstTrigger && !current.pinned) closeCurrent();
}, 2400);
}, 2800);
stage.addEventListener('pointerenter', () => window.clearTimeout(idleTimer), { once: true });
})();
# 外部ライブラリ
なし(追加ライブラリ不要)
# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。