WordPressの wp_nav_menu() で出力されるカスタムメニューには、サブメニューを持つ項目に .menu-item-has-children クラスが自動的に付与されます。このサブメニューをアコーディオン形式で開閉できるようにするJavaScriptを紹介します。
特徴
- クリック型(
.clickdion)とホバー型(.hoverdion)の2種類に対応 - CSSクラスを付けるだけで動作
- Web Animations APIによる滑らかな開閉アニメーション
- 連打防止機能付き
- WordPressメニュー以外の汎用的な構造にも対応
設計のポイント:クラスは <li> に付ける
当初は親の <ul> にクラスを付ける設計でしたが、これだと1つのメニュー項目をホバーしたときに主要なサブメニューが開いてしまう問題が発生しました。
<!-- ❌ 問題のある設計:<ul> にクラス -->
<ul class="menu hoverdion is-opened">
<li class="menu-item-has-children">
<ul class="sub-menu">...</ul> <!-- 全部開く -->
</li>
<li class="menu-item-has-children">
<ul class="sub-menu">...</ul> <!-- 全部開く -->
</li>
</ul>
解決策として、各 <li> にクラスを付ける設計に変更しました。
<!-- ✅ 正しい設計:<li> にクラス -->
<ul class="menu">
<li class="menu-item-has-children hoverdion">
<ul class="sub-menu">...</ul> <!-- この項目だけ開く -->
</li>
<li class="menu-item-has-children hoverdion">
<ul class="sub-menu">...</ul> <!-- 独立して制御 -->
</li>
</ul>
この設計なら各メニュー項目が独立して開閉でき、将来的にネストしたサブメニュー(孫メニュー)にも自然に対応できます。
使い方
WordPressメニューでの設定
WordPress管理画面の「外観」→「メニュー」で、サブメニューを持つ項目の「CSS class」欄に hoverdion または clickdion を追加します。
※「CSS class」欄が表示されていない場合は、メニュー編集画面右上の「表示オプション」から「CSSクラス」にチェックを入れてください。
HTML構造
WordPressメニュー(ホバー型)
<ul class="menu">
<li class="menu-item menu-item-has-children hoverdion">
<a href="#">BLOG</a>
<ul class="sub-menu">
<li class="menu-item"><a href="#">カテゴリー1</a></li>
<li class="menu-item"><a href="#">カテゴリー2</a></li>
</ul>
</li>
</ul>
WordPressメニュー(クリック型・スイッチ付き)
<ul class="menu">
<li class="menu-item menu-item-has-children clickdion">
<a href="#">SNIPPET<span class="dion-switch"></span></a>
<ul class="sub-menu">
<li class="menu-item"><a href="#">項目1</a></li>
<li class="menu-item"><a href="#">項目2</a></li>
</ul>
</li>
</ul>
汎用的な構造(dl/dt/dd など)
<dl class="dion-details clickdion">
<dt class="dion-summary">
タイトル<span class="dion-switch"></span>
</dt>
<dd class="dion-content">
開閉するコンテンツ
</dd>
</dl>
JavaScript コード
(function () {
/**
* _classify-accordion.js v2.0
*
* クリック型(.clickdion)とホバー型(.hoverdion)のアコーディオン
* 各 <li> または .dion-details の親要素にクラスを付与して使用
*/
// ===========================================
// CSS スタイル注入
// ===========================================
const STYLE = document.createElement("style");
STYLE.textContent = `
/* コンテンツ部分の基本設定 */
:is(.clickdion, .hoverdion) > :is(.dion-content, .sub-menu) {
overflow: hidden;
transition: height 0.4s ease-out;
}
/* 閉じた状態 */
:is(.clickdion, .hoverdion):not(.is-opened) > :is(.dion-content, .sub-menu) {
height: 0 !important;
min-height: 0;
padding-block: 0;
border-block-width: 0;
}
/* リンク内のスイッチがある場合、リンク自体は無効化 */
:is(.clickdion, .hoverdion) > a:has(.dion-switch) {
pointer-events: none;
cursor: default;
}
/* スイッチ自体はクリック可能 */
:is(.clickdion, .hoverdion) .dion-switch {
pointer-events: auto;
cursor: pointer;
}
/* トリガー部分の配置 */
:is(.clickdion, .hoverdion) > :is(.dion-summary, a) {
position: relative;
}
/* スイッチ・ステータスアイコンの共通配置 */
:is(.clickdion, .hoverdion) > :is(.dion-summary, a) :is(.dion-switch, .dion-status) {
position: absolute;
top: 0;
bottom: 0;
right: 0;
margin: auto;
display: flex;
justify-content: center;
align-items: center;
width: 44px;
height: 44px;
}
:is(.clickdion, .hoverdion) > :is(.dion-summary, a) :is(.dion-switch, .dion-status)::before {
display: flex;
justify-content: center;
align-items: center;
font-size: 133.3%;
font-family: "Material Symbols Sharp";
font-variation-settings: "FILL" 0, "wght" 200;
}
/* .dion-switch アイコン(矢印) */
:is(.clickdion, .hoverdion) > :is(.dion-summary, a) .dion-switch::before {
content: "\\e313";
transition: rotate 0.3s ease-out;
}
:is(.clickdion, .hoverdion).is-opened > :is(.dion-summary, a) .dion-switch::before {
rotate: -180deg;
}
/* .dion-status アイコン(+/-) */
:is(.clickdion, .hoverdion) > :is(.dion-summary, a) .dion-status::before {
content: "\\e145";
}
:is(.clickdion, .hoverdion).is-opened > :is(.dion-summary, a) .dion-status::before {
content: "\\e15b";
}
`;
document.head.appendChild(STYLE);
// ===========================================
// ユーティリティ関数
// ===========================================
const isAnimating = (element) => element.dataset.anim === "action";
const isOpened = (element) => element.classList.contains("is-opened");
const getContent = (element) => {
return element.querySelector(":scope > .sub-menu, :scope > .dion-content");
};
const getTrigger = (element) => {
return element.querySelector(":scope > a, :scope > .dion-summary");
};
// ===========================================
// 初期化
// ===========================================
const initAccordions = () => {
const clickdions = document.querySelectorAll(".clickdion");
const hoverdions = document.querySelectorAll(".hoverdion");
clickdions.forEach(setupClickdion);
hoverdions.forEach(setupHoverdion);
console.log(
"🟢 classify-accordion.js v2.0: 初期化完了!",
"clickdion =", clickdions.length,
", hoverdion =", hoverdions.length
);
};
// ===========================================
// クリック型アコーディオン
// ===========================================
const setupClickdion = (element) => {
const trigger = getTrigger(element);
const content = getContent(element);
const dionSwitch = element.querySelector(".dion-switch");
if (!trigger || !content) return;
const clickTarget = dionSwitch || trigger;
clickTarget.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// 連打防止
if (isAnimating(element)) return;
toggleAccordion(element, content);
});
};
// ===========================================
// ホバー型アコーディオン
// ===========================================
const setupHoverdion = (element) => {
const trigger = getTrigger(element);
const content = getContent(element);
if (!trigger || !content) return;
let hoverTimeout = null;
const clearHoverTimeout = () => {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
};
const handleOpen = () => {
clearHoverTimeout();
if (!isOpened(element)) {
openAccordion(element, content, true);
}
};
const handleCloseWithDelay = () => {
hoverTimeout = setTimeout(() => {
if (isOpened(element)) {
closeAccordion(element, content, true);
}
}, 100);
};
// トリガーのイベント
trigger.addEventListener("mouseenter", handleOpen);
trigger.addEventListener("focus", handleOpen);
trigger.addEventListener("mouseleave", handleCloseWithDelay);
// コンテンツのイベント
content.addEventListener("mouseenter", clearHoverTimeout);
content.addEventListener("mouseleave", handleCloseWithDelay);
// フォーカスが外れた時
element.addEventListener("focusout", (e) => {
if (!element.contains(e.relatedTarget)) {
clearHoverTimeout();
if (isOpened(element)) {
closeAccordion(element, content, true);
}
}
});
};
// ===========================================
// 開閉処理
// ===========================================
const toggleAccordion = (element, content) => {
if (isOpened(element)) {
closeAccordion(element, content);
} else {
openAccordion(element, content);
}
};
const openAccordion = (element, content, force = false) => {
if (force && isAnimating(element)) {
cancelAnimation(element);
}
element.classList.add("is-opened");
animateContent(content, 0, content.scrollHeight, element);
};
const closeAccordion = (element, content, force = false) => {
if (force && isAnimating(element)) {
cancelAnimation(element);
}
animateContent(content, content.scrollHeight, 0, element, () => {
element.classList.remove("is-opened");
});
};
// ===========================================
// アニメーション
// ===========================================
const animateContent = (content, fromHeight, toHeight, element, onComplete) => {
element.dataset.anim = "action";
const animation = content.animate(
[{ height: fromHeight + "px" }, { height: toHeight + "px" }],
{ duration: 400, easing: "ease-out", fill: "forwards" }
);
element._currentAnimation = animation;
animation.onfinish = () => {
element.dataset.anim = "";
element._currentAnimation = null;
content.style.height = toHeight === 0 ? "" : "auto";
onComplete?.();
};
animation.oncancel = () => {
element.dataset.anim = "";
element._currentAnimation = null;
};
};
const cancelAnimation = (element) => {
if (element._currentAnimation) {
element._currentAnimation.cancel();
element._currentAnimation = null;
}
};
// ===========================================
// 実行
// ===========================================
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAccordions);
} else {
initAccordions();
}
})();
コード解説
連打防止の仕組み
クリック型では、アニメーション中に data-anim="action" を設定し、完了までクリックを無視します。
if (isAnimating(element)) return; // 連打防止
ホバー型の遅延処理
親リンクからサブメニューへマウスを移動する際、一瞬親の :hover が外れます。即座に閉じると操作しづらいため、100msの遅延を設けています。
hoverTimeout = setTimeout(() => {
if (isOpened(element)) {
closeAccordion(element, content, true);
}
}, 100);
サブメニューに入ったらタイマーをクリアすることで、閉じずに済みます。
Web Animations API
scrollHeight で実際のコンテンツ高さを取得し、element.animate() で滑らかにアニメーションします。
content.animate(
[{ height: fromHeight + "px" }, { height: toHeight + "px" }],
{ duration: 400, easing: "ease-out", fill: "forwards" }
);
開いた後は height: auto を設定することで、コンテンツの動的な変化にも対応します。
:scope セレクタ
直下の要素だけを確実に取得するため :scope を使用しています。
element.querySelector(":scope > .sub-menu, :scope > .dion-content");
補足:CSS-onlyで実現する方法
grid-template-rows を使えば、JavaScript なしでもアニメーション付きの開閉が可能です。
.hoverdion {
display: grid;
grid-template-rows: auto 0fr; /* 1行目: リンク, 2行目: サブメニュー */
transition: grid-template-rows 0.4s ease-out;
}
.hoverdion:hover,
.hoverdion:focus-within {
grid-template-rows: auto 1fr;
}
.hoverdion > .sub-menu {
overflow: hidden;
}
ただし、この方法は構造が固定される(1行目がトリガー、2行目がコンテンツ)という制約があります。柔軟な構造が必要な場合は、JavaScript版を使用してください。
このスクリプトはWordPressメニュー以外にも、dl/dt/dd などの汎用的な構造にも対応しているので、様々な場面で活用できます。