まずはFAQアコーディオンの動きを確認する

前回の 「もっと見る」ボタンの記事 では、1つのボタンで1つのリストを開閉する処理を作りました。

今回は、FAQ(よくある質問)のように、同じ開閉パーツが複数並ぶ場合を扱います。

考え方は大きく変わりません。

「クリックを受け取り、HTMLの状態を変え、見た目はCSSに任せる」という基本は同じです。

ただし、複数の開閉パーツがある場合は、「どのボタンが押されたのか」「どのパネルを開くのか」をJavaScript側で正しく特定する必要があります。Web制作でよく使う処理なので、順番に確認していきましょう。

今回作るFAQアコーディオンは、次のように動きます。

  1. 質問部分をクリックする
  2. その質問に対応する回答が開く
  3. もう一度クリックすると閉じる
  4. 別の質問をクリックすると、その質問に対応する回答が開く

今回の基本コードでは、複数の回答を同時に開ける仕様にします。

完成イメージ

開閉の仕組みを3つの役割に分けて考える

今回も、HTML、CSS、JavaScriptの役割を分けて考えます。

  • HTMLは、ボタンと回答パネルの構造を作ります。また、aria-expandedhiddenを使って、現在の開閉状態を持たせます。
  • CSSは、ボタンや回答パネルの見た目を整えます。HTMLの属性に応じて、アイコンなどの表示も切り替えます。
  • JavaScriptは、クリックされたボタンを特定し、aria-expandedhiddenの状態を更新します。

JavaScriptで直接色や余白、displayなどを細かく変更するのではなく、HTMLの状態を切り替え、見た目はCSSに任せるのがポイントです。

この記事で学べること

この記事では、次の内容を扱います。

  • querySelectorAllで複数の要素を取得する方法
  • forEachで要素ごとに処理を登録する方法
  • クリックされたボタンを特定する方法
  • 対応する回答パネルだけを操作する方法
  • aria-expandedで開閉状態を伝える方法
  • aria-controlsでボタンとパネルを関連付ける方法
  • hidden属性で表示・非表示を管理する方法

FAQアコーディオンのHTMLを書く

まずは、FAQの構造をHTMLで作ります。

JavaScriptから操作するためのクラスと、CSSで見た目を整えるためのクラスは分けておきます。

<div class="faq-container">
  <!-- FAQアイテム 1 -->
  <div class="faq-item">
    <h3 class="faq-question">
      <button
        class="faq-trigger js-faq-trigger"
        type="button"
        aria-expanded="false"
        aria-controls="faq-panel-1"
      >
        <span>Q. 返品は可能ですか?</span>
        <span class="faq-icon" aria-hidden="true"></span>
      </button>
    </h3>

    <div id="faq-panel-1" class="faq-panel" hidden>
      <p>A. 商品到着後7日以内であれば返品可能です。</p>
    </div>
  </div>

  <!-- FAQアイテム 2 -->
  <div class="faq-item">
    <h3 class="faq-question">
      <button
        class="faq-trigger js-faq-trigger"
        type="button"
        aria-expanded="false"
        aria-controls="faq-panel-2"
      >
        <span>Q. 送料はかかりますか?</span>
        <span class="faq-icon" aria-hidden="true"></span>
      </button>
    </h3>

    <div id="faq-panel-2" class="faq-panel" hidden>
      <p>
        A. 全国一律500円です。5,000円以上のお買い上げで無料になります。
      </p>
    </div>
  </div>
</div>

aria-controlsの値と、対応するパネルのidが一致していることがポイントです。

たとえば、次のボタンはfaq-panel-1を操作します。

<button aria-controls="faq-panel-1">

そのため、回答パネルには同じ値のidを付けます。

<div id="faq-panel-1" hidden>

また、ボタンにはtype="button"を指定しています。

FAQがフォームの中に置かれた場合でも、意図せず送信ボタンとして動作することを防げます。

ポイントaria-controlsとパネルのidを揃え、ボタンにはtype="button"を付けます。

CSSで閉じている状態と開いている状態を作る

次に、FAQの見た目をCSSで整えます。

hidden属性が付いた要素は、通常はブラウザによって非表示になります。そのため、今回の基本実装では、閉じたパネルに対して別途display: none;を指定する必要はありません。

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

