Webサイトでよく使われるアコーディオン機能。今回は、クリック時とホバー時の両方に対応し、かつdetailsタグでもWordPressメニューでも使える汎用性の高いアコーディオンを目指しましたにホバー型では素早いマウス操作にも対応します。

完成デモ

Clickdion(クリック型)

  • .openerボタンをクリックで開閉
  • 連打防止機能付き
  • summaryとbutton両方のパターンに対応

Hoverdion(ホバー型)

  • マウスホバーで自動開閉
  • 素早いマウス移動にも即座に反応
  • アニメーション中断機能で快適な操作感

主な特徴

1. detailsタグでスムーズなアニメーション

通常、<details>タグは開閉時にアニメーションが適用されませんが、Web Animations APIを使用することで滑らかな動作を実現しています。

<div class="clickdion">
  <details class="dion-item">
    <summary class="dion-summary">
      クリックして開閉<button class="opener"></button>
    </summary>
    <div class="dion-content">
      <!-- コンテンツ -->
    </div>
  </details>
</div>

2. WordPressメニューにも対応

WordPressの標準的なメニュー構造にも対応。既存のテーマを大きく変更することなく導入できます。

<ul class="hoverdion">
  <li class="menu-item menu-item-has-children">
    <a href="#" class="menu-link">
      親メニュー<button class="opener"></button>
    </a>
    <ul class="sub-menu">
      <li class="menu-item">
        <a href="#" class="menu-link">子メニュー1</a>
      </li>
      <li class="menu-item">
        <a href="#" class="menu-link">子メニュー2</a>
      </li>
    </ul>
  </li>
</ul>

3. 柔軟なトリガー設定

.openerクラスを使い分けることで、様々なUIパターンに対応:

  • summaryに直接.openerを付ける → summary全体がトリガー
  • summary内のbutton.openerを付ける → ボタンのみがトリガー

改良されたホバー機能

従来の問題点

一般的なホバー型アコーディオンでは、以下の問題がありました:

  • 素早いマウス移動で反応が鈍い
  • アニメーション中に操作が無視される
  • 連打防止機能がホバーを阻害

解決策

クリック型とホバー型で異なるアプローチを採用:

// クリック型:連打防止あり
const setupClickAccordion = (element) => {
  opener.addEventListener("click", (e) => {
    // 連打防止
    if (isAnimating(element)) return;
    toggleAccordion(element);
  });
};

// ホバー型:即座に反応、アニメーション中断機能
const setupHoverAccordion = (element) => {
  summary.addEventListener('mouseenter', () => {
    if (!isOpen(element)) {
      // アニメーション中でも強制的に開く
      openAccordion(element, true);
    }
  });
};

アニメーション中断機能

既存のアニメーションを中断して、新しい操作に即座に反応:

const openAccordion = (element, force = false) => {
  // 既存のアニメーションを中断(force時)
  if (force && isAnimating(element)) {
    const existingAnim = element._currentAnimation;
    if (existingAnim) {
      existingAnim.cancel();
    }
  }
  
  // 新しいアニメーション開始
  animAccordionContent(content, 0, content.scrollHeight, element);
};

完全なJavaScript実装

(function() {
// CSS自動注入
const STYLE = document.createElement('style');
STYLE.textContent = `
:is(.clickdion, .hoverdion) :is(
  .dion-item .dion-content,
  .menu-item-has-children .sub-menu) {
  overflow: hidden;
  transition: all 0.3s ease-out;
}

:is(.clickdion, .hoverdion) :is(
  .dion-item:not(.is-open) .dion-content,
  .menu-item-has-children:not(.is-open) .sub-menu) {
  height: 0;
  min-height: 0;
  padding-block: 0;
  border-block-width: 0;
}

/* アイコンスタイル */
.opener::before {
  content: "▼";
  transition: transform 0.3s ease;
}

.is-open .opener::before {
  transform: rotate(-180deg);
}
`;
document.head.appendChild(STYLE);

// 初期化
document.addEventListener("DOMContentLoaded", () => {
  // details要素
  document.querySelectorAll(".clickdion .dion-item").forEach(setupClickAccordion);
  document.querySelectorAll(".hoverdion .dion-item").forEach(setupHoverAccordion);
  
  // メニュー要素
  document.querySelectorAll(".clickdion .menu-item-has-children").forEach(setupClickMenudion);
  document.querySelectorAll(".hoverdion .menu-item-has-children").forEach(setupHoverMenudion);
});

// クリック型設定(連打防止あり)
const setupClickAccordion = (element) => {
  const opener = element.querySelector(".opener");
  const summary = element.querySelector(".dion-summary");
  
  if (!opener) return;

  opener.addEventListener("click", (e) => {
    e.stopPropagation();
    e.preventDefault();
    
    if (isAnimating(element)) return; // 連打防止
    toggleAccordion(element);
  });

  summary?.addEventListener("click", (e) => e.preventDefault());
};

// ホバー型設定(即座に反応)
const setupHoverAccordion = (element) => {
  const summary = element.querySelector(".dion-summary");
  const content = element.querySelector(".dion-content");
  
  if (!summary || !content) return;

  summary.addEventListener("click", (e) => e.preventDefault());

  ['mouseenter', 'focus'].forEach(event => {
    summary.addEventListener(event, () => {
      if (!isOpen(element)) {
        openAccordion(element, true); // 強制実行
      }
    });
  });

  ['mouseleave', 'blur'].forEach(event => {
    element.addEventListener(event, () => {
      if (isOpen(element)) {
        closeAccordion(element, true); // 強制実行
      }
    });
  });
};

// アニメーション実行
const animAccordionContent = (content, fromHeight, toHeight, element, onComplete) => {
  element.dataset.animStatus = "action";
  
  const animation = content.animate([
    { height: fromHeight + 'px' },
    { height: toHeight + 'px' }
  ], {
    duration: 300,
    easing: "ease-out"
  });
  
  element._currentAnimation = animation;
  
  animation.onfinish = () => {
    element.dataset.animStatus = "";
    element._currentAnimation = null;
    onComplete?.();
  };
};

// ユーティリティ関数
const isAnimating = (element) => element.dataset.animStatus === "action";
const isOpen = (element) => element.classList.contains("is-open");

})();

