こんにちは、かつコーチです。
前回はハンバーガーメニューの開閉を実装しました。
今回は、よくある質問(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の各項目)にイベントを付けたいときは、querySelectorAll と forEach をセットで使う、という型を覚えておくと迷わなくなります。
応用:開いている項目は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記事で扱います。
まとめ
この記事のポイント
- アコーディオンは複数項目を扱うため、
querySelectorAllとforEachの組み合わせが基本になる scrollHeightを使うと、コンテンツの高さに応じた開閉アニメーションが実現できる- 「単独展開」にしたい場合は、全項目を閉じてから対象項目だけ開き直す順序で実装する
querySelectorとquerySelectorAllの違いを理解しておくと、複数要素の取得漏れを防げる
次に読むべき記事
次回は、タブによるコンテンツ切り替えを実装します。
「複数の要素の中から1つだけをアクティブにする」という考え方は、今回のアコーディオンの単独展開パターンとも共通する部分があります。
→ 次の記事:タブによるコンテンツ切り替えを実装する