.faq-question {
  margin: 0;
}

/* ボタンのスタイル */
.faq-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  border: 0;
  background: none;
  padding: 1rem;
  font: inherit;
  font-weight: 700;
  text-align: left;
  cursor: pointer;
}

/* アイコン */
.faq-icon {
  flex: 0 0 auto;
  margin-left: 1rem;
}

.faq-icon::before {
  content: "+";
  font-size: 1.25rem;
}

/* 開いているときはマイナスに変更 */
.faq-trigger[aria-expanded="true"] .faq-icon::before {
  content: "−";
}

/* 回答パネル */
.faq-panel {
  padding: 0 1rem 1rem;
}

CSSでは、aria-expanded="true"になっているボタンを属性セレクタで指定しています。

.faq-trigger[aria-expanded="true"]

これにより、JavaScript側で専用のクラスを追加しなくても、開閉状態に合わせてアイコンを変更できます。

JavaScriptでクリック時の開閉を切り替える

JavaScriptでは、すべてのFAQボタンを取得し、ボタンごとにクリック処理を登録します。

// 1. すべてのFAQボタンを取得
const faqTriggers = document.querySelectorAll('.js-faq-trigger');

// 2. ボタン1つずつにクリック処理を登録
faqTriggers.forEach((trigger) => {
  trigger.addEventListener('click', (event) => {
    // 3. クリックされたボタンを取得
    const currentButton = event.currentTarget;

    // 4. aria-controlsから対応するパネルのIDを取得
    const targetId = currentButton.getAttribute('aria-controls');

    if (!targetId) {
      return;
    }

    // 5. 対応するパネルを取得
    const targetPanel = document.getElementById(targetId);

    if (!targetPanel) {
      return;
    }

    // 6. 現在の開閉状態を取得
    const isExpanded =
      currentButton.getAttribute('aria-expanded') === 'true';

    // 7. クリック後の状態を作る
    const nextExpanded = !isExpanded;

    // 8. ボタンとパネルの状態を更新
    currentButton.setAttribute(
      'aria-expanded',
      String(nextExpanded)
    );

    targetPanel.hidden = !nextExpanded;
  });
});

このコードでは、クリック前の状態をisExpanded、クリック後の状態をnextExpandedとして分けています。

const nextExpanded = !isExpanded;

現在がfalseならtrueに、現在がtrueならfalseになります。

完成コード

ここまでのHTML、CSS、JavaScriptを組み合わせると、複数のFAQを個別に開閉できるアコーディオンが完成します。

<div class="faq-container">
  <div class="faq-item">
    <h3 class="faq-question">
      <button
        class="faq-trigger js-faq-trigger"
        type="button"
        aria-expanded="false"
        aria-controls="faq-panel-1"
      >
        <span>Q. 返品は可能ですか?</span>
        <span class="faq-icon" aria-hidden="true"></span>
      </button>
    </h3>

    <div id="faq-panel-1" class="faq-panel" hidden>
      <p>A. 商品到着後7日以内であれば返品可能です。</p>
    </div>
  </div>

  <div class="faq-item">
    <h3 class="faq-question">
      <button
        class="faq-trigger js-faq-trigger"
        type="button"
        aria-expanded="false"
        aria-controls="faq-panel-2"
      >
        <span>Q. 送料はかかりますか?</span>
        <span class="faq-icon" aria-hidden="true"></span>
      </button>
    </h3>

    <div id="faq-panel-2" class="faq-panel" hidden>
      <p>
        A. 全国一律500円です。5,000円以上のお買い上げで無料になります。
      </p>
    </div>
  </div>
</div>
.faq-item {
  border-bottom: 1px solid #ddd;
}

.faq-question {
  margin: 0;
}

.faq-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  border: 0;
  background: none;
  padding: 1rem;
  font: inherit;
  font-weight: 700;
  text-align: left;
  cursor: pointer;
}

.faq-icon {
  flex: 0 0 auto;
  margin-left: 1rem;
}

.faq-icon::before {
  content: "+";
  font-size: 1.25rem;
}

.faq-trigger[aria-expanded="true"] .faq-icon::before {
  content: "−";
}

