JavaScript

素のJavaScriptでアコーディオンメニューを作る(クリックで開閉・jQueryなし・WAI-ARIA対応)

クリックで開閉するアコーディオンメニューを素のJavaScript(Vanilla JS)で制作する方法を解説します。ここでご紹介する内容は、他のサイトで紹介されているコートだと達成したい仕様が満たされなかったり、実装の仕方が悪いのか挙動がおかしかったりしたので、自作したものです。
使いまわしもしやすいコードで使いやすいものになっていると思いますので、ぜひご活用ください。
またウェブアクセシビリティのためのWAI-ARIAも対応しています。

実装結果デモ

まずは実装した結果をご確認ください。
後述する仕様のうち、アコーディオンを開いた時にスクロールを許容する部分はスクロールバーが存在するCODEPENだと表現が難しかったので、その点は別途ご自分でご確認いただければと思います。

See the Pen Accordion by Takahiro Inada (@tkhr1) on CodePen.

余談

次の画像は前のアイキャッチ画像で、MicrosoftのClarityというサービスのヒートマップです。色がついている箇所がクリックされていることを示しています。このキャプチャ撮る前に画像を変えてしまったので、合成で再現してますが、大体こんな感じだったはずです。いや、みなさんせっかちすぎでしょ!クリックできそうですか??
ってことで、↑のデモはもう少し下に置いてたんですが、申し訳ない気持ちになりまして、順番を変えました。

アコーディオンメニューとは?

アコーディオンメニューは、クリックで開閉し、開いた時だけ中のコンテンツを表示させるUIのことを指します。

アコーディオンメニューのメリットは?

ページ内に多くの情報が表示されていると、どの情報に注目したらよいのかが分かりにくくなります。ユーザーがより詳しく欲しいと思う情報を取捨選択できるようになるというメリットがあると言えます。

表示領域上の問題、つまりデザインのために用いるケースもあると思います。

アコーディオンのデメリットは?

開かないと表示されない情報は、ユーザーが開かない限りは気付かれないわけなので、その点がデメリットと言えます。そのことも踏まえ、クリックする部分に記載するメニュー名を分かりやすくし、そのメニューを見たいと思う人は気付きやすくなるような見た目を考えましょう。

クリッカブルな箇所は、開閉アイコン部分だけではなく、バー全体にしておく方が望ましいです。理由は上のヒートマップの画像を見ていただければわかるかと思います。

また、詳しくは後述しますが開かないと表示されない内容は、表示されてない間は非表示としてGoogleに伝わりますので、SEO対策として重要なコンテンツである場合は、アコーディオンメニューとせずに最初から表示しておいた方が望ましいと考えています。

素のJavaScriptでアコーディオンメニューを制作する意義

アコーディオンメニューはjQueryを使うともっと簡単に作成できます。ただ、ウェブサイトでjQueryを使うと簡単になるものというのは、逆に言えばアコーディオンメニューぐらいだと思います。

jQueryが広く使われてきた背景には、標準仕様のサポートが疎かだったIEのためだけに工夫しなければならないコーディングというのが多かったということがあります。jQueryはIEでも動く、という点で素晴らしいライブラリでした。
しかし、IEのサポートを考えなくて良くなり、Vanilla JSも進化している今となってはお役御免である状況であり、いつまでjQueryのサポートが続くかわかりません。

jQueryはそれなりの重さがあるので、用いればパフォーマンスへの影響は無視できないですし、実際にGoogleの評価ツールLigighthouseで評価するとかなり点数に違いが出ます。

素のJavaScriptで書く、ということに慣れた方が良いと考えます。

実装したアコーディオンメニューの仕様

達成したかった(実装した)仕様は以下の通りです。

  1. 1画面内に複数のアコーディオンメニューを配置可能
  2. 1つ開いたら、他は閉じる
  3. スムーズに開閉するアニメーション
  4. 開いた時の高さは指定しなくても自動的に取得できるようにする
  5. 開いた時にスクロールを許容する
  6. 開閉のためのクリッカブルな箇所は<button>で実装する

実装したアコーディオンメニュー仕様の理由

