【JavaScript】アコーディオン(FAQ)の開閉を実装する

javascript-icon JavaScript

こんにちは、かつコーチです。

前回はハンバーガーメニューの開閉を実装しました。

今回は、よくある質問(FAQ)ページなどで見かけるアコーディオンを作っていきます。

見出しをクリックすると、下に隠れていた回答部分がにゅっと開く、あの動きです。

ハンバーガーメニューと似た仕組みですが、「複数の項目を管理する」という新しい要素が加わります。

アコーディオンとは?

どんな場面で使われるか

アコーディオンは、次のような「情報量は多いが、一度に全部見せる必要はない」場面でよく使われます。

  • FAQ(よくある質問)ページ
  • 商品ページの「配送について」「返品について」といった補足情報
  • 長い規約・利用約款の見出しごとの折りたたみ

見出しだけをずらっと並べておき、興味のある項目だけクリックして中身を見てもらう、という設計です。

ハンバーガーメニューとの違い

ハンバーガーメニューは「開くか閉じるか」の2択でしたが、アコーディオンは項目が複数あります。

複数の項目をどう管理するかによって、実装のアプローチが少し変わってくるのがこのテーマのポイントです。

基本の実装手順

手順1:HTMLで項目を用意する

質問と回答のペアを、繰り返し構造として用意します。

<div class="faq">
  <div class="faq__item">
    <button class="faq__question" aria-expanded="false">
      料金プランは途中で変更できますか?
    </button>
    <div class="faq__answer">
      <p>はい、いつでも上位・下位プランへの変更が可能です。マイページの「プラン変更」から手続きできます。</p>
    </div>
  </div>
  <div class="faq__item">
    <button class="faq__question" aria-expanded="false">
      無料トライアルはありますか?
    </button>
    <div class="faq__answer">
      <p>14日間の無料トライアルをご用意しています。クレジットカードの登録は不要です。</p>
    </div>
  </div>
  <div class="faq__item">
    <button class="faq__question" aria-expanded="false">
      解約時に違約金は発生しますか?
    </button>
    <div class="faq__answer">
      <p>違約金は一切発生しません。いつでも解約可能です。</p>
    </div>
  </div>
</div>

質問部分を <button> にしているのは、キーボード操作(Tabキーでの移動やEnterキーでの開閉)に自然に対応できるからです。

<div> にクリックイベントを付ける実装も見かけますが、アクセシビリティの観点では <button> を使う方が望ましいです。

手順2:CSSで開閉の見た目を作る

.faq__item {
  border-bottom: 1px solid #ddd;
}

.faq__question {
  width: 100%;
  padding: 16px;
  text-align: left;
  background: #fff;
  border: none;
  font-size: 16px;
  font-weight: bold;
  cursor: pointer;
  position: relative;
}

.faq__question::after {
  content: "+";
  position: absolute;
  right: 16px;
  top: 50%;
  transform: translateY(-50%);
  transition: transform 0.3s;
}

.faq__question.is-open::after {
  transform: translateY(-50%) rotate(45deg);
}

.faq__answer {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s ease;
  background: #fafafa;
}

.faq__answer p {
  margin: 0;
  padding: 16px;
}

::after で「+」記号を表示し、開いたときに45度回転させることで「×」に見せる、というよくあるテクニックを使っています。

CSSだけで見た目のギミックが完結しているので、JavaScript側は本当にクラスの付け外しに専念できます。

手順3:JavaScriptで開閉を制御する

const questions = document.querySelectorAll('.faq__question');

questions.forEach((question) => {
  question.addEventListener('click', () => {
    const answer = question.nextElementSibling;
    const isOpen = question.classList.toggle('is-open');

    if (isOpen) {
      answer.style.maxHeight = answer.scrollHeight + 'px';
    } else {
      answer.style.maxHeight = null;
    }

    question.setAttribute('aria-expanded', isOpen);
  });
});

ここで新しく出てくるのが scrollHeight です。

max-height は数値で指定する必要がありますが、回答文の高さは項目によって変わるため、あらかじめCSSに固定値を書けません。

そこで answer.scrollHeight(要素の中身が実際に必要としている高さ)をJavaScriptで取得し、それを max-height に代入することで、コンテンツの高さに応じた開閉アニメーションを実現しています。

