SAMPLE

アコーディオン

INDEX

説明

アクセシビリティに配慮したアコーディオンの実装例をご紹介します。

完成版

1. WAI-ARIAによる実装例

【簡易版】

【応用版】

2. details、summaryによる実装例

使用するライブラリ

なし(※簡易版のみGSAPを使用)

手順

アコーディオンのアクセシビリティを改善する方法として、以下の2パターンが挙げられます。

・WAI-ARIAを使用した例
detailssummaryタグを使用した例

今回はWAI-ARIAでの実装例をベースに解説したのち、detailssummaryを使用した実装例についてもご紹介します。
また、コードが難解すぎるという方向けに、アクセシビリティの要件を最低限考慮した簡易版も作成していますので、一緒に解説していきます。

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で記述したtransitionoranimationプロパティが完了するまで待機するためのメソッドです。

(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のdetailssummaryタグを使用すると、これらを気にすることなくキーボード操作やスクリーンリーダーの読み上げに対応させることができます。

<!-- 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();
});

以下はdetailssummaryのデフォルトの動作です。(抜粋)

・キーボード操作(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
      }
    }
  }

ここではdetailsdata-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

ランドマーク|ウェブアクセシビリティ実践的用語集