1画面内に複数のアコーディオンメニューを配置

上のデモのように、同一画面内に2か所のアコーディオンメニューがあっても動くようにしています。

1つ開いたら、他は閉じる

複数開いたままにできる仕様と、1つ開いたら他は閉じる仕様の両方が存在すると思います。両方開いて見比べたいとか、場合によっては前者の仕様の方が望ましいこともあるかもしれませんが、少なくともこのサイトのアコーディオンメニューは後者にした方が見やすいだろうと私は考えました。
ただ後者の方は「1つでも開いたら他は閉じる」ということをするわけなので、記録しておくべき情報が1種類増えて、実装難度は上がります。

スムーズに開閉するアニメーション

これはもはや当然ですが、スムーズに開閉するアニメーションを実装しています。また、開閉時にアイコンに変化をつけるということもしています。

開いた時の高さは自動的に取得できるようにする

アコーディオンメニューは、heightを0にすることで閉じ、開く分の高さを指定して開きます。CSSやJavaScriptのアニメーションでは、height: 0からheight: autoは変化がつかないので、開閉時のアニメーションを実装するには、開いた時の高さは指定する必要があります。あらかじめ高さを把握して、指定してしまう方法もありますが、それだと汎用性がありません。
(ちなみに、その方法であればJavaScriptを用いずにチェックボックスとCSSアニメーションだけを用いて実装する方法もありましたが、iOSでの表示がおかしかったのもあり、やめています)

開いた時にスクロールを許容する

スマートフォンのハンバーガーメニューで、アコーディオンメニューを開いたら縦の表示領域が足りなくなるため、スクロールを許容する必要がありました。

開閉のためのクリッカブルな箇所はbuttonタグで実装する

ボタンを<div><span>で実装する方法も見かけますが、以下2つの理由で、アクセシビリティのため避けた方がよいです。

1)キーボード操作できなくなり得る

<div><span>ではtabIndexをつけない限りキーボード操作ができません。tabIndexをつけたとしても、Enterボタンで押下できるようにするには、keyupイベントハンドラを実装する必要もあります。

2)スクリーンリーダーによる解釈が難しくなる

スクリーンリーダー<button>はボタンであると把握して、その旨をユーザーに伝えます。しかし<div>はどうでしょうか。試しに現時点でどうであるかNVDAで試したところ、tabIndexをつけて、onClick属性をつけてクリック可能にした部分は、「ボタン、クリック可能です」と読み上げました。まあよくできたスクリーンリーダーですが、全てのスクリーンリーダーがそうであるとは限りません。

以上のようなことから、やはりHTMLの仕様に基づき、押せる部分は<button>で実装すべきです。先述のチェックボックスを用いるという方法もありますが、チェックボックスにチェックを入れたらメニューが表示される、という仕様があまり一般的ともいえないので、その意味でもJavaScriptを用いて<button>で実装することにしました。

なお、WAI-ARIAではボタンの役割を示すためにrole="button“という仕様がありますが、HTMLが使えるものについては、HTMLを使うべきとされているので、やはりここはbuttonタグを使うべきです。やむを得ない場合がもしあれば<div role="button">のように書く、というのがWAI-ARIAで用意されているものです。<button role="button">のように書くのは仕様に沿ってません。

アコーディオンメニュー実装内容の説明

HTML

<ul class="include-accordion scroll-control">
  <li>
    <button class="accordionBtn" type="button">Menu-1</button>
    <ul>
      <li>List 1-1</li>
      <li>List 1-2</li>
      <li>List 1-3</li>
    </ul>
  </li>
  <li>
    <button class="accordionBtn" type="button">Menu-2</button>
    <ul>
      <li>List 2-1</li>
      <li>List 2-2</li>
      <li>List 2-3</li>
    </ul>
  </li>
  <li>
    <button class="accordionBtn" type="button">Menu-3</button>
    <ul>
      <li>List 3-1</li>
      <li>List 3-2</li>
      <li>List 3-3</li>
    </ul>
  </li>
</ul>
  
