ホバーしたときにポップアップやツールチップを表示したい、マウスを乗せるとプレビューカードが現れるUIを作りたい――こうした「マウスオーバーで要素を追加する」パターンは、インタラクティブなWebページ制作で頻繁に登場します。
この記事では、単純な要素追加の基本から、重複追加の防止・フェードアニメーション・ツールチップ・プレビューカード・マウス追従エフェクトといった実践パターンまで、コピペして即使えるコードとともに体系的に解説します。
mouseover と mouseenter の違い(どちらを使うべきか)
要素を追加する処理を書く前に、イベントの選択が重要です。似た名前の mouseover と mouseenter には大きな違いがあります。
| mouseover | mouseenter | |
|---|---|---|
| 子要素への移動 | 子要素に入るたびに発火(バブリングあり) | 対象要素に入った1回だけ発火 |
| 要素追加用途 | 予期しない多重発火の原因になりやすい | ほぼ必ずこちらを使う |
| セット | mouseout | mouseleave |
mouseenter / mouseleave を使いましょう。mouseover は子要素に移動するたびに発火するため、要素が何個も追加され続けるバグの原因になります。各マウスイベントの詳細な違いは【JavaScript】マウスイベントの使い方|mouseenter・mouseleave・mouseover の使い分けで詳しく解説しています。
基本実装:ホバーで要素を追加、マウスアウトで削除
最もシンプルな実装です。mouseenter で要素を追加し、mouseleave で削除します。
<div class="hover-target">ここにホバーしてください</div>
.hover-target {
position: relative;
padding: 16px 24px;
background: #e0f2fe;
border: 2px solid #0284c7;
border-radius: 8px;
cursor: pointer;
display: inline-block;
}
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #0f172a;
color: #fff;
font-size: 0.85rem;
padding: 6px 12px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
}
const target = document.querySelector('.hover-target');
target.addEventListener('mouseenter', () => {
const tip = document.createElement('div');
tip.className = 'tooltip';
tip.textContent = 'ホバー中!';
target.appendChild(tip);
});
target.addEventListener('mouseleave', () => {
const tip = target.querySelector('.tooltip');
if (tip) tip.remove();
});
要素の追加・削除の基本的な操作については【JavaScript】HTML要素を追加・削除する方法も参考にしてください。
重複追加を防ぐ3つのパターン
ホバー中に複数回イベントが発火したり、削除処理が漏れたりすると、同じ要素が何個も追加されてしまいます。以下の3つのアプローチで防止できます。
パターン1:追加前に存在チェック
target.addEventListener('mouseenter', () => {
if (target.querySelector('.tooltip')) return; // 既にあれば追加しない
const tip = document.createElement('div');
tip.className = 'tooltip';
tip.textContent = 'ホバー中!';
target.appendChild(tip);
});
パターン2:フラグ変数で管理
let isHovered = false;
target.addEventListener('mouseenter', () => {
if (isHovered) return;
isHovered = true;
const tip = document.createElement('div');
tip.className = 'tooltip';
tip.textContent = 'ホバー中!';
target.appendChild(tip);
});
target.addEventListener('mouseleave', () => {
isHovered = false;
target.querySelector('.tooltip')?.remove();
});
パターン3:innerHTML / textContent で直接更新(追加ではなく切替)
追加・削除を繰り返す代わりに、要素をあらかじめHTML側に用意しておき、表示/非表示をCSSクラスで切り替える方法です。DOMの書き換えが最小限になりパフォーマンスが最も高い方法です。
<div class="hover-target"> ここにホバーしてください <div class="tooltip hidden">ホバー中!</div> </div>
.tooltip { /* ...省略... */ }
.tooltip.hidden { display: none; }
const tip = target.querySelector('.tooltip');
target.addEventListener('mouseenter', () => tip.classList.remove('hidden'));
target.addEventListener('mouseleave', () => tip.classList.add('hidden'));
フェードイン・フェードアウトアニメーション
要素をいきなり表示・非表示にするのではなく、フェードイン/アウトでなめらかにするとUXが向上します。CSSの transition と opacity を組み合わせるのが最もシンプルな実装です。
.tooltip {
/* ...位置指定などは省略... */
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.tooltip.visible {
opacity: 1;
}
target.addEventListener('mouseenter', () => {
// 既に存在すれば再利用
let tip = target.querySelector('.tooltip');
if (!tip) {
tip = document.createElement('div');
tip.className = 'tooltip';
tip.textContent = 'ホバー中!';
target.appendChild(tip);
}
// 次のフレームでクラス付与(transitionを機能させるため)
requestAnimationFrame(() => tip.classList.add('visible'));
});
target.addEventListener('mouseleave', () => {
const tip = target.querySelector('.tooltip');
if (!tip) return;
tip.classList.remove('visible');
// transitionが終わってから削除
tip.addEventListener('transitionend', () => tip.remove(), { once: true });
});
element.getBoundingClientRect() で強制リフローを起こす方法も確実です。const tip = document.createElement('div');
tip.className = 'tooltip';
tip.textContent = 'ホバー中!';
target.appendChild(tip);
tip.getBoundingClientRect(); // 強制リフローでスタイルを確定
tip.classList.add('visible'); // transitionが確実に動作する
実践パターン集
ツールチップ(Tooltip)
data属性でツールチップのテキストを管理し、複数要素に一括適用するパターンです。
<button data-tooltip="保存します">保存</button> <button data-tooltip="削除します(元に戻せません)">削除</button>
[data-tooltip] { position: relative; }
.tooltip-popup {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #1e293b;
color: #fff;
font-size: 0.8rem;
padding: 6px 10px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 100;
}
.tooltip-popup.show { opacity: 1; }
document.querySelectorAll('[data-tooltip]').forEach((el) => {
let popup;
el.addEventListener('mouseenter', () => {
popup = document.createElement('div');
popup.className = 'tooltip-popup';
popup.textContent = el.dataset.tooltip;
el.appendChild(popup);
requestAnimationFrame(() => popup.classList.add('show'));
});
el.addEventListener('mouseleave', () => {
if (!popup) return;
popup.classList.remove('show');
popup.addEventListener('transitionend', () => popup.remove(), { once: true });
});
});
プレビューカード(商品・ユーザー情報の展開表示)
リスト項目にホバーするとカード形式の詳細情報をポップアップ表示するパターンです。ECサイトや一覧画面でよく使われます。
<ul class="user-list">
<li data-name="山田太郎" data-role="エンジニア" data-img="https://example.com/avatar1.jpg">
山田太郎
</li>
<li data-name="鈴木花子" data-role="デザイナー" data-img="https://example.com/avatar2.jpg">
鈴木花子
</li>
</ul>
const list = document.querySelector('.user-list');
let card;
list.querySelectorAll('li').forEach((li) => {
li.addEventListener('mouseenter', (e) => {
card = document.createElement('div');
card.className = 'preview-card';
card.innerHTML = `
<strong>${li.dataset.name}</strong>
<span>${li.dataset.role}</span>
`;
// マウス位置の近くに表示
card.style.cssText = `position:fixed; top:${e.clientY + 12}px; left:${e.clientX + 12}px; z-index:999;`;
document.body.appendChild(card);
requestAnimationFrame(() => card.classList.add('show'));
});
li.addEventListener('mousemove', (e) => {
if (!card) return;
card.style.top = `${e.clientY + 12}px`;
card.style.left = `${e.clientX + 12}px`;
});
li.addEventListener('mouseleave', () => {
if (!card) return;
card.classList.remove('show');
card.addEventListener('transitionend', () => { card.remove(); card = null; }, { once: true });
});
});
マウス追従エフェクト(スパークル)
マウスが通過した位置に小さなパーティクルを生成するエフェクトです。ランディングページやポートフォリオサイトのアクセント演出に使われます。
.sparkle {
position: fixed;
width: 8px;
height: 8px;
border-radius: 50%;
pointer-events: none;
animation: sparkle-fade 0.6s ease forwards;
z-index: 9999;
}
@keyframes sparkle-fade {
0% { opacity: 1; transform: scale(1) translateY(0); }
100% { opacity: 0; transform: scale(0) translateY(-20px); }
}
const COLORS = ['#f43f5e', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6'];
document.addEventListener('mousemove', (e) => {
const spark = document.createElement('div');
spark.className = 'sparkle';
spark.style.left = `${e.clientX - 4}px`;
spark.style.top = `${e.clientY - 4}px`;
spark.style.background = COLORS[Math.floor(Math.random() * COLORS.length)];
document.body.appendChild(spark);
// アニメーション終了後に自動削除
spark.addEventListener('animationend', () => spark.remove(), { once: true });
});
Event Delegation で動的追加要素にも対応する
JavaScriptで後から追加した要素には、ページ読み込み時に設定した addEventListener は効きません。そのような場合は Event Delegation(イベント委譲) を使って、親要素でイベントを一括管理します。
<ul id="dynamic-list"> <li>既存アイテム1</li> <li>既存アイテム2</li> </ul> <button id="add-item">アイテムを追加</button>
mouseenter / mouseleave はバブリングしないため、通常の Event Delegation には使えません。実務では mouseover / mouseout + event.target.closest() の組み合わせが最もシンプルで安全です。const list = document.getElementById('dynamic-list');
// mouseover + closest() による Event Delegation(バブリングするので委譲できる)
list.addEventListener('mouseover', (e) => {
const li = e.target.closest('li');
if (!li || li.querySelector('.hover-badge')) return;
const badge = document.createElement('span');
badge.className = 'hover-badge';
badge.textContent = 'ホバー中';
li.appendChild(badge);
});
list.addEventListener('mouseout', (e) => {
const li = e.target.closest('li');
if (!li) return;
// mouseout は子要素へ移動しても発火するため、行き先が同じli内か確認
if (li.contains(e.relatedTarget)) return;
li.querySelector('.hover-badge')?.remove();
});
// 後から追加しても自動的に対応
document.getElementById('add-item').addEventListener('click', () => {
const li = document.createElement('li');
li.textContent = `追加アイテム${list.children.length + 1}`;
list.appendChild(li);
});
mouseout は子要素に移動したときにも発火します。e.relatedTarget(マウスが次に移動した要素)が同じ li 内かを contains() で確認することで、子要素へのホバー時に誤って削除されるのを防げます。Event Delegation の詳細は【JavaScript】Event Delegationで効率的にイベントを管理する方法をご覧ください。タッチデバイス対応(スマートフォン)
スマートフォンでは mouseenter / mouseleave が正常に動作しません。タッチデバイスでも同等の体験を提供するには touchstart / touchend を併用します。
function addElement(el) {
if (el.querySelector('.tooltip')) return;
const tip = document.createElement('div');
tip.className = 'tooltip';
tip.textContent = 'タップ中!';
el.appendChild(tip);
requestAnimationFrame(() => tip.classList.add('visible'));
}
function removeElement(el) {
const tip = el.querySelector('.tooltip');
if (!tip) return;
tip.classList.remove('visible');
tip.addEventListener('transitionend', () => tip.remove(), { once: true });
}
const target = document.querySelector('.hover-target');
// マウス
target.addEventListener('mouseenter', () => addElement(target));
target.addEventListener('mouseleave', () => removeElement(target));
// タッチ
target.addEventListener('touchstart', (e) => {
e.preventDefault(); // click イベントの二重発火を防止
addElement(target);
}, { passive: false });
target.addEventListener('touchend', () => {
setTimeout(() => removeElement(target), 600); // 少し残す
});
パフォーマンス最適化のポイント
- DOM操作の最小化:毎回 createElement するより、要素を再利用するか CSSクラスで切替える
- DocumentFragment の活用:複数要素を一度に追加する場合は DocumentFragment にまとめてから appendChild する
- mousemove の throttle:頻繁に発火するイベントは throttle で間引く
- { once: true } の活用:一度だけ実行すればいい addEventListener は
{ once: true }で自動解除する
target.addEventListener('mouseenter', () => {
const fragment = document.createDocumentFragment();
['タグ1', 'タグ2', 'タグ3'].forEach((text) => {
const span = document.createElement('span');
span.textContent = text;
span.className = 'tag';
fragment.appendChild(span);
});
target.appendChild(fragment); // DOM更新は1回だけ
});
よくある質問
mouseenter / mouseleave を使います。mouseover / mouseout は子要素への移動でも発火するため、要素が意図せず何度も追加されるバグの原因になります。querySelector で既存要素の有無を確認するか、フラグ変数で制御してください。あるいは要素を毎回生成せず、CSSクラスの追加/削除で表示を切り替える方法が最も確実です。transitionend イベントを使います。opacity: 0 への transition が完了したタイミングで remove() を呼ぶことで、アニメーション完了後に要素を削除できます。{ once: true } を指定するとリスナーが自動解除されてメモリリークを防げます。e.target で対象を絞り込みます。mouseenter / mouseleave はバブリングしないため、キャプチャフェーズ({ capture: true })を使うか mouseover / mouseout を選択してください。touchstart / touchend イベントを併用することで同等の体験を提供できます。また CSS の @media (hover: hover) メディアクエリを使うと、ホバーが使える環境のみにCSS効果を限定できます。まとめ
JavaScriptでマウスオーバーを起点に要素を動的に追加・削除する実装のポイントをまとめます。
- イベントには
mouseenter / mouseleaveを使う(mouseoverは多重発火の原因) - 重複追加は存在チェック・フラグ・CSSクラス切替の3パターンで防ぐ
- フェードアニメーションは
requestAnimationFrame + transition + transitionendの組み合わせ - 後から追加した要素への対応は Event Delegation(キャプチャフェーズ)
- タッチデバイスには
touchstart / touchendを併用する
classList の操作については【JavaScript】classListの使い方完全ガイドも参考にしてください。