SAMPLE
アコーディオン
INDEX
説明
アクセシビリティに配慮したアコーディオンの実装例をご紹介します。
完成版
1. WAI-ARIAによる実装例
【簡易版】
【応用版】
2. details、summaryによる実装例
使用するライブラリ
なし(※簡易版のみGSAPを使用)
手順
アコーディオンのアクセシビリティを改善する方法として、以下の2パターンが挙げられます。
・WAI-ARIAを使用した例
・details
、summary
タグを使用した例
今回はWAI-ARIAでの実装例をベースに解説したのち、details
、summary
を使用した実装例についてもご紹介します。
また、コードが難解すぎるという方向けに、アクセシビリティの要件を最低限考慮した簡易版も作成していますので、一緒に解説していきます。
1. ベースコーディング
実装に入る前にアコーディオンの構成要素について触れておきます。
ARIA APG(W3Cが公開しているWAI-ARIAを用いたウェブコンテンツ制作ガイド)では、以下のように説明されています。
・アコーディオンパネル
アコーディオンヘッダーに関連付けられたコンテンツのセクション。・アコーディオンヘッダー
コンテンツのセクションを表すラベルまたはサムネイル。コンテンツのセクションを表示するためのコントロールとしても機能する。
さらに推奨のHTMLタグについても詳しく触れられています。
・アコーディオンヘッダーには、ボタン要素(
role="button"
)を含める。
・各アコーディオンヘッダーボタンは、ページの情報アーキテクチャに適したaria-level
の値が設定された見出し要素(role="heading"
)でラップされる。
・見出し要素内にはボタン要素のみを含める。
まずはこの基準に則ってマークアップを行います。
<!-- 簡易版 -->
<section class="accordion js-accordion">
<h3 class="heading">
<button type="button" class="trigger">
EVOLABとは
</button>
</h3>
<div class="panel" aria-hidden="true">
<div class="panel-inner">
<p>EVOWORXのエンジニアによるWeb制作会社向けの情報ポータルです。</p>
</div>
</div>
</section>
<!-- 応用版 -->
<section class="accordion js-accordion">
<h3 class="heading">
<a class="trigger" role="button" href="#panel1">
EVOLABとは
</a>
</h3>
<div id="panel1" class="panel" hidden="until-found">
<div class="panel-inner">
<p>EVOWORXのエンジニアによるWeb制作会社向けの情報ポータルです。</p>
</div>
</div>
</section>
アコーディオン全体を囲む入れ物として、.accordion
を準備し、その中にアコーディオンヘッダーとアコーディオンパネルを設置。アコーディオンヘッダーには、見出し要素(h3
)、その中にrole="button"
要素を配置していきます。
また、アコーディオンパネルには、簡易版ではaria-hidden
属性、応用版ではhidden
属性を付与し、コンテンツを非表示にしています。
なお、role="button"
属性をわざわざ付けずとも、簡易版のようにbutton
要素を使用すればアコーディオンヘッダーの要件的に問題ないのですが、応用版ではあえてa
要素を採用しています。
この理由については、hidden="until-found"
の挙動が関係しています。(MDN より抜粋)
hidden until found の状態では、要素は非表示になりますが、ブラウザーの「ページ内検索」機能やフラグメントナビゲーションでは、そのコンテンツにアクセスできます。これらの機能によって hidden until found サブツリーの要素にスクロールが発生した場合、ブラウザーは次のようになります。
・非表示の要素に
beforematch
イベントが発生します
・その要素からhidden
属性を取り除きます
・要素までスクロールします
要約すると、ページ内検索︎(Ctrl + Fなど)でアコーディオンパネル内にヒットする文字列があった場合や、ページ内リンク(アンカーリンク)を自動的に検知してhidden
属性が取り除かれるというものです。
アンカーリンクの挙動によって、JavaScriptが無効の環境下においてもアコーディオンパネルを展開することができるので、今回はa
要素を使用しています。
なお、2024年10月現在でhidden="until-found"
はGoogle ChromeとMicrosoft Edgeのみサポートされていますが、サポートされていないSafariやFirefoxでは通常のhidden
属性と同じ挙動になります。(display:none;
のような状態)
後ほど紹介するJavaScriptを使用した開閉アニメーションの実装にてそのまま活用できますので、将来的なサポートを見越して導入しています。
2. WAI-ARIAの設置
次に、WAI-ARIAを設置していきます。
使用する属性についても、ARIA APGにて詳しく説明されています。(一部抜粋)
・アコーディオンヘッダーに関連付けられたアコーディオンパネルが表示されている場合はヘッダーボタン要素の
aria-expanded="true"
、表示されていない場合はaria-expanded="false"
に設定される。
・アコーディオンヘッダーボタン要素には、アコーディオンパネルのコンテンツを含む要素のIDがaria-controls
に設定される。
上記をもとに先ほどのマークアップを修正します。
<!-- 簡易版 -->
<section class="accordion js-accordion">
<h3 class="heading">
<button type="button" class="trigger" aria-expanded="false" aria-controls="panel1">
EVOLABとは
</button>
</h3>
<div id="panel1" class="panel" aria-hidden="true">
<div class="panel-inner">
<p>EVOWORXのエンジニアによるWeb制作会社向けの情報ポータルです。</p>
</div>
</div>
</section>
<!-- 応用版 -->
<section class="accordion js-accordion">
<h3 class="heading">
<a class="trigger" role="button" href="#panel1" aria-expanded="false" aria-controls="panel1">
EVOLABとは
</a>
</h3>
<div id="panel1" class="panel" hidden="until-found">
<div class="panel-inner">
<p>EVOWORXのエンジニアによるWeb制作会社向けの情報ポータルです。</p>
</div>
</div>
</section>
WAI-ARIAの設置により、スクリーンリーダー上でアコーディオンパネルの展開状態が読み上げられるようになります。なお、パラメーターの動的な切り替えについては、後ほどJavaScriptで実装します。
そのほかaria-disabled
やregion roleに関する説明もありますが、今回は汎用的なアコーディオンメニューを作成するため、これらの属性は省略しています。詳細はARIA APGをご参照ください。
3. キーボード操作について
次に、キーボード操作による挙動を見ていきます。
ARIA APGでは以下のように説明されています。(一部抜粋)
・EnterまたはSpaceキー
アコーディオンヘッダーにフォーカスがある場合、そのアコーディオンヘッダーに関連づけられたアコーディオンパネルを開閉する。・Tabキー
次のフォーカス可能な要素にフォーカスを移動する。・Shift + Tabキー
前のフォーカス可能な要素にフォーカスを移動する。・Down Arrowキー(オプション)
アコーディオンヘッダーにフォーカスがある場合、次のアコーディオンヘッダーにフォーカスを移動する。フォーカスが最後のアコーディオンヘッダーにある場合、何も行わないか、最初のアコーディオンヘッダーにフォーカスを移動する。・Up Arrowキー(オプション)
アコーディオンヘッダーにフォーカスがある場合、前のアコーディオンヘッダーにフォーカスを移動する。フォーカスが最初のアコーディオンヘッダーにある場合、何も行わないか、最後のアコーディオンヘッダーにフォーカスを移動する。・Homeキー(オプション)
アコーディオンヘッダーにフォーカスがある場合、最初のアコーディオンヘッダーにフォーカスを移動する。・Endキー(オプション)
アコーディオンヘッダーにフォーカスがある場合、最後のアコーディオンヘッダーにフォーカスを移動する。
今回はオプションについては省略し、Enter
またはSpace
キー、Tab
キー、Shift + Tab
キーへの対応を行います。
簡易版のようにbutton
要素を使用した場合、追加の処理を書かずとも上記のキー操作(オプション以外)には対応できていますが、a
要素を使用した場合、Space
キーを使用した展開ができないため、JavaScriptで処理を追加する必要があります。
4. JavaScriptの実装
ここまでの要件を踏まえて、JavaScriptでアコーディオンの動作を実装します。
ARIA APGのAccordion Exampleをベースに作成しています。
【簡易版】
import gsap from 'gsap'
const accordionInit = () => {
const accordion = document.querySelectorAll('.js-accordion')
if (!accordion) return
accordion.forEach((el) => {
const trigger = el.querySelector('button[aria-expanded]')
const panel = el.querySelector(`#${trigger.getAttribute('aria-controls')}`)
let isOpen = trigger.ariaExpanded === 'true'
const tl = gsap.timeline({ paused: true })
tl.fromTo(
panel,
{
height: 0,
},
{
height: 'auto',
duration: 0.3,
}
)
tl.progress(isOpen ? 1 : 0)
trigger.addEventListener('click', () => {
isOpen = !isOpen
trigger.ariaExpanded = isOpen
panel.ariaHidden = !isOpen
tl[isOpen ? 'play' : 'reverse']()
})
})
}
// 実行
accordionInit()
簡易版ではアニメーション実装のハードルを下げるためにGSAPを使用しました。aria-expanded
の値を軸にisOpen
というフラグを準備し、フラグの状態に応じてWAI-ARIAの値とアニメーションを切り替えるシンプルな作りとなっています。
【応用版】
class Accordion {
constructor(el) {
// DOM要素
this.el = el
if (!this.el) return
this.trigger = null
this.panel = null
// インスタンス
this.observer = null
// フラグ
this.isOpen = null
this.isAnimating = false
}
// 初期化
init() {
this.trigger = this.el.querySelector('[role="button"][aria-expanded]')
this.panel = this.el.querySelector(`#${this.trigger.getAttribute('aria-controls')}`)
this.isOpen = !this.panel.hidden
this.panel.style.height = this.isOpen ? '' : '0'
this._observeHidden()
this.trigger.addEventListener('click', this._toggle.bind(this))
this.trigger.addEventListener('keydown', (e) => {
e.key === ' ' && this._toggle()
})
}
// 破棄
destroy() {
this.isAnimating = false
if (this.trigger) {
this.trigger.removeEventListener('click', this._toggle.bind(this))
this.trigger.removeEventListener('keydown', this._toggle.bind(this))
this.trigger = null
}
if (this.panel) {
this.panel = null
}
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
}
// アニメーションの待機
async _waitAnimation(target) {
const animations = target.getAnimations()
if (animations.length === 0) {
return Promise.resolve()
} else {
await Promise.allSettled(animations.map((animation) => animation.finished))
}
}
// アコーディオンの開閉
async _toggle(e) {
e.preventDefault()
if (this.isAnimating) return
this.isAnimating = true
this.isOpen = !this.isOpen
this.trigger.ariaExpanded = this.isOpen
if (this.isOpen) {
this.panel.hidden = false
this.panel.style.height = `${this.panel.scrollHeight}px`
await this._waitAnimation(this.panel)
this.panel.style.height = 'auto'
this.isAnimating = false
} else {
this.panel.style.height = `${this.panel.scrollHeight}px`
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
this.panel.style.height = '0'
await this._waitAnimation(this.panel)
this.panel.hidden = 'until-found';
this.isAnimating = false
})
})
}
}
// hidden属性の監視(ページ内検索用)
_observeHidden() {
this.observer = new MutationObserver(() => {
if (this.isAnimating) return
this.isOpen = !this.panel.hidden
this.trigger.ariaExpanded = this.isOpen
this.panel.style.height = this.isOpen ? 'auto' : '0'
})
this.observer.observe(this.panel, {
attributes: true,
attributeFilter: ['hidden'],
})
}
}
// クラスの実行
const elements = document.querySelectorAll('.js-accordion')
elements.forEach((el) => {
const accordion = new Accordion(el)
accordion.init()
})
応用版ではクラス構文を用いて実装しています。以下はメソッドの内訳です。
(1) init
アコーディオンの実行メソッドです。DOM要素の取得や、アコーディオンの開閉を行うメソッド(_toggle)、hidden
属性が付与されているかを監視するメソッド(_observeHidden)ここで呼び出しています。
(2) destroy
アコーディオンクラスの破棄用メソッドです(オプション)
(3)_waitAnimation(element: HTMLElement)
DOM要素のAnimation
オブジェクトを検知し、CSSで記述したtransition
oranimation
プロパティが完了するまで待機するためのメソッドです。
(4)_toggle(e : MouseEvent)
アコーディオンの開閉用メソッドです。処理の詳細は以下となります。
・アニメーション中(this.isAnimating
)は処理をスキップ(連打対策)
・WAI-ARIAの値(aria-expanded
)を切り替え
・hidden
属性の付与/削除
・アコーディオンパネルの高さを変更
なお、requestAnimationFrame
(ブラウザのフレーム更新を待つメソッド)や_waitAnimation
で処理が複雑になっている理由として、hidden
属性のdisplay: none;
のような挙動により、アコーディオンパネルの高さ変更とhidden
属性の付け外しが同じタイミングで実行されると、アニメーションが適切に機能しないことが関係しています。
(5)_observeHidden
ページ内検索など、クリック or キーボード操作以外でhidden
属性が付与/削除されたかどうかを監視するメソッドです。this.isAnimating
フラグによって、クリック or キーボード操作による開閉動作との衝突を防ぎつつ、DOMの変更を監視するMutationObserver
APIを使用することで属性の監視を行っています。
なお、クラスの呼び出し方法については、インスタンス生成時にアコーディオンのwrap要素(ここでは.js-accordion
)を引数に指定し、インスタンスからinit()メソッドを呼び出すことで処理を実行できます。
details、summaryでの実装例
ここまで推奨HTMLタグの使用やWAI-ARIAの設定などを行ってきましたが、HTMLのdetails
、summary
タグを使用すると、これらを気にすることなくキーボード操作やスクリーンリーダーの読み上げに対応させることができます。
<!-- HTML -->
<details class='accordion js-accordion'>
<summary class='summary'>
<h3 class='heading'>
EVOLABとは
</h3>
</summary>
<div class='panel'>
<div class='panel-inner'>
<p>EVOWORXのエンジニアによるWeb制作会社向けの情報ポータルです。</p>
</div>
</div>
</details>
/* JavaScript */
class Accordion {
constructor(el) {
// DOM要素
this.details = el;
if (!this.details) return;
this.trigger = null;
this.panel = null;
// インスタンス
this.observer = null;
// フラグ
this.isOpen = null;
this.isAnimating = false;
}
// 初期化
init() {
this.trigger = this.details.querySelector("summary");
this.panel = this.trigger.nextElementSibling;
this.isOpen = this.details.open;
this.panel.style.height = this.isOpen ? "" : "0";
this._observeHidden();
this.trigger.addEventListener("click", this._toggle.bind(this));
}
// 破棄
destroy() {
if (this.trigger) {
this.trigger.removeEventListener("click", this._toggle.bind(this));
this.trigger = null;
}
if (this.panel) {
this.panel = null;
}
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
// アニメーションの待機
async _waitAnimation(target) {
const animations = target.getAnimations();
if (animations.length === 0) {
return Promise.resolve();
} else {
await Promise.allSettled(
animations.map((animation) => animation.finished)
);
}
}
// アコーディオンの開閉
async _toggle(e) {
e.preventDefault();
if (this.isAnimating) return;
this.isAnimating = true;
this.isOpen = !this.isOpen;
this.details.classList.toggle("is-open", this.isOpen);
if (this.isOpen) {
this.details.open = true;
this.panel.style.height = `${this.panel.scrollHeight}px`;
await this._waitAnimation(this.panel);
this.panel.style.height = "auto";
this.isAnimating = false;
} else {
this.panel.style.height = `${this.panel.scrollHeight}px`;
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
this.panel.style.height = "0";
await this._waitAnimation(this.panel);
this.details.open = false;
this.isAnimating = false;
});
});
}
}
// open属性の監視(ページ内検索用)
_observeHidden() {
this.observer = new MutationObserver(() => {
if (this.isAnimating) return;
this.isOpen = this.details.open;
this.details.classList.toggle("is-open", this.isOpen);
this.panel.style.height = this.isOpen ? "auto" : "0";
});
this.observer.observe(this.details, {
attributes: true,
attributeFilter: ["open"]
});
}
}
// クラスの実行
const elements = document.querySelectorAll(".js-accordion");
elements.forEach((el) => {
const accordion = new Accordion(el);
accordion.init();
});
以下はdetails
、summary
のデフォルトの動作です。(抜粋)
・キーボード操作(
Enter
またはSpace
キー、Tab
キー、Shift + Tab
)による開閉、フォーカス移動が可能。
・スクリーンリーダーでアコーディオンの開閉状態を読み上げられる。
・summary
要素(アコーディオンヘッダー)をクリックするとdetails
要素にopen
属性が付与され、summary
の兄弟要素(アコーディオンパネル)が表示される。
・ページ内検索時にアコーディオンパネル内にヒットする文字列があった場合、details
要素にopen
属性が付与される。(※Chrome系ブラウザのみ)
open
属性を使用してアコーディオンパネルの表示を切り替えることができますが、一瞬で表示/非表示(display: none;
に近い挙動)になってしまうため、hidden
属性と同様、requestAnimationFrame
などを使用してopen
属性の付与タイミングを調整しています。
なお、details
はARIAロールgroup
に分類されており、目次やページの要約に含まれないユーザーインターフェイスとして識別されてしまうため、スクリーンリーダーのランドマークへジャンプする機能をアコーディオンに適応させたい場合などは、WAI-ARIA+ランドマークロールを持つHTML要素での実装が適切だとされています。
おまけ
ここまでの内容でアクセシビリティ的には概ね問題ないのですが、補足として追加で3つほどTipsをご紹介します。
1. アコーディオンパネルのアニメーション
先ほどご紹介したアコーディオンパネルのアニメーションでは、直接height
プロパティを操作していましたが、この方法以外でも実装できます。
今回はgrid-template-rows
プロパティとChrome129より追加されたcalc-size
プロパティを使用して実装してみました。
詳細は以下のデモをご参照ください。
・grid-template-rowsでの実装例
・calc-sizeでの実装例(Chrome系129以上のみ)
2. 排他的なアコーディオン
details
要素ではname
属性を使用することで、同じname
属性の値を持つグループ内で展開するアコーディオンを1つに限定する(他のアコーディオンが開いている状態で、別のアコーディオンをクリックすると、クリックした以外のアコーディオンを閉じる)ことができます。
<details class="accordion" name="groupA">
<summary class="summary">
<h3 class="heading">EVOLABとは</h3>
</summary>
<div class="panel"><!-- 略 --></div>
</details>
<details class="accordion" name="groupA">
<summary class="summary">
<h3 class="heading">BLOG</h3>
</summary>
<div class="panel"><!-- 略 --></div>
</details>
<details class="accordion" name="groupA">
<summary class="summary">
<h3 class="heading">SAMPLE</h3>
</summary>
<div class="panel"><!-- 略 --></div>
</details>
なお、排他的なアコーディオンでは同じname
グループ内でopen
属性が2つ以上存在してはいけない、という制約があるため、
何かしらの理由でopen
属性を共存させたい場合は、name
属性を動的に削除するなどの対応が必要になります。
そんなこと滅多にないだろうと思うかもしれませんが、最後にご紹介する「印刷時にアコーディオンを開く」実装では、この対応が必要になります。
3. 印刷時にアコーディオンを展開する
先ほどご紹介した排他的なアコーディオンでは、同じname
グループ内で1つしかアコーディオンを開けないため、何らかの理由でアコーディオンパネル内のコンテンツをプリントアウトしたい場合に、1つずつ開閉して1枚ずつ印刷するような形になってしまいます。
よってオプションとして印刷時に全てのdetails
タグにopen
属性を付与する処理を追加します。
class Accordion {
constructor(el) {
this.details = el
if (!this.details) return
this.trigger = null
this.panel = null
this.detailsName = null /* 追加 */
this.observer = null
this.isOpen = null
this.isAnimating = false
this.isPrint = null /* 追加 */
}
// 初期化
init() {
/* 略 */
/* 追加 */
this.detailsName = this.details.getAttribute('name') || null
this.isPrint = this.details.hasAttribute('data-print')
if (this.isPrint) {
window.addEventListener('beforeprint', this._handlePrint.bind(this))
window.addEventListener('afterprint', this._handlePrint.bind(this))
}
}
/* 略 */
/* 追加 */
_handlePrint(e) {
if (e.type === 'beforeprint') {
if (this.detailsName) this.details.removeAttribute('name')
this.details.open = true
} else if (e.type === 'afterprint') {
if (this.detailsName) this.details.setAttribute('name', this.detailsName)
this.details.open = false
}
}
}
ここではdetails
にdata-print
属性が付与されているかどうかで印刷時の展開可否をコントロールしています。
また、先ほども説明した通り、open
属性を共存させるために、初期化時にname
属性を保持しておき、印刷前後で属性の付与/削除を行っています。
参考文献
・Accordion Pattern (Sections With Show/Hide Functionality) | APG | WAI | W3C
・hidden - HTML: ハイパーテキストマークアップ言語 | MDN
・<details>: 詳細折りたたみ要素 - HTML: ハイパーテキストマークアップ言語 | MDN
・<summary>: 概要明示要素 - HTML: ハイパーテキストマークアップ言語 | MDN
・calc-size() - CSS: Cascading Style Sheets | MDN
・Element: getAnimations() メソッド - Web API | MDN
・MutationObserver - Web API | MDN
・タブやアコーディオンの非表示コンテンツにはhidden="until-found"を使うべし – TAKLOG
・details要素のname属性を使用した排他的なアコーディオンの実装例 – TAKLOG
・アクセシブルなアコーディオンの実装について考える - Zenn
・detailsとsummaryタグで作るアコーディオンUI - アニメーションのより良い実装方法 - ICS MEDIA