<ul class="include-accordion scroll-control">
  <li>
    <button class="accordionBtn" type="button">Menu-1</button>
    <ul>
      <li>List 1-1</li>
      <li>List 1-2</li>
      <li>List 1-3</li>
    </ul>
  </li>
  <li>
    <button class="accordionBtn" type="button">Menu-2</button>
    <ul>
      <li>List 2-1</li>
      <li>List 2-2</li>
      <li>List 2-3</li>
    </ul>
  </li>
  <li>
    <button class="accordionBtn" type="button">Menu-3</button>
    <ul>
      <li>List 3-1</li>
      <li>List 3-2</li>
      <li>List 3-3</li>
    </ul>
  </li>
</ul>
  • アコーディオンメニューがある一番外の<ul>include-accordionのクラス名をつけます
    (1行目、28行目)
  • アコーディオンが開いた時にスクロールを許容したい要素にscroll-controlのクラス名をつけます
    (1行目、28行目)
  • アコーディオン開閉のためのクリック箇所となる<button>accordionBtnのクラス名をつけます
    (3行目、11行目、19行目、30行目、38行目)

クラス名は好きに変えて大丈夫です。
また、<ul>である必要はなく、例えば<div>でも同様に動きます。

CSS

ul{
  background-color: #35924A;
  width: 150px;
  padding: 0;
  color: #fff;
  float: left;
  margin-left:30px;
}
li{
  list-style: none;
}
.include-accordion ul{
  height: 0;
  padding: 0;
  overflow: hidden;
  transition: .5s;
  border-top: 1px solid #67a863;
  background-color: #5EAA6C;
  margin:0;
}
.include-accordion li li{
  border-bottom: 1px dotted #7FBF8B;
  padding: 10px 0 10px 10px;
  margin-left:15px;
}
ul:nth-of-type(1) li.active li:last-child{
  border-bottom:1px solid #67a863; 
}
button{
  position: relative;
  border: none;
  width: 100%;
  background-color: inherit;
  color: #fff;
  cursor: pointer;
  text-align: left;
  padding: 15px 0 15px 20px;
  font-size:1em;
}
button:hover{
  background-color: #1A5B27;
}
button::before,
button::after{
  content:"";
  position: absolute;
  top: 50%;
  width: 1.5px;
  height: 8px;
  background-color: #fff;
  transition: .5s;
}
button::before{
  transform: translateY(-50%) rotate(-45deg);
  right: 35px;
}
button::after{
  transform: translateY(-50%) rotate(45deg);
  right: 30px;
}
li.active button::before{
  transform: translateY(-50%) rotate(-135deg);
  transition:.5s;
}
li.active button::after{
  transform: translateY(-50%) rotate(135deg);
  transition:.5s;
}
ul:nth-of-type(2){ background-color: #357D87; }
ul:nth-of-type(2) ul{ background-color: #519FA5; border-top: 1px solid #5D9FA8; }
ul:nth-of-type(2) button:hover{ background-color: #1C4B56; }
ul:nth-of-type(2) li li{ border-bottom: 1px dotted #73BEBF; }
ul:nth-of-type(2) li.active li:last-child{ border-bottom:1px solid #5D9FA8; }

ul.active{ overflow-y: auto; }
  • 開閉する部分の初期の高さを0にして表示されないようにします(13行目)
  • overflow: hiddenで開閉する部分が表示された際にはみ出した分が表示されないようにします(15行目)
  • 閉じたときのアニメーションをCSSのtransitionでつけます(16行目)
  • 開閉のアイコンを疑似要素で作り、CSSのアニメーションで動かします(43~68行目)
    ※この部分は疑似要素でなくても問題はなく、自由に作って大丈夫です
  • 開いているときのulに対してスクロール許可します(75行目)

関係する部分は上記の通りで、あとは見た目、装飾のための記述です。

JavaScript

<script>
// 要素を表示する関数
const slideDown = function(el) {
  el.style.height = 'auto'; //いったんautoに
  let h = el.offsetHeight;  //autoにした要素から高さを取得
  el.animate({ // 高さ0から取得した高さまでのアニメーション
    height: [ 0, h + 'px' ]
  }, {
    duration: 300, // アニメーションの時間
   });
   el.style.height = 'auto';  //ブラウザの表示幅を途中で閲覧者が変えた時を考慮してautoに戻す
   el.setAttribute('aria-hidden', 'false');  //WAI-ARIA対応、閉じた状態であることを支援技術に伝える
};

// 要素を非表示にする関数
const slideUp = function(el) {
  let h = el.offsetHeight;
  el.style.height = h + 'px';
  el.animate({
    height: [ h + 'px', 0]
  }, {
    duration: 300,
   });
   el.style.height = 0;
   el.setAttribute('aria-hidden', 'true');  //WAI-ARIA対応、開いた状態であることを支援技術に伝える
};

let activeIndex = null;//開いているアコーディオン

//アコーディオンコンテナ全てで実行
const accordions = document.querySelectorAll('.include-accordion');
accordions.forEach(function (accordion) {

  //アコーディオンボタン全てで実行
  const accordionBtns = accordion.querySelectorAll('.accordionBtn');
  accordionBtns.forEach( function(accordionBtn, index) {
    accordionBtn.addEventListener('click', function(e) {
      activeIndex = index; //クリックされたボタンを把握
      e.currentTarget.parentNode.classList.toggle('active'); //ボタンの親要素(ul>li)にクラスを付与/削除
      accordionBtn.setAttribute('aria-expanded', isActive ? 'true' : 'false'); //WAI-ARIA対応、開いた状態かどうかを示す
      const content = e.currentTarget.nextElementSibling; //ボタンの次の要素を取得
      if (e.currentTarget.parentNode.classList.contains('active')) {
         slideDown(content); //クラス名がactive(=閉じていた)なら上記で定義した開く関数を実行
      }else{
         slideUp(content); //クラス名にactiveがない(=開いていた)なら上記で定義した閉じる関数を実行
      }
      accordionBtns.forEach( function(accordionBtn, index) {
        if (activeIndex !== index) {
          e.currentTarget.parentNode.classList.remove('active');
          e.currentTarget.setAttribute('aria-expanded', 'false'); //WAI-ARIA対応、開いた状態かどうかを示す
          const openedContent = e.currentTarget.nextElementSibling;
          slideUp(openedContent); //現在開いている他のメニューを閉じる
        }
      });
      //スクロール制御のために上位階層のクラス名を変える
      let container = accordion.closest('.scroll-control'); // クラス名がscroll-controlである近傍の要素
      if (e.currentTarget.parentNode.classList.contains('active') == false && container) {
        container.classList.remove('active')
      }else if (container !== null){
        container.classList.add('active')
      }
    });
  });
});
</script>

細かい部分はコメントアウトの記述で記載していますので、あとは大まかに、何をしているのかを説明します。

  • 開くときにheight: 0からいったんheight: autoにして高さを取得してからその高さを指定する、ということをしています。
  • 開いた後、閲覧者がブラウザの幅を変えたときに高さが指定されたままだと表示が崩れるので、アニメーションが動いた後に高さをautoに戻しています。
    ※この記事を書いた当初、autoに戻さず、閉じるときは高さが指定された状態からheight: 0へとCSSのアニメーションで動かしてましたが、ブラウザの幅が変わった時に表示が崩れるということに気づき修正しています。
  • height: 0からheight: auto、あるいはその逆のアニメーションに関して、CSSではうまくつけられず(方法ご存じの方いればご連絡ください!)、JavaScriptのanimate()メソッドを利用しています。これは比較的新しい仕様ですが、2020年にSafariが対応し、IEを除く主要なブラウザでは動きます。
    ※この部分について、繰り返し処理を用いて少しずつ高さを足していく、という方法を取っているものもあると思いますが(未確認ですがjQueryはそれ?)、ブラウザに組み込まれた処理なので、それよりもパフォーマンスが良いはずです。
  • クリックされたボタンをactiveIndex(変数名なので名称は自由)で定義した変数で把握し、あるボタンがクリックされたときに、他のボタンの次の要素を閉じる、ということをしています。
    ※開いた部分は閉じない限り開いておく仕様にする場合は、この部分が不要となりますので、28行目、38行目、47~53行目を削除してください
  • アコーディオンメニューをクリックしたときの関数のところ修正しました(2023年2月15日)。元々は引数をeにして、クリックされた要素を基準にクラス名activeの付与/削除する要素を決めていました。それだとアコーディオンメニューの中が複雑な時に不具合が生じることに気付きまして、あくまでもaccordionBtnのクラス名がついている要素を基準にクラス名activeの付与/削除する要素を決める形に変更しました。その方がシンプルでもあり、元々のは、どうしてそうしていたのでしょうか(笑)。
  • ボタンに対しては、開閉させている部分が開いた状態かどうかを示すaria-expanded属性をつけています。開閉する部分には、それが表示された状態かどうかを示すaria-hidden属性を付与しています。これらはWAI-ARIAという仕様で、スクリーンリーダーなどの支援技術に対して状態を示すためのものです。ウェブアクセシビリティ対応となります。

アコーディオンメニュー開閉部の上下の隙間を調整したいとき

開閉部は、heightを0にすることで非表示にしているので、開閉部の最上位要素にpaddingmarginの縦方向に値をつけると隙間が空いてしまいます。

例えば次のような場合に、dd要素にpaddingmarginをつけるのではなく、p要素にpaddingmarginをつければ、閉じているときは非表示に、開いているときはつけられたpaddingmarginが反映された形で表示されます。

<dl class="faq-container include-accordion">
   <dt class="accordionBtn"><button>駐車場はありますか?</button></dt>
   <dd><p>駐車場は2台分あります。</p></dd>
</dl>

アコーディオンメニューのアクセシビリティとSEOについて

jQueryによるアコーディオメニューは、閉じているときはdisplay: noneで、開いているときはdisplay: blockになっていると思われます。

スクリーンリーダーは、display: noneは表示されてないものとして無視します。しかしheight:0で非表示になっているものは、無視はしません。アクセシビリティ上、どちらがいいかというと、画面上では非表示にしているので、本来は無視してもらった方が良いかもしれません。とはいえ、飛ばしながら読み上げられるので、大きな問題とは思いません。

SEO上どうかで考えると、スクリーンリーダーが無視するものは、Googleも分析上無視して見ていると考えた方が無難だと思っています。以前、キーワードが単純にたくさん含まれている方が有利だったような時代に、display: noneで非表示にしたキーワードをたくさん埋め込むという、いわゆる隠しテキストが使われて、そういったものはGoogleからペナルティが与えられるようになりました。よって、display: nonevisibility: hiddenはSEO上のことをよく考えて使った方が良いと私は考えており、jQueryのようにdisplay:noneで非表示にするよりも、height: 0で非表示にしている本実装方法の方がSEO的には有利である可能性があると思っています。WAI-ARIAも合わせて実装しておけば間違いないかと思ってます。

まとめ

jQueryなしでアコーディオンメニューを作る方法を説明しました。
求めた仕様が割と複雑ですが、決めたクラス名をつけるだけでよくて、汎用性がある状態にできたと思ったので、ご紹介しました。
ご指摘や改善点、感想などあれば問い合わせフォームからいただければ幸いです。

著者のイメージ画像

株式会社BringFlower
稲田 高洋(Takahiro Inada)

2003年から大手総合電機メーカーでUXデザインプロセスの研究、実践。UXデザイン専門家の育成プログラム開発。SEOにおいても重要なW3Cが定めるWeb標準仕様策定にウェブアクセシビリティの専門家として関わる。2010~2018年に人間中心設計専門家を保有、数年間ウェブアクセシビリティ基盤委員も務める。その後、不動産会社向けにSaaSを提供する企業の事業開発部で複数サービスを企画、ローンチ。CMSを提供し1000以上のサイトを分析。顧客サポート、サイト運営にも関わる。
2022年3月に独立後、2024年4月に株式会社BringFlowerを設立。SEOコンサルを活動の軸に据えつつ、AIライティングツールの開発と運営を自ら行う。グッドデザイン賞4件、ドイツユニバーサルデザイン賞2件、米国IDEA賞1件の受賞歴あり。