CSS設定のポイント

基本的なアコーディオン構造

/* 閉じた状態 */
.dion-content,
.sub-menu {
  height: 0;
  overflow: hidden;
  padding-block: 0;
  border-block-width: 0;
}

/* 開いた状態 */
.is-open .dion-content,
.is-open .sub-menu {
  height: auto;
  padding-block: initial;
  border-block-width: initial;
}

Material Symbols使用時

.opener::before {
  font-family: "Material Symbols Sharp";
  font-variation-settings: "FILL" 0, "wght" 200;
  content: "\e313"; /* expand_more */
  transition: rotate 0.3s ease;
}

.is-open .opener::before {
  rotate: -180deg;
}

カスタマイズ時のポイント

1. アニメーション速度の調整

// より高速な反応が欲しい場合
duration: 200

// よりゆっくりとした動作が欲しい場合
duration: 500

2. イージングのカスタマイズ

// 自然な動作
easing: "ease-out"

// バウンド効果
easing: "cubic-bezier(0.68, -0.55, 0.265, 1.55)"

// リニア
easing: "linear"

3. 排他制御の追加

同時に開けるアコーディオンを1つに制限:

const openAccordion = (element, force = false) => {
  // 同じ親要素内の他のアコーディオンを閉じる
  const parent = element.closest('.accordion-group');
  if (parent) {
    parent.querySelectorAll('.is-open').forEach(sibling => {
      if (sibling !== element) {
        closeAccordion(sibling, true);
      }
    });
  }
  
  // 通常の開く処理...
};

4. WordPressテーマでの実装

// functions.phpに追加
function enqueue_accordion_script() {
    wp_enqueue_script(
        'accordion-js',
        get_template_directory_uri() . '/js/accordion.js',
        array(),
        '1.0.0',
        true
    );
}
add_action('wp_enqueue_scripts', 'enqueue_accordion_script');

// メニューにクラスを追加
function add_accordion_classes($classes, $item, $args) {
    if (in_array('menu-item-has-children', $classes)) {
        $classes[] = 'hoverdion'; // または 'clickdion'
    }
    return $classes;
}
add_filter('nav_menu_css_class', 'add_accordion_classes', 1, 3);

パフォーマンス最適化

1. CSS containment

.dion-content,
.sub-menu {
  contain: layout style paint;
}

2. will-change プロパティ

.dion-content,
.sub-menu {
  will-change: height;
}

/* アニメーション完了後は削除 */
.dion-content:not(.animating),
.sub-menu:not(.animating) {
  will-change: auto;
}

3. アニメーションの最適化

// アニメーション開始時
content.style.willChange = 'height';

// アニメーション完了時
animation.onfinish = () => {
  content.style.willChange = 'auto';
  // その他の処理...
};

トラブルシューティング

よくある問題と解決策

Q: ホバー時に素早く移動すると反応しない
A: 本実装ではforceパラメータでアニメーション中断機能を実装済み

Q: Material Symbolsが表示されない
A: フォントの読み込みを確認:

<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Sharp" rel="stylesheet">

Q: WordPressでメニューが動作しない
A: メニュー構造とクラス名を確認。必要に応じてPHPでクラスを追加

まとめ

今回作成したアコーディオンの特徴:

  • 用途別最適化:クリック型は安定性、ホバー型は反応速度を重視
  • アニメーション中断:素早い操作にも即座に対応
  • 汎用性:detailsタグとWordPressメニューの両方に対応
  • 自動CSS注入:JavaScriptファイル1つで完結
  • カスタマイズ性:豊富な設定オプション

様々な要件に対応できるよう柔軟性を重視、特にWordPressの標準メニュー構造でホバーでもクリックでも使えるアコーディオンメニューです。