.faq-panel {
  padding: 0 1rem 1rem;
}
const faqTriggers = document.querySelectorAll('.js-faq-trigger');

faqTriggers.forEach((trigger) => {
  trigger.addEventListener('click', (event) => {
    const currentButton = event.currentTarget;
    const targetId = currentButton.getAttribute('aria-controls');

    if (!targetId) {
      return;
    }

    const targetPanel = document.getElementById(targetId);

    if (!targetPanel) {
      return;
    }

    const isExpanded =
      currentButton.getAttribute('aria-expanded') === 'true';

    const nextExpanded = !isExpanded;

    currentButton.setAttribute(
      'aria-expanded',
      String(nextExpanded)
    );

    targetPanel.hidden = !nextExpanded;
  });
});

実際に触ってみよう

記事冒頭の完成イメージで質問部分をクリックし、それぞれの回答が個別に開閉することを確認してください。

ボタンを押したときに、次の部分がどう変わるか確認してみましょう。

  • ボタンのaria-expanded
  • 回答パネルのhidden
  • プラスとマイナスのアイコン

完成コードは、下のCodePenでも確認できます。質問部分をクリックして、それぞれの回答が個別に開閉することを確認してください。

See the Pen Web制作JS|FAQアコーディオン|基本サンプル by WebToolGen (@webtoolgen) on CodePen.

コードの意味を1行ずつ見る

すべてのボタンを取得する

document.querySelectorAll('.js-faq-trigger')

querySelectorAllは、指定したセレクタに一致する要素をすべて取得します。

前回使ったquerySelectorは、最初に見つかった1つの要素だけを取得します。一方、querySelectorAllは、該当する複数の要素をNodeListとして取得します。

今回のように、同じFAQボタンが複数ある場合に使います。

forEachでボタンごとに処理を登録する

faqTriggers.forEach((trigger) => {
  // ボタンごとの処理
});

querySelectorAllで取得したNodeList自体には、クリック処理を直接登録できません。

forEachを使ってボタンを1つずつ取り出し、それぞれにaddEventListenerを登録します。

クリックされたボタンを判定する

const currentButton = event.currentTarget;

event.currentTargetには、クリックイベントを登録した要素が入ります。

今回の場合は、実際にクリック処理が実行されたFAQボタンです。

これにより、1つ目のボタンが押されたのか、2つ目のボタンが押されたのかを特定できます。

対応するパネルを取得する

const targetId =
  currentButton.getAttribute('aria-controls');

クリックされたボタンのaria-controlsから、対応するパネルのIDを取得します。

続いて、document.getElementByIdでそのIDを持つパネルを探します。

const targetPanel = document.getElementById(targetId);

この仕組みにより、クリックされたボタンと、そのボタンが操作する回答パネルを関連付けられます。

aria-expandedを切り替える

const isExpanded =
  currentButton.getAttribute('aria-expanded') === 'true';

aria-expandedは、ボタンが操作する領域が開いているかどうかを表します。

  • true:開いている
  • false:閉じている

クリック後は、現在の状態を反転させます。

const nextExpanded = !isExpanded;

その値を、ボタンのaria-expandedに設定し直します。

currentButton.setAttribute(
  'aria-expanded',
  String(nextExpanded)
);

hidden属性で表示状態を管理する

JavaScriptでは、hiddenプロパティを使って表示状態を変更できます。

targetPanel.hidden = !nextExpanded;

nextExpandedtrue、つまり開く場合は、hiddenfalseになります。

反対に、閉じる場合はhiddentrueになります。

HTMLのhidden属性を直接追加・削除する方法もありますが、プロパティを使うと簡潔に書けます。

よくある失敗と解決法

クリックしても開かない

まずは、ブラウザの開発者ツールでコンソールエラーが出ていないか確認します。よくある原因は、aria-controlsの値と、パネルのidが一致していないことです。大文字・小文字、ハイフン、数字なども含めて確認しましょう。

aria-controls="faq-panel-1"
id="faq-panel-1"

1つ目のFAQしか動かない

querySelectorAllではなく、querySelectorを使っていないか確認します。複数のFAQボタンを扱う場合は、querySelectorAllを使います。

// 1つだけ取得
document.querySelector('.js-faq-trigger');

// すべて取得
document.querySelectorAll('.js-faq-trigger');

