アコーディオンメニューを jQuery なしで : CSS をベースにミニマル Vanilla JS で実装

アコーディオンメニューを jQuery なしで : CSS をベースにミニマル Vanilla JS で実装

たくさんの製品をカテゴリーごとにまとめたり、FAQで詳しい回答をたたんでおいたり、項目を整理して見せるためのアコーディオンメニュー。その実装には、jQuery ライブラリの slideToggle() メソッドがよく使われます。

ですが、jQuery の使用により、パフォーマンス低下や他のフレームワークとの競合が生じることがあります。今回は jQuery に依存せず、CSS をベースに最小限の純粋な JavaScript で実装してみます。

サンプルコード

HTML

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>accordion sample</title>
  </head>
  <body>
    <h1>accordion sample</h1>
    <dl id="accordion">
      <div>
        <dt>[Q1]存在するのに犬(居ぬ)とはこれいかに</dt>
        <dd>[A1]近寄ってきても猿(去る)と呼ぶが如し</dd>
      </div>
      <div>
        <dt>[Q2]回答が長くても大丈夫ですか?</dt>
        <dd>[A2]寿限無、寿限無、五劫のすりきれ、海砂利水魚の、水行末・雲来末・風来末、食う寝るところに住むところ、やぶらこうじのぶらこうじ、パイポ・パイポ・パイポのシューリンガン、シューリンガンのグーリンダイ、グーリンダイのポンポコピーのポンポコナの、長久命の長助</dd>
      </div>
      <div>
        <dt>[Q3]ビューポート幅が変わってテキストが折り返されても大丈夫ですか?</dt>
        <dd>[A3]安心してください。QやAの高さが変わるとCSS変数が書き換えられます。お試しください。</dd>
      </div>
    </dl>
  </body>
</html>

CSS

@charset "UTF-8";

#accordion {
  margin: 0;
}
#accordion > div {
  --term-height: 3.5em;
  --group-height: 100dvh;
  display: flex;
  flex-direction: column;
  position: relative;
  z-index: 0;
  overflow: hidden;
  max-height: var(--group-height);
  transition: 0.3s max-height ease-out;
}
#accordion > div:not(.open) {
  max-height: var(--term-height);
}
#accordion dt,
#accordion dd {
  padding: 1em;
  line-height: 1.5;
}
#accordion dt {
  cursor: pointer;
  background-color: beige;
}
#accordion dd {
  margin: 0;
  position: relative;
  z-index: -10;
  transition: 0.3s transform ease-out;
}
#accordion > div:not(.open) dd {
  transform: translateY(-100%);
}

JavaScript

"use strict";

document.addEventListener("DOMContentLoaded", () => {
  // accordion
  const term = document.querySelectorAll("#accordion dt");
  term.forEach((e) => {
    const setData = () => {
      const termHeight = e.getBoundingClientRect().height;
      const groupHeight = e.parentElement.scrollHeight;
      e.parentElement.style.setProperty("--term-height", `${termHeight}px`);
      e.parentElement.style.setProperty("--group-height", `${groupHeight}px`);
    };
    setData();
    e.addEventListener("click", () => {
      setData();
      e.parentElement.classList.toggle("open");
    });
    const resizeObserver = new ResizeObserver((entries) => {
      for (const entry of entries) {
        if (entry.contentRect.height !== entry.target.oldHeight) {
          entry.target.oldHeight = entry.contentRect.height;
          setData();
        }
      }
    });
    e.oldHeight = e.getBoundingClientRect().height;
    e.parentElement.oldHeight = e.parentElement.scrollHeight;
    resizeObserver.observe(e);
    resizeObserver.observe(e.parentElement);
  });
  // end of accordion
});

サンプル DEMO

Question をクリック(タップ)すると、対応する Answer が下にスライドして表示されます。

[Q1]存在するのに犬(居ぬ)とはこれいかに
[A1]近寄ってきても猿(去る)と呼ぶが如し
[Q2]回答が長くても大丈夫ですか?
[A2]寿限無、寿限無、五劫のすりきれ、海砂利水魚の、水行末・雲来末・風来末、食う寝るところに住むところ、やぶらこうじのぶらこうじ、パイポ・パイポ・パイポのシューリンガン、シューリンガンのグーリンダイ、グーリンダイのポンポコピーのポンポコナの、長久命の長助
[Q3]ビューポート幅が変わってテキストが折り返されても大丈夫ですか?
[A3]安心してください。QやAの高さが変わるとCSS変数が書き換えられます。お試しください。

コード解説

HTMLでは、<dl> 要素で記述し、id 属性に "accordion" を設定します。<dt> と <dd> のセットは <div> でグループ化します。<dd> は <dt> に対して1個だけにしてください。
以降解説する CSS と JavaScript は、外部ファイルにして読み込むなり、インラインで記述するなりします。

次に CSS では、35行目で <dd> 要素を上に100%ずらしています。transform プロパティを使用しているので、元の位置の高さはそのまま維持されます。class 属性に "open" が追加されると元の位置に戻ります。
兄要素の <dt> の後ろに隠したいので、11-12行目で親要素の <div> を位置基準にした上で、31-32行目で重ね順をネガティブにしています。このままだと <dt> の後ろに <dd> が重なって見えてしまうため、27行目で前面の <dt> に背景色を設定しています。溢れは13行目で隠しています。

ここからがポイントですが、7-8行目で CSS変数を設定しています。<dt> 要素の高さを "--term-height" 、<div> 要素の高さを "--group-height" として、それぞれ初期値を指定しています。実際には次で解説するJavaScript で上書きするので、ここで宣言しておく必要はありません。
そして14行目で <div> の max-height に "--group-height" を、class 属性に "open" が追加されていない場合には18行目で "--term-height" を指定しています。つまり、<div> は(<dd> のない) <dt> だけの高さになり、 "open" class が追加されると <dd> のある本来の高さに戻るわけです。

最後に JavaScript ですが、CSS の解説で登場した "open" class の追加と、CSS変数 "--group-height" と "--term-height" の上書きだけをしています。
7-12行目の関数で <div>(<dt> の親要素)の style 属性にCSS変数を追加しています。<td> 要素の高さを取得するために getBoundingClientRect().height を使用しています。offsetHeight だと小数点以下を取得できませんので、閉じた時に隙間が生じます。<div> 要素の高さの取得には scrollHeight を使用しています。溢れた <dd> を含んだ要素の内容の高さを取得するためです。
この関数を、13行目で DOM構築完了時、14行目で <dt> 要素クリック時、22行目で <div> または <dt> の高さが変わった時に実行しています。28-29行目は、特定要素のリサイズを監視する resizeObserver です。なかなか使用する機会がありませんが、window の resize イベントリスナーよりも効率がよいでしょう。

おわりに

かなり以前に、アコーディオンメニューを CSS だけでできないものかと試行錯誤したことがあったのですが、結局のところ、実際の高さを取得できなければキレイにはできませんでした(なんとなくならできます)。

今なら CSS変数を使えるようになったし、最小限の JavaScript で実装できるのではないかと思い立って、CSSベースでなるべくシンプルになるように考えてみました。

改善点などありましたら、ご指摘いただければうれしいです。Twitter(現 𝕏)は @uncl010 まで(あまり公私分けてないですが)。
それではまた、Happy coding !