「アクセシブルなアコーディオンをどう実装すべきか」は、よく議論される話題の一つです。
主な実装パターンは以下の2通りが考えられます。
- WAI-ARIA準拠で、見出しロール・buttonロールを使って実装する。
- details / summary タグを使って実装する。
最近では、details/summaryを使って実装する方法がよく紹介されており、(2026年現在、)CSSだけでアニメーションができるブラウザの範囲もかなり広がってきました。
自分も details/summary で実装することが多いのですが、世の中には「アコーディオンをdetails/summaryで実装するなんてけしからん」という派閥の人たちもいるようです。
- 一部のスクリーンリーダーとブラウザの組み合わせによっては、summary内の見出しロールが検出できない問題がある。
- そもそもアコーディオンUIの役割と、details/summaryの役割は別物であり、セマンティックではないのではないか。
- ページ内検索が逆に邪魔な時がある。
1.に関しては、明確に「問題」として挙げられる点ですね。
ただし、details/summaryを使ったアコーディオンの実装方法で記載している通り、2026年1月現在、すでにこの問題はかなり解消されつつあるようです。
2. については、確かにそうかもしれないと思いつつ、気にするかどうかはその人次第な問題かなと。
興味深いのは 3. です。
これはなにかというと、例えば、ヘッダー付近のナビゲーションの折りたたみにdetails/summaryを使ってしまうと、ページ検索で単語入力中にナビゲーションメニューがパカパカと開いて鬱陶しいというケースがあるよね、という話だそうです。(“Text” を検索しようとしたら “T” を打った時点で “T” を含む他の無関係な文字列にも反応する。)
日本語で検索すると変換を確定するまでページ内検索も走らないので気になることがほぼないのですが、英語圏内だとたしかにこれが起こると鬱陶しいかもしれません。
これらを踏まえると、details/summaryを使わない方が無難な場面もたしかにありそうです。
WordPress6.9(2025年12月リリース)で実装されたアコーディオンブロックや、海外の最強つよつよチームが開発する Base UI (2025年12月ver.1.0 がリリース)のアコーディオンでも、buttonタグで実装されており、汎用性を意識して実装する場合は特に、details/summaryを使わないのが無難かもしれません。
そこで、今回は、WAI-ARIAを使ってアクセシビリティを意識したアコーディオンの実装方法について、改めて調べてみましたので、情報をまとめて実装例を紹介していこうと思います。
実装ポイント
今回の実装にあたり押さえておくべきポイントをいくつかまとめます。
hidden="until-found"でページ内検索に対応
hidden="until-found"を使用すれば、details/summaryを使った時と同じように、折りたたまれたコンテンツの中までテキスト検索できるようになります。
これは、通常時はhidden属性が付与されているのと同様の状態にしながら、ページ内検索やページ内リンクでコンテンツがヒットした場合に自動でhidden属性が削除されるような仕組みです。
そんな便利なhidden="until-found"ですが、2025年についに、Safari/Firefoxでもサポートされました。(※ ただし、Safari/Firefoxではまだバグが少しあるようです。)
(非サポートブラウザではただのhidden属性として扱われます。)
今回の実装では、このhidden="until-found"を使用できるようにしつつ、ただのhiddenにも対応できるようにします。(ページ内検索の対象としたくないケースも考慮)
hidden="until-found"内のテキストにページ検索がヒットした時に、その位置までスクロールしてくれないという問題が残っています。(2026年1月現在)
たがし、これは'beforematch' でhidden属性処理する場合、そのタイミングによっては挙動が改善されるようです。
今回実装するアコーディオンでも、この問題が自然と解消されていることを、Mac OSのFirefox/Safari最新版で確認しました。
data-allow-multipleで複数同時展開の制御
今回の実装例では、data-allow-multiple 属性の有無よって、複数の兄弟アコーディオンが同時に展開可能かどうかをコントロールしていきます。
- 複数アコーディオンを囲う親要素に
data-allow-multipleがある時は、複数同時展開を許可する。 data-allow-multipleがない時は、展開できるアコーディオンを1つに制限する。
ただし、これは独自実装であり、WAI-ARIAの仕様には含まれていません。
WAI-ARIA の要件確認
アコーディオン実装時に抑えておくべき WAI-ARIA の実装ポイントもしっかり確認します。
「不適切なARIA」を使うくらいなら「ARIAなし」の方がマシです。
W3Cの公式実装パターンのページを読んでいきましょう。
キーボード操作について
必須動作は以下のように定義されています。
- Enter または Space:
- アコーディオンヘッダーにフォーカスがある状態で、パネルの開閉ができる。
- 一度に1つのパネルしか展開できない仕様の場合、他のパネルが開いていればそれを閉じる。
- Tab:
- 次のフォーカス可能な要素に移動できる。
- パネル展開時、そのパネル内のすべてのフォーカス可能な要素に移動できる。
- Shift を押しながらの時は、移動順序が逆になる。
role について
- 各アコーディオンヘッダーのタイトルは、
role="button"を持つ要素の中に含める。- そのボタンは、
role="heading"を持つ要素で囲み、この要素には、ページ構造に応じた適切なaria-levelの値を設定する。
- もし、HTMLの見出しタグ(
h1〜h6)のように、暗黙的に見出しの役割とaria-levelを持つ要素が利用できる場合は、それらを使用しても構わない。- ボタン要素は、見出し要素の中に含まれる唯一の要素である必要がある。(他にも常に表示されている要素がある場合、それらは見出し要素の中に含めない。)
- ヘッダーに関連付けられたパネルが表示されている場合、ボタン要素には
aria-expanded="true"を、パネルが表示されていない場合はaria-expanded="false"を設定する。- ヘッダーのボタン要素には、アコーディオンパネルのコンテンツを含む要素の
idをaria-controlsに設定する。- ヘッダーに関連付けられたパネルが表示されており、かつそのアコーディオンがパネルを閉じることができない設定になっている場合は、ヘッダーのボタン要素に
aria-disabled="true"を設定する。- (任意の設定)パネルのコンテンツを保持する各要素に
role="region"を付与し、さらにそのパネルの表示を制御するボタンを指し示す値をaria-labelledbyに設定することも可能。
- ただし、同時に展開可能なパネルが6つ以上あるアコーディオンなど、ランドマーク領域が過剰に増えてしまうような状況では、region ロールの使用は避けてください。
role="region"は、パネルの中にさらに見出し要素が含まれていたり、入れ子になったアコーディオンがある場合に、スクリーンリーダーの利用者が構造を把握するのに特に役立ちます。
つまり、次のような構造にするべきとのことです。
div {h3|[role="heading"][aria-level]} {button|[role="button"]}[aria-expanded][aria-controls={panel-id}](#{label-id})([aria-disabled="true"]) div#{panel-id}([role="region"][aria-labelledby={label-id}])role="region" をつけるべきかどうか
ここが一番迷うポイントかなと思います。
大前提として、もともと任意項目なので無理につける必要はないでしょう。
role="region"は、パネルの中にさらに見出し要素が含まれていたり、入れ子になったアコーディオンがある場合に、スクリーンリーダーの利用者が構造を把握するのに特に役立ちます。
とある通り、Q&Aような短いコンテンツが入っている場合には、そもそもそこまで役立つものでもなさそうです。(パネル内に出入りするタイミングが分かりやすくなる効果はあるかなと思います。)
また、
公式実装例では、パネル折りたたみ時はhiddenとなっており、"region"が認識されるのはパネルが開かれている時のみとなっています。
そもそもこの挙動が正しいのかが少し気になりましたが、「同時に展開できるパネル が6つ以上など、ランドマークが増えすぎるのは注意」(≒ 1つしか展開できないアコーディオンであれば問題ない)と書かれてることも踏まえると、これが正解の挙動なのでしょう。
つまり、ランドマーク検索によってアコーディオン(パネル)へ辿り着けるようにするためのものでもない(むしろありすぎると邪魔になるので非推奨である)ことがわかります。(見出しメニューやフォームコントロールメニューからアコーディオンへ辿り着くことができますし。)
とすると、hidden="until-found"な要素では挙動に問題があることがわかります。
hidden="until-found"にrole="region"を付与した時の挙動折り畳まれている間(hidden="until-found"が付与されている間)も、ランドマークとして認識されてしまうようです。
さらにこの時、コンテンツは空として認識されてしまうのでランドマークの読み上げ時に 「カラのセクション」となってしまいます。(VoiceOver + Chrome で確認しました。)
これを回避するして同じ挙動を目指すならば、考えられる候補は二つあります。
- スクリプトで
role="region"とaria-labelledbyを付け外す。 - パネル直下の要素でコンテンツを囲むようにし、その要素(今回の例だと
.accordion__content)に対してrole="region"とaria-labelledbyを付けるようにする。
前者は試していませんが、後者であれば、挙動としては問題なさそうでした。(ただし、構造としてありなのかどうか自信はありません。)
結論
role="region"を付けるとしても、hidden="until-found" を使用していない時、かつ、複数同時展開を禁止している時(今回の例だとdata-allow-multipleがない時)のみにするのが無難そうです。
WAI-ARIAを使ったアコーディオンの実装例
さて、長々と書いてきましたが、ここからは実装例を紹介していきましょう。
hidden="until-found" を使う例
今回目標としていた、WAI-ARIA と hidden="until-found"を使ったアコーディオンの実装例を紹介します。
/* (装飾) */
.accordions {
--bdc: hsl(210, 16%, 80%);
--bdrs: 8px;
overflow: clip;
border-radius: var(--bdrs);
border: solid 1px var(--bdc);
}
/* (装飾)2つ目以降のアコーディオンには上部にボーダーを追加 */
.accordion + .accordion {
border-top: solid 1px var(--bdc);
}
.accordion {
--duration: 0.25s;
--icon-transform: rotate(0deg); /* icon 変化用の変数 */
}
/* 開いている時 */
.accordion[data-opened] {
--icon-transform: rotate(90deg);
}
.accordion__title {
font: inherit; /* 見出しタグのフォントを打ち消す */
background-color: color-mix(in srgb, var(--bdc), transparent 80%);
}
.accordion__button {
display: flex;
background: none;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1.25rem 1rem;
gap: 1rem;
/* button のスタイルリセット */
height: auto;
border: none;
font-size: inherit;
color: inherit;
text-align: inherit;
cursor: pointer;
}
.accordion__panel {
position: relative;
overflow: hidden;
height: 0px;
transition: height var(--duration);
/* jsで変数にセットする */
--height--opened: auto;
}
/* 開いている時 */
[data-opened] > .accordion__panel {
height: var(--height--opened);
}
/* パネルが完全に閉じている時にabsoluteで飛ばしておくと、Chromeでも検索時に正常にハイライトされるようになる(なぜかは不明) */
[hidden] > .accordion__content{
position: absolute;
}
/* コンテンツの paddingは __panel ではなくこっちにつける */
.accordion__content {
padding: 1.25rem 1rem;
}
/* コンテンツ間の余白 */
.flow > * + *{
margin-block-start: 1rem;
}
/* アイコンの描画 */
.accordion__icon {
display: grid;
width: 1em;
height: 1em;
place-items: center;
flex-shrink: 0;
&::before,
&::after {
content: '';
display: block;
grid-area: 1 / 1;
background-color: currentColor;
}
&::before {
width: 0.1em;
height: 100%;
transition: transform var(--duration);
transform: var(--icon-transform);
}
&::after {
height: 0.1em;
width: 100%;
}
}
/* タイトルホバー時、背景色を少し濃くする */
@media (any-hover: hover) {
.accordion__title:hover {
background-color: color-mix(in srgb, var(--bdc), transparent 60%);
}
}
/* Tabキーでのフォーカス時も同じスタイリング */
.accordion__title:has(> :focus-visible) {
background-color: color-mix(in srgb, var(--bdc), transparent 60%);
}
/* フォーカス時のアウトラインを少し内側に寄せる( overflow:hidden によって途切れてしまうので) */
.accordion__button:focus-visible {
outline: solid 2px currentColor;
outline-color: revert;
outline-offset: -3px; /* offset 調整だけだとブラウザ間の差が大きい */
}
/* --- (任意) フォーカスリングの角丸調整 --- */
.accordion:first-child .accordion__button {
border-top-left-radius: var(--bdrs);
border-top-right-radius: var(--bdrs);
}
.accordion:last-child:not([data-opened]) .accordion__button {
border-bottom-left-radius: var(--bdrs);
border-bottom-right-radius: var(--bdrs);
}
/* --- 「視差効果を減らす」設定を考慮 --- */
@media (prefers-reduced-motion: reduce) {
.accordion{
--duration: 0s;
}
}
/* --- JSオフ環境の考慮 --- */
@media (scripting: none) {
.accordion__panel {
height: auto !important;
content-visibility: visible !important;
}
.accordion__content{
position: static !important;
}
} data-allow-multiple を使う例
次に、data-allow-multiple で複数同時展開を許可する例を紹介します。
また、role="region" は除去しています。
<div class="accordions" data-allow-multiple>
<div class="accordion">
<h3 class="accordion__title">
<button class="accordion__button" type="button" aria-expanded="false" aria-controls="AccID-panel--01">
アコーディオン01 (allow-multiple)
<span class="accordion__icon" aria-hidden="true"></span>
</button>
</h3>
<div class="accordion__panel" id="AccID-panel--01" hidden="until-found">
<div class="accordion__content flow">
<p>アコーディオン 01 のコンテンツです。<a href="!">このリンク</a>はフォーカス確認のためのダミーリンクです。</p>
<h4>これはパネルコンテンツ内の見出し要素です。</h4>
<p>ここに並んでいるアコーディオンは、同時に複数展開可能です。その代わり、role="region" は除去しています。</p>
</div>
</div>
</div>
<div class="accordion">
<h3 class="accordion__title">
<button class="accordion__button" type="button" aria-expanded="false" aria-controls="AccID-panel--02">
アコーディオン 02
<span class="accordion__icon" aria-hidden="true"></span>
</button>
</h3>
<div class="accordion__panel" id="AccID-panel--02" hidden="until-found">
<div class="accordion__content flow">
<p>アコーディオン 02 のコンテンツです。</p>
<p>ダミーテキスト: ロレム・イプサムの座り雨。目まぐるしい文章の流れの中で、それは静かに歩く仮の言葉です。</p>
</div>
</div>
</div>
<div class="accordion">
<h3 class="accordion__title">
<button class="accordion__button" type="button" aria-expanded="false" aria-controls="AccID-panel--03">
アコーディオン 03
<span class="accordion__icon" aria-hidden="true"></span>
</button>
</h3>
<div class="accordion__panel" id="AccID-panel--03" hidden="until-found">
<div class="accordion__content flow">
<p>アコーディオン 03 のコンテンツです。</p>
<p>ダミーテキスト: ロレム・イプサムの座り雨。目まぐるしい文章の流れの中で、それは静かに歩く仮の言葉です。</p>
</div>
</div>
</div>
</div> シンプルな hidden を使う例
初期状態を hidden とすることで、折り畳み時にページ検索の対象から除外します。
また、以下の例では、パネル側に data="region" を付けています。読み上げの変化も確認してみてください。
<div class="accordions">
<div class="accordion">
<h3 class="accordion__title">
<button class="accordion__button" type="button" id="AccID-label--01" aria-expanded="false" aria-controls="AccID-panel--01">
アコーディオン01 (ふつうの hidden)
<span class="accordion__icon" aria-hidden="true"></span>
</button>
</h3>
<div class="accordion__panel" id="AccID-panel--01" role="region" aria-labelledby="AccID-label--01" hidden>
<div class="accordion__content flow">
<p>アコーディオン 01 のコンテンツです。<a href="!">このリンク</a>はフォーカス確認のためのダミーリンクです。</p>
<h4>これはパネルコンテンツ内の見出し要素です。</h4>
<p>ここに並ぶアコーディオンのコンテンツは、折り畳まれている間はページ内検索の対象外となります。</p>
</div>
</div>
</div>
<div class="accordion">
<h3 class="accordion__title">
<button class="accordion__button" type="button" id="AccID-label--02" aria-expanded="false" aria-controls="AccID-panel--02">
アコーディオン 02
<span class="accordion__icon" aria-hidden="true"></span>
</button>
</h3>
<div class="accordion__panel" id="AccID-panel--02" role="region" aria-labelledby="AccID-label--02" hidden>
<div class="accordion__content flow">
<p>アコーディオン 02 のコンテンツです。</p>
<p>ダミーテキスト: ロレム・イプサムの座り雨。目まぐるしい文章の流れの中で、それは静かに歩く仮の言葉です。</p>
</div>
</div>
</div>
<div class="accordion">
<h3 class="accordion__title">
<button class="accordion__button" type="button" id="AccID-label--03" aria-expanded="false" aria-controls="AccID-panel--03">
アコーディオン 03
<span class="accordion__icon" aria-hidden="true"></span>
</button>
</h3>
<div class="accordion__panel" id="AccID-panel--03" role="region" aria-labelledby="AccID-label--03" hidden>
<div class="accordion__content flow">
<p>アコーディオン 03 のコンテンツです。</p>
<p>ダミーテキスト: ロレム・イプサムの座り雨。目まぐるしい文章の流れの中で、それは静かに歩く仮の言葉です。</p>
</div>
</div>
</div>
</div> スクリプトのポイント解説
最初にhidden属性の状態を読み取ってく
その初期値を、アコーディオンの展開に合わせて付け外しするようにしています。
// hidden を付け外しする時の値let ACCORDION_HIDDEN_VALUE = 'until-found';
/* ... 略... */
// until-found のオン・オフが使い分けれるように、最初に初期値を取得 if (panel.hasAttribute('hidden')) { ACCORDION_HIDDEN_VALUE = panel.getAttribute('hidden'); }初期状態で開いている(hidden属性がない)アコーディオンの場合、ACCORDION_HIDDEN_VALUEの初期値である'until-found'がセットされるようにしています。
'beforematch'イベントにも開閉処理を登録する
hidden="until-found"が使用された場合は、ヒット判定時に'beforematch'イベントが発火するようになっています。
なので、'click'イベントと同じ処理(toggleAccordion())を登録しておきます。
// clickイベント登録button.addEventListener('click', function (e) { e.preventDefault(); // hidden="until-found" の自動付け外しを無効化 toggleAccordion(accordion); // ここで属性値を処理する});
// beforematchイベント登録 (ページ検索時などはこっちが発火する)panel.addEventListener('beforematch', function (e) { e.preventDefault(); // hidden="until-found" の自動付け外しを無効化 toggleAccordion(accordion); // ここで属性値を処理する});details がページ内検索で展開される時は 'toggle' イベントが発火するのですが、そっちはopen属性がブラウザで自動切り替えされた後に発火してしまいます。それに対し、この'beforematch' イベントはhiddenの自動付け外し前に発火するので、'click'と同じ処理を登録できるという点がWAI-ARIA+until-foundで実装するメリットの一つです。
waitFrame / waitAnimation で タイミングを調整する
アニメーションをスムーズに行うために、hidden付け外し、data-open属性の付け外し、高さ(--height--opened)のセットを行う順序やタイミングが重要になります。(hiddenの有無でコンテンツのレンダリング状態が変わるため)
そこで、実装コード内のいくつかのポイントで、waitFrame で 1フレーム だけ処理をずらしたり、waitAnimation でアニメーションが完了するのを待ったりして、タイミングを調整しています。
// 1フレーム待機する関数export const waitFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));export const waitAnimation = async (panel) => { const animations = panel.getAnimations();
// アニメーションがなければ 'finished' を返す if (animations.length === 0) return 'finished';
// allSettled を使うことで、キャンセル時も例外にならずに結果を取得できる const results = await Promise.allSettled(animations.map((a) => a.finished));
// ( pause()で止めた時用 )rejected があれば false return results.every((r) => r.status === 'fulfilled') ? 'finished' : 'canceled';};また、このwaitAnimation()で返り値を文字列で返すようにしているのにも、理由があります。
クリック連打への対応
アコーディオンヘッダーを連続でクリックされた時に、すぐにそのクリックに反応して展開処理が逆向きに切り替わるようにしています。
今回の実装ではheightをアニメーションさせており、--height--openedでその値をコントロールしていますが、この変数を残しておくとウインドウがリサイズされた時に表示が崩れてしまうので、アニメーション完了後は削除(autoにする)必要があります。
しかし、アニメーションを一時停止してすぐに展開処理を切り替える時に変数が一瞬でも削除されるとautoへ値が一瞬飛び、次の高さをすぐにセットしても、スムーズに高さのアニメーションがつながりません。
そこで、一時停止された時はこの変数をつけっぱなしにする必要があり、waitAnimation()の返り値でそれを判定するようにしています。
/* open/close処理内にて */
// アニメーションがあれば一時停止maybePauseAnimation(panel);
// 変数をセットするpanel.style.setProperty('--height--opened', 目標の高さ );
// アニメーションを待つconst status = await waitAnimation(panel);
// アニメーションが最後まで完了した時のみ、変数を削除するif ('finished' === status) { // (クリック連打で pause() が走った時はここはスキップされる) panel.style.removeProperty('--height--opened');}export const maybePauseAnimation = (panel) => { const animations = panel.getAnimations(); if (animations.length === 0) return; animations.forEach((a) => a.pause());};その他のアクセシビリティ対応
WAI-ARIA対応・ページ検索対応の他に今回の例で実装しているアクセシビリティ対応を紹介しておきます。
「視差効果を減らす」設定を考慮する
一部のユーザーはアニメーションによって気分が悪くなることがあり、OSの設定で「視差効果を減らす」設定をオンにしている可能性があります。
そのようなユーザーには、アニメーションを控えめにするか無効にすることが推奨されています。
prefers-reduced-motionメディアクエリを使って、アニメーションの緩和を行っておきましょう。今回の例では、スライドアニメーションは完全にオフにしています。
@media (prefers-reduced-motion: reduce) { .accordion{ --duration: 0s; }}JavaScriptが無効になっている環境を考慮する
ブラウザの設定で JavaScript が 無効になっていて使用不可の時のことも考慮し、CSSで強制的にコンテンツを表示させるようにしています。
@media (scripting: none) { .accordion__panel { height: auto !important; content-visibility: visible !important; } .accordion__content{ position: static !important; }}今回のデモでは@media (scripting: none)を使っていますが、noscriptタグで、必要な時のみ読み込むようにしておくのがいいとは思います。
まとめ: details/summary と WAI-ARIA の比較
details/summary を使うべきかどうかはその人・その現場次第ですが、最後にそれぞれのポイントを比較しておきます。
| details/summary | WAI-ARIA実装 | |
|---|---|---|
| 見出しロールの検出 | 不安定(ただし、ほとんど改善されてそう) | 安定 |
| 開閉操作 | デフォルトで備わっている | JSを使って実装する |
| 開閉操作(アニメーション付き) | モダンブラウザであればCSSだけでもできるが、そうでない場合はJSが必要 | JSを使って実装する |
| キーボード操作 | デフォルトで備わっている | WAI-ARIAの知識が必要 |
| 折り畳み時のテキスト検索 | デフォルトでサポート | hidden=“until-found” を使うことで対応可能 |
| 折り畳み時のテキスト検索を対象外にできるか | できない | できる |
| 検索ヒット時の自動展開時のアニメーション | クリック時と同じ挙動にしづらい、できないこともある | クリック時と同じ挙動にできる |
| 同時展開を1つに制限 | name属性(アニメーションによっては独自実装が必要) | 独自実装が必要 |
| JavaScript無効時 | 開閉できる | 開閉動作は難しい(aタグ+until-foundを使えばなんとかなる) |
参考記事