【JavaScript】マウスオーバーで要素を動的に追加・削除する実践ガイド|ツールチップ・プレビュー・アニメーション・Event Delegation まで解説

ホバーしたときにポップアップやツールチップを表示したい、マウスを乗せるとプレビューカードが現れるUIを作りたい――こうした「マウスオーバーで要素を追加する」パターンは、インタラクティブなWebページ制作で頻繁に登場します。

この記事では、単純な要素追加の基本から、重複追加の防止・フェードアニメーション・ツールチップ・プレビューカード・マウス追従エフェクトといった実践パターンまで、コピペして即使えるコードとともに体系的に解説します。

スポンサーリンク

mouseover と mouseenter の違い(どちらを使うべきか)

要素を追加する処理を書く前に、イベントの選択が重要です。似た名前の mouseovermouseenter には大きな違いがあります。

mouseover mouseenter
子要素への移動 子要素に入るたびに発火(バブリングあり) 対象要素に入った1回だけ発火
要素追加用途 予期しない多重発火の原因になりやすい ほぼ必ずこちらを使う
セット mouseout mouseleave
要素追加には必ず mouseenter / mouseleave を使いましょう。mouseover は子要素に移動するたびに発火するため、要素が何個も追加され続けるバグの原因になります。

各マウスイベントの詳細な違いは【JavaScript】マウスイベントの使い方|mouseenter・mouseleave・mouseover の使い分けで詳しく解説しています。

基本実装:ホバーで要素を追加、マウスアウトで削除

最もシンプルな実装です。mouseenter で要素を追加し、mouseleave で削除します。

HTML
<div class="hover-target">ここにホバーしてください</div>
CSS
.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の書き換えが最小限になりパフォーマンスが最も高い方法です。

HTML(要素を事前に用意)
<div class="hover-target">
  ここにホバーしてください
  <div class="tooltip hidden">ホバー中!</div>
</div>
CSS(表示切替)
.tooltip { /* ...省略... */ }
.tooltip.hidden { display: none; }
クラスで表示切替
const tip = target.querySelector('.tooltip');

target.addEventListener('mouseenter', () => tip.classList.remove('hidden'));
target.addEventListener('mouseleave', () => tip.classList.add('hidden'));
どれを選ぶか:動的なコンテンツが必要な場合(ホバー対象に応じて内容が変わる)は createElement アプローチ、固定コンテンツなら CSSクラス切替が最もシンプルかつ高パフォーマンスです。

フェードイン・フェードアウトアニメーション

要素をいきなり表示・非表示にするのではなく、フェードイン/アウトでなめらかにするとUXが向上します。CSSの transitionopacity を組み合わせるのが最もシンプルな実装です。

CSS(フェード用)
.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 });
});
requestAnimationFrame が必要な理由:DOMに追加した直後にクラスを付けると、ブラウザがレイアウトを確定する前に終端スタイルが設定されてしまい、初期値との変化を検知できずtransitionが無視されます。1フレーム待つことでブラウザがスタイルを確定させ、transitionが正常に動作します。環境によってrAF1回では不安定な場合は、element.getBoundingClientRect() で強制リフローを起こす方法も確実です。
より安定したフェードイン(強制リフロー方式)
const tip = document.createElement('div');
tip.className = 'tooltip';
tip.textContent = 'ホバー中!';
target.appendChild(tip);
tip.getBoundingClientRect(); // 強制リフローでスタイルを確定
tip.classList.add('visible'); // transitionが確実に動作する

実践パターン集

ツールチップ(Tooltip)

data属性でツールチップのテキストを管理し、複数要素に一括適用するパターンです。

HTML
<button data-tooltip="保存します">保存</button>
<button data-tooltip="削除します(元に戻せません)">削除</button>
CSS
[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サイトや一覧画面でよく使われます。

HTML
<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 });
  });
});

マウス追従エフェクト(スパークル)

マウスが通過した位置に小さなパーティクルを生成するエフェクトです。ランディングページやポートフォリオサイトのアクセント演出に使われます。

CSS(パーティクル)
.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 });
});
パフォーマンス注意:mousemove は非常に高頻度で発火します。スパークルエフェクトをすべての mousemove で生成するとCPUに負担がかかります。必要に応じて throttle 処理(一定間隔でのみ実行)を挟んでください。

Event Delegation で動的追加要素にも対応する

JavaScriptで後から追加した要素には、ページ読み込み時に設定した addEventListener は効きません。そのような場合は Event Delegation(イベント委譲) を使って、親要素でイベントを一括管理します。

HTML(動的に追加されるリスト)
<ul id="dynamic-list">
  <li>既存アイテム1</li>
  <li>既存アイテム2</li>
</ul>
<button id="add-item">アイテムを追加</button>
注意:mouseenter/mouseleaveとEvent Delegation
mouseenter / mouseleave はバブリングしないため、通常の Event Delegation には使えません。実務では mouseover / mouseout + event.target.closest() の組み合わせが最もシンプルで安全です。
Event Delegation でホバー対応(実務推奨パターン)
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 の落とし穴: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 } で自動解除する
DocumentFragment で複数要素を一括追加
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回だけ
});

よくある質問

Qmouseover と mouseenter のどちらを使えばいいですか?
A要素を追加・削除する用途では、原則 mouseenter / mouseleave を使います。mouseover / mouseout は子要素への移動でも発火するため、要素が意図せず何度も追加されるバグの原因になります。
Qホバー中に同じ要素が何個も追加されてしまいます。
A追加前に querySelector で既存要素の有無を確認するか、フラグ変数で制御してください。あるいは要素を毎回生成せず、CSSクラスの追加/削除で表示を切り替える方法が最も確実です。
Qフェードアウト後に要素を削除するにはどうしますか?
Atransitionend イベントを使います。opacity: 0 への transition が完了したタイミングで remove() を呼ぶことで、アニメーション完了後に要素を削除できます。{ once: true } を指定するとリスナーが自動解除されてメモリリークを防げます。
Q後から動的に追加した要素にもホバーイベントを付けるには?
AEvent Delegation を使います。動的要素の親要素にイベントを設定し、e.target で対象を絞り込みます。mouseenter / mouseleave はバブリングしないため、キャプチャフェーズ({ capture: true })を使うか mouseover / mouseout を選択してください。
Qスマートフォンでホバー効果が動作しません。
Aタッチデバイスにはホバーがありません。touchstart / touchend イベントを併用することで同等の体験を提供できます。また CSS の @media (hover: hover) メディアクエリを使うと、ホバーが使える環境のみにCSS効果を限定できます。

まとめ

JavaScriptでマウスオーバーを起点に要素を動的に追加・削除する実装のポイントをまとめます。

  • イベントには mouseenter / mouseleave を使う(mouseover は多重発火の原因)
  • 重複追加は存在チェック・フラグ・CSSクラス切替の3パターンで防ぐ
  • フェードアニメーションは requestAnimationFrame + transition + transitionend の組み合わせ
  • 後から追加した要素への対応は Event Delegation(キャプチャフェーズ)
  • タッチデバイスには touchstart / touchend を併用する

classList の操作については【JavaScript】classListの使い方完全ガイドも参考にしてください。