まずはFAQアコーディオンの動きを確認する
前回の 「もっと見る」ボタンの記事 では、1つのボタンで1つのリストを開閉する処理を作りました。
今回は、FAQ(よくある質問)のように、同じ開閉パーツが複数並ぶ場合を扱います。
考え方は大きく変わりません。
「クリックを受け取り、HTMLの状態を変え、見た目はCSSに任せる」という基本は同じです。
ただし、複数の開閉パーツがある場合は、「どのボタンが押されたのか」「どのパネルを開くのか」をJavaScript側で正しく特定する必要があります。Web制作でよく使う処理なので、順番に確認していきましょう。
今回作るFAQアコーディオンは、次のように動きます。
- 質問部分をクリックする
- その質問に対応する回答が開く
- もう一度クリックすると閉じる
- 別の質問をクリックすると、その質問に対応する回答が開く
今回の基本コードでは、複数の回答を同時に開ける仕様にします。
完成イメージ
A. 商品到着後7日以内であれば返品可能です。
A. 全国一律500円です。5,000円以上のお買い上げで無料になります。
開閉の仕組みを3つの役割に分けて考える
今回も、HTML、CSS、JavaScriptの役割を分けて考えます。
- HTMLは、ボタンと回答パネルの構造を作ります。また、
aria-expandedやhiddenを使って、現在の開閉状態を持たせます。 - CSSは、ボタンや回答パネルの見た目を整えます。HTMLの属性に応じて、アイコンなどの表示も切り替えます。
- JavaScriptは、クリックされたボタンを特定し、
aria-expandedやhiddenの状態を更新します。
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;
nextExpandedがtrue、つまり開く場合は、hiddenがfalseになります。
反対に、閉じる場合はhiddenがtrueになります。
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を使わない方法
detailsとsummaryでFAQを作る
シンプルなFAQであれば、JavaScriptを書かずに、HTMLのdetailsとsummaryで開閉UIを作れます。
<details>
<summary>Q. 返品は可能ですか?</summary>
<p>A. 商品到着後7日以内であれば返品可能です。</p>
</details>
summaryをクリックすると、details内の内容が開閉します。
ブラウザが標準の開閉処理やキーボード操作を提供してくれるため、要件がシンプルな場合には有力な選択肢です。
JavaScriptで作る場合との違い
「すべてdetailsでよいのでは」と思うかもしれません。
detailsは手軽で扱いやすい一方、デザインや開閉動作を細かく調整したい場合は、JavaScriptで独自に実装するほうが適していることがあります。
たとえば、次のような要件です。
- 1つ開いたらほかを閉じたい
- 独自のアニメーションを付けたい
- 開閉時に別の処理を実行したい
- サイト独自の状態管理と連携したい
JavaScriptを書くこと自体を目的にせず、必要な機能に合わせて実装方法を選びましょう。
次回は、ハンバーガーメニューへ
今回は、複数の要素を扱うためのquerySelectorAllとforEach、ボタンとパネルを関連付けるaria-controls、開閉状態を表すaria-expandedについて確認しました。
これらは、Web制作のJavaScriptで繰り返し使う考え方です。
次回は、スマートフォン向けサイトでよく使われるハンバーガーメニューを作ります。
これまでに学んだクリックイベントと状態の切り替えを応用し、メニューの開閉、背景の固定、リンクをクリックしたときの閉じ方などを確認していきます。