アコーディオンは便利です。情報を畳めて、画面もスッキリ。FAQでも設定画面でも大活躍。 でも同時に、地雷も多いUIです。
見た目は動いているのに、キーボードで操作できない。スクリーンリーダーだと何が開いたか分からない。検索で該当箇所に飛んだのに中身が閉じたまま。あるいはアニメーションが優しさのつもりで酔いの原因になっている。
この記事では、CSS Notesの「WAI-ARIA + hidden=”until-found” を使用したアクセシブルなアコーディオン実装」を土台にしつつ、忙しい実務者がそのまま現場に持ち込める形で整理していきます。 流し読みでも要点が拾えるように、先に結論と事故ポイントからいきます。
先に結論 迷ったらこうする
おすすめ方針
- アコーディオンのトリガーはbutton要素で実装する
- 開閉状態はaria-expandedで必ず同期する
- 開閉対象はaria-controlsでひも付ける
- パネル側は必要に応じてrole=”region”とaria-labelledbyを付ける
- ページ内検索に強くするならhidden=”until-found”とbeforematchを使う
- アニメーションはprefers-reduced-motionを尊重する
避けたい方針
- divにclickを付けてdisplay:noneで開閉するだけ
- 見出しやラベルがただのspanで、フォーカスが当たらない
- 開閉状態がariaに反映されず、支援技術が置き去り
- クラス名だけで状態を持ち、JSの同期があいまい
一言で言うと、見た目のアコーディオンではなく「操作できて伝わるアコーディオン」を作る。これが勝ち筋です。
なぜ止める必要があるのか アコーディオン実装のよくある失敗
ここで言う「止める必要がある」は、次のような実装を卒業しよう、という話です。
失敗1 divをボタン代わりにする
divはデフォルトでボタンではありません。Tabでフォーカスが当たらないことが多く、EnterやSpaceで操作できないこともあります。 キーボード操作を後付けで直すのは可能ですが、その時点で罠が増えます。 最初からbuttonを使うのが圧倒的に楽で安全です。
失敗2 開閉状態が支援技術に伝わらない
画面上は開閉していても、スクリーンリーダーには「押した結果」が伝わらないことがあります。 その典型が、aria-expandedを更新していないケースです。 ボタンが今どちらの状態かを支援技術に伝えるには、aria-expandedを状態に合わせて更新する必要があります。
失敗3 URLのアンカーやページ内検索で該当箇所に来ても中身が閉じたまま
ユーザーは「検索でヒットした場所に飛べば見える」と思っています。閉じたままだと、体感としては行方不明です。 ここを真面目に潰すと、FAQやドキュメントページの満足度が一段上がります。 CSS Notesの元記事は、この問題をhidden=”until-found”とbeforematchで扱っています。
失敗4 連打で壊れる
開閉アニメーション中に連打されると、状態が逆転したまま固まったり、ariaと見た目がズレたりします。 実装側は「ユーザーは普通に押すだろう」と思いがちですが、現実はスマホのタップもキーボードも普通に連打されます。 壊れない仕組みにしておくと、保守が一気に楽になります。
失敗5 目に優しいつもりのアニメが、体調に刺さる
アニメーションは気持ちいい。でも人によっては負荷になる。 なのでprefers-reduced-motionを尊重するのは、アクセシビリティというより大人のマナーです。
WAI-ARIA対応アコーディオンの基本要件
WAI-ARIAのアコーディオンパターンは、ざっくり言うと「見出しの中にボタンがあり、そのボタンがパネルを制御する」という形です。
最低限おさえる属性セット
- buttonにaria-expanded=”true/false”で状態を付与する
- buttonにaria-controls=”panel-id”で制御対象を示す
そして、必要に応じてパネル側に次を追加します。
- パネルにrole=”region”を付け、見出しとaria-labelledbyで関連付ける
role=”region”は必須ではなく、ページの情報構造やコンテンツの性質によって判断するのが現実的です。 元記事でもこの点が論点として扱われています。
実装パターン1 WAI-ARIAで作る これが基本形
ここからは実装です。 まずは最も素直で、実務で事故が少ないWAI-ARIAベースの形。
HTMLの基本形
<div class="accordions">
<div class="accordion">
<h3 class="accordion__title">
<button
class="accordion__button"
type="button"
id="acc-label-01"
aria-expanded="false"
aria-controls="acc-panel-01"
>
よくある質問 01
<span class="accordion__icon" aria-hidden="true"></span>
</button>
</h3>
<div
class="accordion__panel"
id="acc-panel-01"
role="region"
aria-labelledby="acc-label-01"
hidden
>
<div class="accordion__content">
<p>ここに回答が入ります。</p>
</div>
</div>
</div>
</div>
ポイントは2つだけです。
- トリガーはbuttonで作る
- aria-expandedとhiddenの状態を必ず同期する
JSの基本形
const accordions = document.querySelectorAll('.accordion')
accordions.forEach((accordion) => {
const button = accordion.querySelector('.accordion__button')
const panelId = button.getAttribute('aria-controls')
const panel = document.getElementById(panelId)
button.addEventListener('click', () => {
const isExpanded = button.getAttribute('aria-expanded') === 'true'
// 状態を反転
button.setAttribute('aria-expanded', String(!isExpanded))
// パネル表示を同期
if (isExpanded) {
panel.setAttribute('hidden', '')
} else {
panel.removeAttribute('hidden')
}
})
})
これだけでも成立します。まずはここから始めるのが安全です。
実装パターン2 hidden=”until-found”でページ内検索にも強くする
ここがCSS Notesの元記事の一番おいしいところです。
ページ内検索でヒットした部分を、ブラウザが自動で展開して見せてくれる仕組みとしてhidden=”until-found”があります。 通常のhiddenは見つからないが、until-foundにすると検索ヒット時に見せられる。つまり、FAQの検索体験が良くなります。
HTML例 パネル側をhidden=”until-found”にする
<div
class="accordion__panel"
id="acc-panel-01"
hidden="until-found"
>
<div class="accordion__content">
<p>検索でヒットしたら自動で開くようにしたい内容。</p>
</div>
</div>
ただし、ここで終わると危険です。 ブラウザがhiddenを外すタイミングと、aria-expandedの状態がズレるからです。 ズレた瞬間、支援技術には「閉じているのに開いている」ように見えたり、その逆になったりします。
beforematchで同期する これが事故を防ぐコツ
CSS Notesの元記事では、hidden=”until-found”が使われるとbeforematchイベントが発火する点に注目し、clickと同じ開閉処理を登録しています。 さらにe.preventDefault()でブラウザの自動付け外しを無効化してから、自前の処理で同期させています。 この流れが重要です。
const accordions = document.querySelectorAll('.accordion')
accordions.forEach((accordion) => {
const button = accordion.querySelector('.accordion__button')
const panelId = button.getAttribute('aria-controls')
const panel = document.getElementById(panelId)
const open = () => {
button.setAttribute('aria-expanded', 'true')
panel.removeAttribute('hidden') // until-found を外す
}
const close = () => {
button.setAttribute('aria-expanded', 'false')
panel.setAttribute('hidden', 'until-found')
}
const toggle = () => {
const isExpanded = button.getAttribute('aria-expanded') === 'true'
if (isExpanded) close()
else open()
}
// click時
button.addEventListener('click', (e) => {
e.preventDefault()
toggle()
})
// 検索ヒット時など
panel.addEventListener('beforematch', (e) => {
e.preventDefault()
open()
})
})
これで、検索ヒットによる展開でもaria-expandedが同期します。 見た目だけでなく、状態が伝わるUIになります。
複数同時展開の制御 仕様を決めるとレビューが楽になる
アコーディオンには2種類あります。
- 常に1つだけ開くタイプ
- 複数同時に開けるタイプ
どちらが正解というより、ページの目的で決めます。 FAQは複数開けた方が読み比べしやすいことが多い。 設定画面は1つだけ開く方が見通しが良いことが多い。
複数開く場合 data属性で許可する
CSS Notesの元記事では、data-allow-multipleのようなデータ属性で制御する例が示されています。 実務でもこの方式は分かりやすいです。
<div class="accordions" data-allow-multiple>
...
</div>
JS側では、allow-multipleが無い場合は開いた瞬間に他を閉じる。 ある場合はそのまま。これで仕様が一本化できます。
const container = document.querySelector('.accordions')
const allowMultiple = container.hasAttribute('data-allow-multiple')
const closeOthers = (currentAccordion) => {
if (allowMultiple) return
document.querySelectorAll('.accordion').forEach((acc) => {
if (acc === currentAccordion) return
const btn = acc.querySelector('.accordion__button')
const panel = document.getElementById(btn.getAttribute('aria-controls'))
btn.setAttribute('aria-expanded', 'false')
panel.setAttribute('hidden', panel.getAttribute('hidden') === 'until-found' ? 'until-found' : '')
})
}
ここはプロジェクトの好みで調整してください。 大事なのは、仕様を決めてコードに落とすことです。
メリット WAI-ARIA対応にする価値は十分ある
キーボード操作が自然に成立する
buttonを使うだけで、Tab移動とEnter/Spaceの操作が自然に揃います。 後付けのイベント地獄を回避できます。
状態が支援技術に伝わる
aria-expandedとaria-controlsを正しく使えば、開閉状態と制御関係が伝わります。 これはQAや監査でも説明しやすいポイントです。
検索体験が上がる
hidden=”until-found”とbeforematchを使うと、ページ内検索に対して強いアコーディオンになります。 FAQの満足度が目に見えて変わる領域です。
デメリット つまりコストと注意点
実装が少しだけ増える
divで開閉するだけよりコードは増えます。 ただし、増えるのは保守に効く部分です。将来の自分への保険料と思うと安いです。
状態同期のバグが起きると影響が大きい
ariaと見た目がズレた瞬間、使える人と使えない人の差が一気に広がります。 だからこそ、開閉処理は1関数にまとめて、状態更新を一箇所で行うのがおすすめです。
details/summaryとの比較で迷う
details/summaryはネイティブ要素で魅力があります。 一方で「detailsはアコーディオンではない」という立場の議論もあり、要件次第で選択が分かれます。 元記事もこの論点に触れています。
迷ったら、次の基準で考えると整理しやすいです。
- 単体の開閉ウィジェットとして使うならdetails/summaryでも良い場面はある
- 複数パネルの制御や検索対応、状態同期を厳密にやるならWAI-ARIA方式が安定
やり方まとめ 実務でそのまま使えるチェックリスト
HTML
- 見出しはh3などの見出し要素で包む
- トリガーはbutton
- buttonにaria-expandedとaria-controls
- パネルはidで参照される
- 必要ならrole=”region”とaria-labelledby
- ページ内検索を強化したいならhidden=”until-found”
JS
- open/close/toggleを関数で統一する
- aria-expandedとhiddenの同期を必ず行う
- hidden=”until-found”を使うならbeforematchでも同期する
- 複数展開の仕様を決め、data属性などで分岐する
UX
- アニメーションはprefers-reduced-motionを尊重する
- 開閉アイコンはaria-hiddenで装飾扱いにする
- 連打で壊れないように、アニメ中の連続処理を考慮する
関連して役立つ話 もう一歩だけ強くなる
1 アニメーションはCSSだけで頑張りすぎない
高さのアニメはやりたくなりますが、実務では「壊れないこと」が最優先です。 要件が強くないなら、フェードやアイコン回転など軽い表現で十分です。 どうしても高さを滑らかにしたいなら、連打耐性まで含めて設計しましょう。
2 目次プラグインと相性を良くする
見出しをh2/h3で正しく立てておけば、目次プラグインは自然に拾います。 アコーディオン内部にも見出しがある場合は、ページ構造として意味のある階層にしてください。
3 QAで確認したい最低ライン
- Tabで各トリガーにフォーカスできる
- EnterまたはSpaceで開閉できる
- 開閉時にaria-expandedが切り替わる
- ページ内検索でヒットしたら中身が見える
- JS無効でも最低限の情報に到達できる設計かを判断する
まとめ 現場で壊れないアコーディオンはボタンと同期が全て
アコーディオンは見た目の問題ではなく、操作と情報の伝達の問題です。 buttonで作り、aria-expandedとhiddenを同期し、必要ならhidden=”until-found”とbeforematchで検索体験まで面倒を見る。 ここまでやると、UIは一段上の安定感になります。
そして何より、将来のあなたが助かります。 バグ報告が来た時に「それは仕様です」ではなく「それは直せます」と言える設計は、精神衛生にも効きます。