つまずきやすいポイント:全項目に同じイベントを付けたつもりが1つしか動かない

私が実際にハマった経験

私が最初にアコーディオンを実装したとき、querySelectorAll ではなく querySelector を使ってしまい、1つ目の項目しか開閉しない、という失敗をしました。

querySelector は最初に見つかった1つの要素しか返さないメソッドなので、当然といえば当然なのですが、当時は「なぜ2つ目以降が反応しないんだろう」としばらく悩みました。

❌ Before:querySelector で最初の1つしか取得できていない

const question = document.querySelector('.faq__question');
// questionには1つ目の要素しか入っていない

question.addEventListener('click', () => {
  question.classList.toggle('is-open');
});
// 2つ目、3つ目の項目をクリックしても何も起きない

✅ After:querySelectorAll と forEach で全項目にイベントを付ける

const questions = document.querySelectorAll('.faq__question');
// questionsには一致する全要素がNodeListとして入っている

questions.forEach((question) => {
  question.addEventListener('click', () => {
    question.classList.toggle('is-open');
  });
});
// 全項目それぞれにクリックイベントが登録される

querySelectorAll は条件に一致するすべての要素をNodeListという配列に似た形式で返します。

複数の同じ部品(今回で言えばFAQの各項目)にイベントを付けたいときは、querySelectorAllforEach をセットで使う、という型を覚えておくと迷わなくなります。

応用:開いている項目は1つだけにする

アコーディオンの「単独展開」パターン

FAQページによっては、「1つ開いたら、他の開いていた項目は自動的に閉じる」という仕様もよく見かけます。

複数開けるパターンと、単独展開パターンのどちらが良いかは、項目数や内容の長さによって使い分けます。

const items = document.querySelectorAll('.faq__item');

items.forEach((item) => {
  const question = item.querySelector('.faq__question');
  const answer = item.querySelector('.faq__answer');

  question.addEventListener('click', () => {
    const isCurrentlyOpen = question.classList.contains('is-open');

    // まず全項目を閉じる
    items.forEach((otherItem) => {
      const otherQuestion = otherItem.querySelector('.faq__question');
      const otherAnswer = otherItem.querySelector('.faq__answer');
      otherQuestion.classList.remove('is-open');
      otherAnswer.style.maxHeight = null;
      otherQuestion.setAttribute('aria-expanded', 'false');
    });

    // クリックされた項目が「閉じていた」場合のみ開き直す
    if (!isCurrentlyOpen) {
      question.classList.add('is-open');
      answer.style.maxHeight = answer.scrollHeight + 'px';
      question.setAttribute('aria-expanded', 'true');
    }
  });
});

「一度全部閉じてから、クリックされた項目だけ開き直す」という順序にすることで、開いていた項目をクリックしたときに正しく閉じる(トグルできる)動きにもなります。

先に「開くかどうか」を判定してから閉じ処理を書くと、この分岐がねじれてバグの元になりやすいので注意してください。

jQueryではこう書けます(参考)

jQueryでは、同じ処理を次のように書けます。

$('.faq__question').on('click', function () {
  const $answer = $(this).next('.faq__answer');
  $(this).toggleClass('is-open');
  $answer.slideToggle(300);
});

slideToggle() を使うと、max-height の計算をしなくても開閉アニメーションを実現できるのがjQueryの便利なところです。

詳しい使い方は別カテゴリのjQuery記事で扱います。

まとめ

この記事のポイント

  • アコーディオンは複数項目を扱うため、querySelectorAllforEach の組み合わせが基本になる
  • scrollHeight を使うと、コンテンツの高さに応じた開閉アニメーションが実現できる
  • 「単独展開」にしたい場合は、全項目を閉じてから対象項目だけ開き直す順序で実装する
  • querySelectorquerySelectorAll の違いを理解しておくと、複数要素の取得漏れを防げる

次に読むべき記事

次回は、タブによるコンテンツ切り替えを実装します。

「複数の要素の中から1つだけをアクティブにする」という考え方は、今回のアコーディオンの単独展開パターンとも共通する部分があります。

→ 次の記事:タブによるコンテンツ切り替えを実装する

タイトルとURLをコピーしました