class名やid名がずれている

HTMLとJavaScriptで使っているクラス名が一致しているか確認します。今回のコードでは、CSS用とJavaScript用のクラスを分けています。このように分けておくと、デザイン変更によってJavaScriptが動かなくなる事故を減らせます。

class="faq-trigger js-faq-trigger"

// faq-trigger:CSS用
// js-faq-trigger:JavaScript用

開閉状態は変わっているのに見た目が変わらない

開発者ツールで、aria-expandedが切り替わり、回答パネルのhiddenが追加・削除されるか確認します。ここまで変化していればJavaScriptは動いているため、CSSのセレクタや指定内容を確認します。

.faq-trigger[aria-expanded="true"]
  .faq-icon::before {
  content: "−";
}

アニメーションを入れたら閉じる動きが不自然になる

hidden属性やdisplay: none;は、要素をすぐに非表示にします。そのため、そのままではCSSのtransitionを使った開閉アニメーションを付けにくい性質があります。

// 別の方法を検討
max-height + opacity
CSS Grid
JavaScriptで高さを取得

注意:今回の記事では仕組みを分かりやすくするため、アニメーションなしの基本形を使用します。

カスタマイズして使ってみよう

1つ開いたら他を閉じる形にする

今回のコードでは、複数の回答を同時に開けます。

1つ開いたら他の回答を閉じる仕様にする場合は、新しいパネルを開く前に、現在開いているほかのパネルを探して閉じます。

これは「排他的なアコーディオン」と呼ばれることがあります。

回答を比較しながら読みたいFAQでは複数開ける形、画面をすっきり見せたい場合は1つだけ開ける形など、用途に合わせて選びましょう。

複数同時に開ける形にする

今回の基本コードは、最初から複数の回答を同時に開ける仕様です。

ユーザーが複数の回答を見比べる可能性がある場合は、この仕様が適しています。

アイコンを回転させる

aria-expanded="true"の属性セレクタを使えば、矢印などのアイコンを回転させることもできます。

.faq-trigger[aria-expanded="true"] .faq-icon {
  transform: rotate(180deg);
}

アイコンにtransitionを設定すると、なめらかに回転します。

.faq-icon {
  transition: transform 0.3s ease;
}

開閉アニメーションを付ける

CSS Gridを使い、grid-template-rowsを切り替えて開閉する方法もあります。

ただし、hidden属性を使った今回の実装とは構造が変わります。アニメーションを追加する場合は、表示状態の管理方法も含めて設計し直す必要があります。

まずはアニメーションなしで動作を理解し、その後に見た目を調整するほうが安全です。

JSを使わない方法

detailssummaryでFAQを作る

シンプルなFAQであれば、JavaScriptを書かずに、HTMLのdetailssummaryで開閉UIを作れます。

<details>
  <summary>Q. 返品は可能ですか?</summary>
  <p>A. 商品到着後7日以内であれば返品可能です。</p>
</details>

summaryをクリックすると、details内の内容が開閉します。

ブラウザが標準の開閉処理やキーボード操作を提供してくれるため、要件がシンプルな場合には有力な選択肢です。

JavaScriptで作る場合との違い

「すべてdetailsでよいのでは」と思うかもしれません。

detailsは手軽で扱いやすい一方、デザインや開閉動作を細かく調整したい場合は、JavaScriptで独自に実装するほうが適していることがあります。

たとえば、次のような要件です。

  • 1つ開いたらほかを閉じたい
  • 独自のアニメーションを付けたい
  • 開閉時に別の処理を実行したい
  • サイト独自の状態管理と連携したい

JavaScriptを書くこと自体を目的にせず、必要な機能に合わせて実装方法を選びましょう。

次回は、ハンバーガーメニューへ

今回は、複数の要素を扱うためのquerySelectorAllforEach、ボタンとパネルを関連付けるaria-controls、開閉状態を表すaria-expandedについて確認しました。

これらは、Web制作のJavaScriptで繰り返し使う考え方です。

次回は、スマートフォン向けサイトでよく使われるハンバーガーメニューを作ります。

これまでに学んだクリックイベントと状態の切り替えを応用し、メニューの開閉、背景の固定、リンクをクリックしたときの閉じ方などを確認していきます。