アコーディオンでFAQを作りたいけれど、「1つだけ開く挙動」と「複数同時に開く挙動」のどちらにすべきか迷うことはないでしょうか。「すべて開く / 閉じる」ボタンを置きたいけれど、ボタンの状態管理と支援技術への通知をどこまでやればよいか分からない、という相談もよく耳にします。

この記事では、バニラJavaScript と ARIA 対応で作る2つの開閉制御パターン、複数同時オープン型(一括コントロール付き)と排他制御型(常に最大1つだけ開く)を、コード付きで対比します。a11y 通知の入れ方、状態同期の設計、ユースケース別の選び方、そして共通実装の核(setItemState を真実の源にする設計)まで踏み込みます。

実際の動きを確認できるデモと、GitHub のソースコードも公開しています。先に完成物のイメージをつかんでおくと、本文の解説がより理解しやすくなります。

🌐 デモ1: 複数同時オープン型 + 一括コントロール(GitHub Pages)

🌐 デモ2: 排他制御型・1つだけ開く(GitHub Pages)

💻 ソースコード1: 004_multi-open(GitHub)

💻 ソースコード2: 005_single-open(GitHub)

この記事で分かること
  • 複数同時オープン型と排他制御型、2つの開閉制御パターンの実装コード
  • 「すべて開く / すべて閉じる」一括ボタンの設計と disabled 同期の組み方
  • スクリーンリーダーへの通知(aria-live)が必要な場面・不要な場面
  • どちらを選べばよいかの判断軸(FAQ・モバイル・設定パネル別)
  • 2パターンを支える共通実装(setItemState を真実の源にする設計)

2つの開閉制御パターンと選び方の早見

開閉制御の現実的な選択肢は2つです。違いと共通点を早見表で押さえておきます。

観点パターン1: 複数同時オープン型パターン2: 排他制御型
同時に開ける項目数複数(無制限)1つだけ
推奨ユースケース各項目が独立した情報・複数を比較したい・設定パネルFAQ で焦点を絞らせたい・モバイルで画面領域が狭い場面
副次機能「すべて開く / すべて閉じる」一括コントロール(他項目を自動クローズする排他ロジック)
a11y 通知一括操作時に aria-live で通知個別 aria-expanded のみ(追加通知なし)
共通実装の核setItemState(header, open) を真実の源として一元化(両パターンで同シグネチャ)setItemState(header, open) を真実の源として一元化(両パターンで同シグネチャ)

迷ったら、各項目が独立しているか・1つに集中させたいかで決めるのが穏当です。設定パネルや比較リストならパターン1、FAQ や狭い画面領域ならパターン2、という棲み分けで考えるとシンプルになります。

a11y 通知の行が示すとおり、一括操作を入れるかどうかでスクリーンリーダーへの通知設計も変わります。一括ボタンを置くなら aria-live 領域がセットで必要、置かないなら個別 aria-expanded の切替だけで足ります。共通実装の核については本記事の後半で改めて整理します。

パターン1: 複数同時オープン型 +「すべて開く / すべて閉じる」一括コントロール

各項目を独立して開閉できる構造に、「すべて開く / すべて閉じる」一括コントロールを追加した実装です。複数項目を見比べたい・設定パネルで並行編集したい場面に向きます。コピペで動作する完成形を先に提示します。

HTML
<!-- 一括操作コントロール
     c-accordion の外側に置く(c-accordion 側の overflow:hidden に巻き込まれないため)
     data-bulk-controls の値に対象 c-accordion の id を入れることで、
     同一ページに複数アコーディオンが共存しても破綻しない設計にしている。
     スクリーンリーダー向けライブリージョンも同じユニット内に置き、
     JS 側で bulkRoot を起点にスコープ取得することで他アコーディオン用の
     status 領域に書き込むのを防ぐ。 -->
<div class="c-accordion__bulk-controls" data-bulk-controls="c-accordion-multi-open">
  <button
    type="button"
    class="c-accordion__bulk-button"
    data-bulk-action="open"
    aria-controls="c-accordion-multi-open"
  >
    すべて開く
  </button>
  <button
    type="button"
    class="c-accordion__bulk-button"
    data-bulk-action="close"
    aria-controls="c-accordion-multi-open"
  >
    すべて閉じる
  </button>
  <!-- スクリーンリーダー向けライブリージョン
       一括操作の状態変化(全開/全閉)を画面外で通知する。
       視覚的には sr-only パターンで隠している。 -->
  <div
    class="c-accordion__sr-status"
    role="status"
    aria-live="polite"
    data-bulk-status
  ></div>
</div>

<div class="c-accordion" data-accordion id="c-accordion-multi-open">
  <!-- 項目 1 -->
  <div class="c-accordion__item">
    <h2 class="c-accordion__heading">
      <button
        type="button"
        class="c-accordion__header"
        aria-expanded="false"
        aria-controls="c-accordion-multi-panel-1"
        id="c-accordion-multi-header-1"
      >
        <span class="c-accordion__title">複数同時オープン型はどんな場面で使う?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-multi-panel-1"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          各項目が独立した情報を持ち、ユーザーが複数項目を同時に参照したい場面に向いています。
          たとえばFAQで複数の質問を見比べたい・設定パネルで複数セクションを並行して編集したい等のユースケースです。
        </p>
        <p>
          逆に「読ませたい順序がある」「画面領域を1項目分しか確保できない」場合は排他制御型(snippet 005 single-open)が向いています。
        </p>
      </div>
    </div>
  </div>

  <!-- 項目 2 -->
  <div class="c-accordion__item">
    <h2 class="c-accordion__heading">
      <button
        type="button"
        class="c-accordion__header"
        aria-expanded="false"
        aria-controls="c-accordion-multi-panel-2"
        id="c-accordion-multi-header-2"
      >
        <span class="c-accordion__title">「すべて開く/すべて閉じる」ボタンは何のため?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-multi-panel-2"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          項目が10個・20個と増えると「順に開いていく」「個別に閉じていく」操作の手数が地味に重くなります。
          一括操作ボタンを置くと、ユーザーは「全体を一度に俯瞰したい」「読み終わったので畳みたい」というニーズに即応できます。
        </p>
        <p>
          本スニペットでは、現在の状態に応じてボタンを <code>disabled</code> 化します。
          全項目が閉じている時は「すべて閉じる」を、全項目が開いている時は「すべて開く」を無効化することで、
          「押しても何も起きないボタン」を視覚的・支援技術的に明示しています。
        </p>
      </div>
    </div>
  </div>

  <!-- 項目 3 -->
  <div class="c-accordion__item">
    <h2 class="c-accordion__heading">
      <button
        type="button"
        class="c-accordion__header"
        aria-expanded="false"
        aria-controls="c-accordion-multi-panel-3"
        id="c-accordion-multi-header-3"
      >
        <span class="c-accordion__title">個別操作と一括操作の状態同期はどう担保している?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-multi-panel-3"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          個別ヘッダーのクリック・一括ボタンのクリックのいずれの経路でも、
          最終的に同じ <code>setItemState(header, open)</code> 関数を経由して
          <code>aria-expanded</code><code>hidden</code> を同時更新する設計にしています。
        </p>
        <p>
          操作後は <code>updateBulkButtonsState()</code> で全項目の状態を見直し、
          「すべて開く/すべて閉じる」ボタンの <code>disabled</code> 状態をDOMに反映します。
          これにより個別経由・一括経由のどちらで操作しても、画面上の状態が常に一致します。
        </p>
      </div>
    </div>
  </div>

  <!-- 項目 4 -->
  <div class="c-accordion__item">
    <h2 class="c-accordion__heading">
      <button
        type="button"
        class="c-accordion__header"
        aria-expanded="false"
        aria-controls="c-accordion-multi-panel-4"
        id="c-accordion-multi-header-4"
      >
        <span class="c-accordion__title">スクリーンリーダーへの通知はどう実装している?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-multi-panel-4"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          個別ヘッダーの開閉は <code>aria-expanded</code> の切替で支援技術に状態が伝わるため、追加の通知は不要です。
        </p>
        <p>
          一方、一括操作はフォーカス位置(一括ボタン)と状態変化の起こる場所(個別項目)が離れているため、
          <code>role="status"</code> + <code>aria-live="polite"</code> を持つ視覚的に隠したライブリージョンに
          「すべて開きました/すべて閉じました」というテキストを差し込むことで、
          画面外で起きた変化をスクリーンリーダーに通知しています。
        </p>
      </div>
    </div>
  </div>
</div>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   snippet 001 と同じトークンセットを継承し、
   一括コントロール用に --accordion-color-bulk-* を追加。
   命名規則は snippet 001 (--accordion-color-{役割}) を踏襲。
   ================================================ */
:root {
  --accordion-color-text:        #2b2b2b;
  --accordion-color-text-muted:  #555;
  --accordion-color-border:      #e0e0e0;
  --accordion-color-bg:          #ffffff;
  --accordion-color-bg-hover:    #f5f7fa;
  --accordion-color-bg-active:   #eef3fa;
  --accordion-color-accent:      #0066cc;
  --accordion-color-focus-ring:  #0066cc;

  /* 一括コントロール用トークン(004 で追加) */
  --accordion-color-bulk-bg:        #f5f7fa;
  --accordion-color-bulk-bg-hover:  #e8edf3;
  --accordion-color-bulk-text:      #2b2b2b;
  --accordion-color-bulk-disabled:  #aaa;

  --accordion-radius:            8px;
  --accordion-header-padding-y:  16px;
  --accordion-header-padding-x:  20px;
  --accordion-body-padding-y:    16px;
  --accordion-body-padding-x:    20px;

  --accordion-transition:        0.2s ease;
}

/* ================================================
   ベースリセット(デモページ用)
   ================================================ */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
    "Hiragino Sans", "Noto Sans JP", sans-serif;
  color: var(--accordion-color-text);
  background-color: #fafafa;
  line-height: 1.7;
}

code {
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
  font-size: 0.92em;
  padding: 0.1em 0.35em;
  background-color: #eef0f3;
  border-radius: 4px;
}

/* ================================================
   l-main / p-section: デモページのレイアウト
   ================================================ */
.l-main {
  max-width: 720px;
  margin-inline: auto;
  padding: 40px 20px;
}

.p-section__title {
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 16px;
}

.p-section__text {
  margin: 0 0 24px;
  color: var(--accordion-color-text-muted);
}

@media (min-width: 768px) {
  .p-section__title {
    font-size: 28px;
  }
}

/* ================================================
   c-accordion__bulk-controls: 一括操作ボタンのラッパー
   c-accordion の外側に置くため、c-accordion 本体より
   先に記述してマージンで間隔を取る。
   ================================================ */
.c-accordion__bulk-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin: 0 0 16px;
}

/* ================================================
   c-accordion__bulk-button: 「すべて開く / すべて閉じる」ボタン
   c-accordion__header と同じトーンで揃えつつ、
   位置・形状で副次的なコントロールであることを示す。
   ================================================ */
.c-accordion__bulk-button {
  /* ボタンリセット */
  appearance: none;
  border: 1px solid var(--accordion-color-border);
  font: inherit;
  cursor: pointer;

  /* レイアウト */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 40px; /* タップ領域確保 */
  padding: 8px 16px;
  border-radius: var(--accordion-radius);

  /* テキスト */
  font-size: 14px;
  font-weight: 600;
  color: var(--accordion-color-bulk-text);

  /* 背景 */
  background-color: var(--accordion-color-bulk-bg);

  /* 状態遷移 */
  transition:
    background-color var(--accordion-transition),
    color var(--accordion-transition),
    border-color var(--accordion-transition);
}

@media (any-hover: hover) and (pointer: fine) {
  .c-accordion__bulk-button:hover:not(:disabled) {
    background-color: var(--accordion-color-bulk-bg-hover);
  }
}

.c-accordion__bulk-button:focus-visible {
  outline: 2px solid var(--accordion-color-focus-ring);
  outline-offset: 2px;
}

.c-accordion__bulk-button:disabled {
  cursor: not-allowed;
  color: var(--accordion-color-bulk-disabled);
  border-color: var(--accordion-color-border);
  background-color: var(--accordion-color-bg);
}

/* ================================================
   c-accordion__sr-status: スクリーンリーダー専用ステータス領域
   一括操作後の状態通知を視覚的には隠した上で
   aria-live で読み上げさせる sr-only パターン。
   display:none ではフォーカス・読み上げが行われないため、
   位置を取らずクリップする方式を採用する。
   ================================================ */
.c-accordion__sr-status {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ================================================
   c-accordion: アコーディオン本体(snippet 001 と同一)
   ================================================ */
.c-accordion {
  border: 1px solid var(--accordion-color-border);
  border-radius: var(--accordion-radius);
  background-color: var(--accordion-color-bg);
  overflow: hidden; /* 角丸を子要素にも適用するため */
}

.c-accordion__item + .c-accordion__item {
  border-top: 1px solid var(--accordion-color-border);
}

/* ================================================
   c-accordion__heading: 見出し要素のリセット
   ================================================ */
.c-accordion__heading {
  margin: 0;
  font-size: inherit;
  font-weight: inherit;
}

/* ================================================
   c-accordion__header: 開閉ボタン(クリック対象)
   ================================================ */
.c-accordion__header {
  /* ボタンリセット */
  appearance: none;
  background: none;
  border: none;
  font: inherit;
  color: inherit;
  cursor: pointer;

  /* レイアウト */
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  width: 100%;
  min-height: 44px;
  padding: var(--accordion-header-padding-y) var(--accordion-header-padding-x);

  /* テキスト */
  text-align: left;
  font-size: 16px;
  font-weight: 600;

  /* 状態遷移 */
  background-color: var(--accordion-color-bg);
  transition:
    background-color var(--accordion-transition),
    color var(--accordion-transition);
}

@media (any-hover: hover) and (pointer: fine) {
  .c-accordion__header:hover {
    background-color: var(--accordion-color-bg-hover);
  }
}

.c-accordion__header:focus-visible {
  outline: 2px solid var(--accordion-color-focus-ring);
  outline-offset: 2px;
}

.c-accordion__header[aria-expanded="true"] {
  background-color: var(--accordion-color-bg-active);
  color: var(--accordion-color-accent);
}

/* ================================================
   c-accordion__title: ヘッダー内のタイトルテキスト
   ================================================ */
.c-accordion__title {
  flex: 1;
  min-width: 0;
}

/* ================================================
   c-accordion__icon: 開閉インジケータ(▼)
   ================================================ */
.c-accordion__icon {
  flex-shrink: 0;
  display: inline-block;
  width: 12px;
  height: 12px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transform-origin: center;
  transition: transform var(--accordion-transition);
}

.c-accordion__header[aria-expanded="true"] .c-accordion__icon {
  transform: rotate(-135deg);
}

/* ================================================
   c-accordion__body: 本文パネル
   ================================================ */
.c-accordion__body {
  background-color: var(--accordion-color-bg);
}

/* ================================================
   c-accordion__content: 本文の内側ラッパー
   ================================================ */
.c-accordion__content {
  padding: var(--accordion-body-padding-y) var(--accordion-body-padding-x);
  color: var(--accordion-color-text);
  border-top: 1px solid var(--accordion-color-border);
}

.c-accordion__content > :first-child {
  margin-top: 0;
}

.c-accordion__content > :last-child {
  margin-bottom: 0;
}

.c-accordion__content p {
  margin: 0 0 12px;
}

.c-accordion__content p:last-child {
  margin-bottom: 0;
}

/* ================================================
   prefers-reduced-motion: 動きを減らす設定への配慮
   snippet 001 と同じ範囲を維持し、追加した
   __bulk-button の transition もここで無効化する。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon,
  .c-accordion__bulk-button {
    transition: none;
  }
}
OPEN
JavaScript
/**
 * 004_multi-open
 * バニラJS + ARIA対応アコーディオン(複数同時オープン許可型 + 一括操作)
 *
 * 設計方針(snippet 001 を継承):
 * - ルート要素 .c-accordion[data-accordion] に対してイベント委譲する。
 * - 開閉状態は aria-expanded 属性を真実の源(single source of truth)とし、
 *   CSSは [aria-expanded="true"] セレクタで見た目を切り替える。
 *   余分な is-open クラスは付与しない。
 * - 本文の表示/非表示は HTML の hidden 属性で制御する。
 *
 * 004 で追加した機能:
 * - 「すべて開く / すべて閉じる」一括コントロール
 *   data-bulk-controls="<対象 c-accordion の id>" で対象を特定するため、
 *   同一ページに複数アコーディオンが共存しても破綻しない。
 * - 個別操作・一括操作のいずれでも setItemState() を経由して
 *   aria-expanded と hidden を同時更新するため、状態が必ず一致する。
 * - 全項目の状態に応じて一括ボタン自体を disabled 化(全閉時は「すべて閉じる」、
 *   全開時は「すべて開く」を無効化)。
 * - 一括操作後は aria-live="polite" 領域へテキストを差し込み、
 *   スクリーンリーダーへ「すべて開きました/閉じました」を通知する。
 */

(function () {
  "use strict";

  // ルート要素を全て取得(同一ページ内に複数のアコーディオンがあっても動作する)
  const accordionRoots = document.querySelectorAll(".c-accordion[data-accordion]");

  if (accordionRoots.length === 0) return;

  /**
   * 1つのヘッダーボタンの開閉状態を「指定の状態」に揃える。
   * トグルではなく明示的な値を取るため、一括操作で活用する。
   *
   * @param {HTMLButtonElement} header - 対象のヘッダーボタン
   * @param {boolean} open - true で開く、false で閉じる
   */
  function setItemState(header, open) {
    const panelId = header.getAttribute("aria-controls");
    const panel = panelId ? document.getElementById(panelId) : null;

    if (!panel) return;

    if (open) {
      header.setAttribute("aria-expanded", "true");
      panel.removeAttribute("hidden");
    } else {
      header.setAttribute("aria-expanded", "false");
      panel.setAttribute("hidden", "");
    }
  }

  /**
   * 1つのヘッダーボタンの開閉状態をトグルする(個別クリック用)。
   *
   * @param {HTMLButtonElement} header - クリックされたヘッダーボタン
   */
  function toggleItem(header) {
    const isExpanded = header.getAttribute("aria-expanded") === "true";
    setItemState(header, !isExpanded);
  }

  /**
   * 指定 root 内の全ヘッダーを取得する。
   * 入れ子アコーディオン対策で、対象 root 直下のヘッダーのみを返す。
   *
   * @param {HTMLElement} root - .c-accordion[data-accordion] 要素
   * @returns {HTMLButtonElement[]}
   */
  function collectHeaders(root) {
    const candidates = root.querySelectorAll(".c-accordion__header");
    const result = [];
    candidates.forEach(function (header) {
      // header から最も近い .c-accordion が root と一致する場合のみ採用
      // (入れ子アコーディオン時に子の header を拾わないため)
      if (header.closest(".c-accordion") === root) {
        result.push(header);
      }
    });
    return result;
  }

  /**
   * 一括操作ボタンの disabled 状態を、現在の開閉状況に応じて更新する。
   * - 全項目が開いている → 「すべて開く」を disabled
   * - 全項目が閉じている → 「すべて閉じる」を disabled
   *
   * @param {HTMLElement} root - .c-accordion[data-accordion] 要素
   * @param {HTMLElement} bulkRoot - [data-bulk-controls] 要素
   */
  function updateBulkButtonsState(root, bulkRoot) {
    const headers = collectHeaders(root);
    if (headers.length === 0) return;

    const allOpen = headers.every(function (h) {
      return h.getAttribute("aria-expanded") === "true";
    });
    const allClosed = headers.every(function (h) {
      return h.getAttribute("aria-expanded") !== "true";
    });

    const openButton = bulkRoot.querySelector('[data-bulk-action="open"]');
    const closeButton = bulkRoot.querySelector('[data-bulk-action="close"]');

    if (openButton) openButton.disabled = allOpen;
    if (closeButton) closeButton.disabled = allClosed;
  }

  /**
   * スクリーンリーダーへ一括操作の結果を通知する。
   * aria-live="polite" 領域へテキストを差し込むだけで支援技術が読み上げる。
   * 同じテキストを連続書き込みすると変化なしと見なされ通知されない場合があるため、
   * 既に同じ内容が入っている場合のみ末尾に不可視文字(NBSP, U+00A0)を付けて
   * テキストの変化を明示する。
   *
   * @param {HTMLElement} statusEl - data-bulk-status 要素
   * @param {string} message - 通知するメッセージ
   */
  function announce(statusEl, message) {
    if (!statusEl) return;
    // 同一テキスト連続書き込み時に aria-live が変化として検出するよう、
    // 既に同じ内容が入っている場合のみ末尾に不可視のNBSPを付ける
    const suffix = statusEl.textContent === message ? " " : "";
    statusEl.textContent = message + suffix;
  }

  /**
   * ルート要素1つに対してイベント委譲を仕掛ける。
   *
   * @param {HTMLElement} root - .c-accordion[data-accordion] 要素
   */
  function bindAccordion(root) {
    // 対応する一括コントロールを id 経由で特定する
    const accordionId = root.id;
    // 注: data-bulk-controls の値(= 対象 c-accordion の id)は
    // 英数字・ハイフン・アンダースコアのみで構成する前提。
    // CSS3 識別子規則を超える文字を使う場合は CSS.escape() を併用すること。
    const bulkRoot = accordionId
      ? document.querySelector(
          '[data-bulk-controls="' + accordionId + '"]'
        )
      : null;
    // statusEl は bulkRoot 配下から取得し、複数アコーディオン共存時に
    // 他アコーディオン用の sr-status へ書き込むことを防ぐ。
    const statusEl = bulkRoot ? bulkRoot.querySelector("[data-bulk-status]") : null;

    // 個別ヘッダーの開閉(snippet 001 と同等のイベント委譲)
    root.addEventListener("click", function (event) {
      const header = event.target.closest(".c-accordion__header");
      if (!header) return;

      // 入れ子アコーディオン対策: ヘッダーから最も近い .c-accordion が
      // 自分自身(root)でない場合は処理しない。
      if (header.closest(".c-accordion") !== root) return;

      toggleItem(header);

      // 個別操作後も一括ボタンの disabled を同期する
      if (bulkRoot) updateBulkButtonsState(root, bulkRoot);
    });

    // 一括操作ボタンのバインド
    if (bulkRoot) {
      bulkRoot.addEventListener("click", function (event) {
        const button = event.target.closest("[data-bulk-action]");
        if (!button) return;
        if (button.disabled) return;

        const action = button.getAttribute("data-bulk-action");
        const headers = collectHeaders(root);

        if (action === "open") {
          headers.forEach(function (h) {
            setItemState(h, true);
          });
          announce(statusEl, "すべての項目を開きました");
        } else if (action === "close") {
          headers.forEach(function (h) {
            setItemState(h, false);
          });
          announce(statusEl, "すべての項目を閉じました");
        }

        updateBulkButtonsState(root, bulkRoot);
      });

      // 初期状態の disabled を反映(HTMLの初期値が全閉なら「すべて閉じる」を即無効)
      updateBulkButtonsState(root, bulkRoot);
    }
  }

  // 全てのルート要素に対してバインド
  accordionRoots.forEach(bindAccordion);
})();
OPEN

構造の要点は次の3点です。

  • 一括コントロールは <div data-bulk-controls="<対象 c-accordion の id>"> で対象アコーディオンと id 経由で関連付けし、同一ページに複数アコーディオンが共存しても破綻しない設計にしている
  • 一括コントロール内に role="status" + aria-live="polite" のライブリージョンを sr-only パターンで配置し、画面外で起きた状態変化を支援技術へ通知する
  • 個別ヘッダーは <button> + aria-expanded + aria-controls + <div hidden> の組み合わせで、本文の表示・非表示と支援技術への通知を同じ仕組みで完結させる

スニペット内のコメントが「なぜそう書いたか」を細かく説明しているので、各小節では要点だけ補足します。

「すべて開く / すべて閉じる」ボタンの設計と disabled 同期

一括ボタンの状態管理は updateBulkButtonsState 関数に集約しています。全項目が開いている時は「すべて開く」を disabled、全項目が閉じている時は「すべて閉じる」を disabled にする、という素直な設計です。

ポイントは、一括ボタンを押した時だけでなく、個別ヘッダーをクリックした時も updateBulkButtonsState(root, bulkRoot) を呼んで一括ボタンの状態を同期している点です。これにより個別経由・一括経由のどちらで操作しても、画面上の状態が常に一致します。

「押しても何も起きないボタン」は、ユーザーの誤操作を生む小さなノイズになります。視覚的にグレーアウトし、支援技術にも disabled として伝えることで、現在の状態を一瞬で読み取れるインターフェースになります。

スクリーンリーダーへの通知(aria-live 領域の使い方)

個別ヘッダーの開閉は aria-expanded の切替で支援技術に状態が伝わるため、追加の通知は要りません。一方、一括操作はフォーカス位置(一括ボタン)と状態変化の起こる場所(個別項目)が離れているため、追加通知を入れる価値があります。

スニペットでは role="status" + aria-live="polite" を持つ視覚的に隠したライブリージョン(.c-accordion__sr-status)を一括コントロール内に配置し、announce 関数でテキストを差し込んでいます。「すべての項目を開きました / 閉じました」のような短いステータスを polite で読み上げさせるのが穏当です。

ひとつ実装上のクセがあります。同じテキストを連続書き込みすると、支援技術が「変化なし」と判定して読み上げないことがあります。スニペットの announce 関数では、既に同じ内容が入っている時だけ末尾に不可視文字(NBSP、U+00A0)を付けてテキストの変化を明示するワークアラウンドを入れています。

イベント委譲とスコープ分離(同一ページ複数共存への対応)

イベントリスナーはルート要素 .c-accordion[data-accordion] に1つだけ付け、event.target.closest('.c-accordion__header') でヘッダーを特定するイベント委譲方式です。各項目に直接リスナーを付けるより、後から項目を増やしたときに改変箇所が少なくて済みます。

header.closest('.c-accordion') !== root のスコープガードも入れています。これは入れ子アコーディオン対策で、自分のスコープ外(子アコーディオン)のヘッダーは処理しない、という意味です。

data-bulk-controls の値で対象アコーディオンの id を指定する設計のため、同一ページに複数のアコーディオン+それぞれの一括コントロールを置いても、互いに干渉しません。announce 関数も bulkRoot.querySelector("[data-bulk-status]") で自分のスコープ内のステータス領域だけを取得しており、他アコーディオン用の sr-status 領域へ書き込んでしまう事故を構造的に防いでいます。

パターン1 が向く場面

複数同時オープン型 + 一括コントロールが向くのは次のような場面です。

  • 各項目が独立した情報を持ち、複数を見比べたい場面(FAQ で複数の質問を比較したい / 仕様一覧 / 機能比較表 等)
  • 設定パネルで複数セクションを並行して編集・確認したい場面
  • 「全体を一度に俯瞰したい / 読み終わったので一気に畳みたい」という一括操作のニーズがある場面
  • 読ませたい順序が無く、ユーザーが自由に展開したい場面

逆に「読ませたい順序がある」「画面領域を1項目分しか確保できない」場合は、次のパターン2(排他制御型)の方が向きます。

パターン2: 排他制御型(常に最大1つだけ開く)

ある項目を開くと、それ以外の項目が自動的に閉じられる構造です。常に最大1つの項目だけが開いている状態を保ち、開いている項目をもう一度クリックすれば閉じられる(全閉状態も許容)設計です。FAQ・モバイル・ステップ式チュートリアル等の「注意を1箇所に集中させる」場面に向きます。

HTML
<div class="c-accordion" data-accordion id="c-accordion-single-open">
  <!-- 項目 1 -->
  <div class="c-accordion__item">
    <h2 class="c-accordion__heading">
      <button
        type="button"
        class="c-accordion__header"
        aria-expanded="false"
        aria-controls="c-accordion-single-panel-1"
        id="c-accordion-single-header-1"
      >
        <span class="c-accordion__title">なぜ「1つだけ開く」設計を選ぶのか?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-single-panel-1"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          排他制御型は「読ませたい順序がある」「画面領域を1項目分しか確保できない」場面に向きます。
          たとえばモバイルのFAQ・サイドメニュー・ステップ式のチュートリアル等、ユーザーの注意を1箇所に集中させたい設計に有効です。
        </p>
        <p>
          逆に、複数項目を見比べたい・並行して情報を参照したい用途では複数同時オープン型(snippet 004 multi-open)の方が向きます。
        </p>
      </div>
    </div>
  </div>

  <!-- 項目 2 -->
  <div class="c-accordion__item">
    <h2 class="c-accordion__heading">
      <button
        type="button"
        class="c-accordion__header"
        aria-expanded="false"
        aria-controls="c-accordion-single-panel-2"
        id="c-accordion-single-header-2"
      >
        <span class="c-accordion__title">「全部閉じている状態」も許容する?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-single-panel-2"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          はい。本スニペットは「常に1つは開いた状態を維持する」厳格モードではなく、開いている項目をもう一度クリックすれば閉じられる設計です。
          ユーザーが「読み終わったので畳みたい」「全項目を閉じてリスト全体を俯瞰したい」というニーズにも応えるためです。
        </p>
        <p>
          常に1つは開いた状態を維持する設計(厳格モード:開いている項目を再クリックしても閉じない)も実装可能ですが、ユーザーが任意のタイミングで畳めない不自由さを生むため、本スニペットでは採用していません。
        </p>
      </div>
    </div>
  </div>

  <!-- 項目 3 -->
  <div class="c-accordion__item">
    <h2 class="c-accordion__heading">
      <button
        type="button"
        class="c-accordion__header"
        aria-expanded="false"
        aria-controls="c-accordion-single-panel-3"
        id="c-accordion-single-header-3"
      >
        <span class="c-accordion__title">他項目を閉じる挙動はスクリーンリーダーに通知しなくていい?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-single-panel-3"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          各項目の <code>aria-expanded</code> をJSで切り替えることで、支援技術には個別項目の状態変化が通知されます。
          WAI-ARIA Authoring Practices の Accordion パターンも、排他制御に関する追加通知(<code>aria-live</code> 等)を要求していません。
        </p>
        <p>
          そのため本スニペットでは snippet 004 の一括操作のような <code>role="status"</code> + <code>aria-live="polite"</code> 領域は設けず、個別 <code>aria-expanded</code> の切替だけでアクセシビリティ要件を満たす設計にしています。
        </p>
      </div>
    </div>
  </div>

  <!-- 項目 4 -->
  <div class="c-accordion__item">
    <h2 class="c-accordion__heading">
      <button
        type="button"
        class="c-accordion__header"
        aria-expanded="false"
        aria-controls="c-accordion-single-panel-4"
        id="c-accordion-single-header-4"
      >
        <span class="c-accordion__title">snippet 004 multi-open との使い分けは?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-single-panel-4"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          どちらが正解という話ではなく、コンテンツとの相性で選びます。
          項目同士が独立していて並行参照したいなら 004 multi-open、ユーザーの注意を1箇所に集中させたいなら 005 single-open が向きます。
        </p>
        <p>
          実装としては <code>aria-expanded</code> + <code>hidden</code> を真実の源とする骨格は共通で、違いは「他項目を閉じるか/閉じないか」のJS側の制御方針だけです。
          そのため2つを切り替えてもCSS・HTML構造を作り直す必要はありません。
        </p>
      </div>
    </div>
  </div>
</div>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   snippet 001 / 004 と同一のトークンセットを継承する。
   排他制御は JS 側でのみ行うため、CSS トークンは
   001 と完全一致でよい(004 の --accordion-color-bulk-*
   系トークンは本 snippet では使用しない)。
   ================================================ */
:root {
  --accordion-color-text:        #2b2b2b;
  --accordion-color-text-muted:  #555;
  --accordion-color-border:      #e0e0e0;
  --accordion-color-bg:          #ffffff;
  --accordion-color-bg-hover:    #f5f7fa;
  --accordion-color-bg-active:   #eef3fa;
  --accordion-color-accent:      #0066cc;
  --accordion-color-focus-ring:  #0066cc;

  --accordion-radius:            8px;
  --accordion-header-padding-y:  16px;
  --accordion-header-padding-x:  20px;
  --accordion-body-padding-y:    16px;
  --accordion-body-padding-x:    20px;

  --accordion-transition:        0.2s ease;
}

/* ================================================
   ベースリセット(デモページ用)
   ================================================ */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
    "Hiragino Sans", "Noto Sans JP", sans-serif;
  color: var(--accordion-color-text);
  background-color: #fafafa;
  line-height: 1.7;
}

code {
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
  font-size: 0.92em;
  padding: 0.1em 0.35em;
  background-color: #eef0f3;
  border-radius: 4px;
}

/* ================================================
   l-main / p-section: デモページのレイアウト
   ================================================ */
.l-main {
  max-width: 720px;
  margin-inline: auto;
  padding: 40px 20px;
}

.p-section__title {
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 16px;
}

.p-section__text {
  margin: 0 0 24px;
  color: var(--accordion-color-text-muted);
}

@media (min-width: 768px) {
  .p-section__title {
    font-size: 28px;
  }
}

/* ================================================
   c-accordion: アコーディオン本体
   ================================================ */
.c-accordion {
  border: 1px solid var(--accordion-color-border);
  border-radius: var(--accordion-radius);
  background-color: var(--accordion-color-bg);
  overflow: hidden; /* 角丸を子要素にも適用するため */
}

/* 項目間の区切り線 */
.c-accordion__item + .c-accordion__item {
  border-top: 1px solid var(--accordion-color-border);
}

/* ================================================
   c-accordion__heading: 見出し要素のリセット
   <h2> をボタンのラッパーに使うため、見出しのデフォルト
   余白・サイズを打ち消す。
   ================================================ */
.c-accordion__heading {
  margin: 0;
  font-size: inherit;
  font-weight: inherit;
}

/* ================================================
   c-accordion__header: 開閉ボタン(クリック対象)
   ================================================ */
.c-accordion__header {
  /* ボタンリセット */
  appearance: none;
  background: none;
  border: none;
  font: inherit;
  color: inherit;
  cursor: pointer;

  /* レイアウト */
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  width: 100%;
  min-height: 44px; /* タップ領域の最低保証(モバイルアクセシビリティ)。
                       padding を変更してもこの最低高さは崩れない。 */
  padding: var(--accordion-header-padding-y) var(--accordion-header-padding-x);

  /* テキスト */
  text-align: left;
  font-size: 16px;
  font-weight: 600;

  /* 状態遷移 */
  background-color: var(--accordion-color-bg);
  transition:
    background-color var(--accordion-transition),
    color var(--accordion-transition);
}

/* マウス系デバイスかつホバー操作が可能な場合のみ適用 */
@media (any-hover: hover) and (pointer: fine) {
  .c-accordion__header:hover {
    background-color: var(--accordion-color-bg-hover);
  }
}

/* キーボードフォーカス時のフォーカスリング
   outline を消さず、視認性の高いリングで上書きする
   outline-offset は正値(外側)にして、開いた項目の背景色
   (--accordion-color-bg-active)に埋没しないようにする。 */
.c-accordion__header:focus-visible {
  outline: 2px solid var(--accordion-color-focus-ring);
  outline-offset: 2px;
}

/* 開いている項目のヘッダーは背景色を変えて視認性を上げる */
.c-accordion__header[aria-expanded="true"] {
  background-color: var(--accordion-color-bg-active);
  color: var(--accordion-color-accent);
}

/* ================================================
   c-accordion__title: ヘッダー内のタイトルテキスト
   ================================================ */
.c-accordion__title {
  flex: 1;
  min-width: 0; /* テキストが長い場合に折り返せるようにする */
}

/* ================================================
   c-accordion__icon: 開閉インジケータ(▼)
   CSSのみでシェブロンを描画。aria-expanded の値に応じて
   回転させることで開閉状態を視覚的に伝える。
   ================================================ */
.c-accordion__icon {
  flex-shrink: 0;
  display: inline-block;
  width: 12px;
  height: 12px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);            /* 閉じている状態: ▼ */
  transform-origin: center;
  transition: transform var(--accordion-transition);
}

.c-accordion__header[aria-expanded="true"] .c-accordion__icon {
  transform: rotate(-135deg);          /* 開いている状態: ▲ */
}

/* ================================================
   c-accordion__body: 本文パネル
   閉じている状態は <div hidden> 属性で非表示にする。
   (CSS の display:none と同等の振る舞いだが、
    HTMLとして閉状態が読み取れるため可視性が高い)
   ================================================ */
.c-accordion__body {
  background-color: var(--accordion-color-bg);
}

/* ================================================
   c-accordion__content: 本文の余白を持つ内側ラッパー
   snippet 007(animation)で grid-template-rows
   アニメーションを実装する際、外側の __body は
   コンテナ、内側の __content がコンテンツとして
   分離できるよう2層構造にしている。
   ================================================ */
.c-accordion__content {
  padding: var(--accordion-body-padding-y) var(--accordion-body-padding-x);
  color: var(--accordion-color-text);
  border-top: 1px solid var(--accordion-color-border);
}

.c-accordion__content > :first-child {
  margin-top: 0;
}

.c-accordion__content > :last-child {
  margin-bottom: 0;
}

.c-accordion__content p {
  margin: 0 0 12px;
}

.c-accordion__content p:last-child {
  margin-bottom: 0;
}

/* ================================================
   prefers-reduced-motion: 動きを減らす設定への配慮
   前庭機能障害等で OS の「視差効果を減らす」を有効に
   しているユーザーに対し、transition を無効化する。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon {
    transition: none;
  }
}
OPEN
JavaScript
/**
 * 005_single-open
 * バニラJS + ARIA対応アコーディオン(排他制御型・常に最大1つだけ開く)
 *
 * 設計方針(snippet 001 / 004 を継承):
 * - ルート要素 .c-accordion[data-accordion] に対してイベント委譲する。
 * - 開閉状態は aria-expanded 属性を真実の源(single source of truth)とし、
 *   CSSは [aria-expanded="true"] セレクタで見た目を切り替える。
 *   余分な is-open クラスは付与しない。
 * - 本文の表示/非表示は HTML の hidden 属性で制御する。
 * - setItemState(header, open) を経由して aria-expanded と hidden を
 *   同時更新するため、状態が必ず一致する(snippet 004 から踏襲)。
 *
 * 005 ならではの実装:
 * - クリックされた項目以外をすべて閉じる「排他制御パターンA」を採用。
 *   常に最大1つの項目だけが開いている状態を保つ。
 * - 開いている項目を再クリックすれば閉じられる(全閉状態を許容)。
 *   「常に1つは開いた状態を維持する」厳格モードは採用しない。
 * - aria-expanded の切替で支援技術に状態変化が伝わるため、
 *   snippet 004 の aria-live ライブリージョンのような追加通知は不要。
 *   WAI-ARIA APG Accordion パターンも追加通知を要求していない。
 *
 * 不採用パターン(記事 003 で本文コラムとして言及):
 * - パターンB: HTML 仕様の <details name="..."> による排他制御
 * - パターンC: ラジオボタン + :checked 擬似クラスの CSS のみ実装
 * - パターンD: 常に1つは開いた状態を維持する厳格モード
 * いずれも 001/004 と実装系を揃えるため、本 snippet では採用していない。
 */

(function () {
  "use strict";

  // ルート要素を全て取得(同一ページ内に複数のアコーディオンがあっても動作する)
  const accordionRoots = document.querySelectorAll(".c-accordion[data-accordion]");

  if (accordionRoots.length === 0) return;

  /**
   * 1つのヘッダーボタンの開閉状態を「指定の状態」に揃える。
   * snippet 004 と同一シグネチャ(排他制御で他項目を強制クローズする際に活用)。
   *
   * @param {HTMLButtonElement} header - 対象のヘッダーボタン
   * @param {boolean} open - true で開く、false で閉じる
   */
  function setItemState(header, open) {
    const panelId = header.getAttribute("aria-controls");
    const panel = panelId ? document.getElementById(panelId) : null;

    if (!panel) return;

    if (open) {
      header.setAttribute("aria-expanded", "true");
      panel.removeAttribute("hidden");
    } else {
      header.setAttribute("aria-expanded", "false");
      panel.setAttribute("hidden", "");
    }
  }

  /**
   * 指定 root 内の全ヘッダーを取得する。
   * 入れ子アコーディオン対策で、対象 root 直下のヘッダーのみを返す。
   * snippet 004 と同一実装。
   *
   * @param {HTMLElement} root - .c-accordion[data-accordion] 要素
   * @returns {HTMLButtonElement[]}
   */
  function collectHeaders(root) {
    const candidates = root.querySelectorAll(".c-accordion__header");
    const result = [];
    candidates.forEach(function (header) {
      // header から最も近い .c-accordion が root と一致する場合のみ採用
      // (入れ子アコーディオン時に子の header を拾わないため)
      if (header.closest(".c-accordion") === root) {
        result.push(header);
      }
    });
    return result;
  }

  /**
   * 1つのヘッダーボタンの開閉状態をトグルする(排他制御版)。
   *
   * 排他制御の手順:
   *   1. forEach で「対象以外」を全クローズ(`h !== header` ガードで対象自身は除外)。
   *   2. 対象自身は forEach から除外されているため、最後の
   *      setItemState(header, !isExpanded) で1回だけトグルされる。
   *      (元が閉じていたら開く・元が開いていたら閉じる)
   *
   * この設計により、対象を「閉じてから再度開く」ような二重操作にはならず、
   * かつ元が開いていた対象を閉じるパスでは自然に全閉状態への遷移が成立する。
   * (= 「常に1つは開いた状態を維持する」厳格モード(パターンD)にはならない)
   *
   * @param {HTMLButtonElement} header - クリックされたヘッダーボタン
   * @param {HTMLElement} root - 所属する .c-accordion[data-accordion] 要素
   */
  function toggleItem(header, root) {
    const isExpanded = header.getAttribute("aria-expanded") === "true";

    // 排他制御: 対象以外の項目を先にすべて閉じる
    // (`h !== header` ガードにより対象自身はこの forEach の処理対象外)
    collectHeaders(root).forEach(function (h) {
      if (h !== header) setItemState(h, false);
    });

    // 対象項目はここで1回だけトグルされる(forEach 側では触れていないため二重操作にならない)
    setItemState(header, !isExpanded);
  }

  /**
   * ルート要素1つに対してイベント委譲を仕掛ける。
   *
   * @param {HTMLElement} root - .c-accordion[data-accordion] 要素
   */
  function bindAccordion(root) {
    root.addEventListener("click", function (event) {
      const header = event.target.closest(".c-accordion__header");
      if (!header) return;

      // 入れ子アコーディオン対策(snippet 001 / 004 と同等):
      // ヘッダーから最も近い .c-accordion が「自分自身(root)」でない場合は
      // 子アコーディオンのヘッダーなので処理しない。
      if (header.closest(".c-accordion") !== root) return;

      // root をクロージャから渡し、collectHeaders のスコープを限定する。
      toggleItem(header, root);
    });
  }

  // 全てのルート要素に対してバインド
  accordionRoots.forEach(bindAccordion);
})();
OPEN

構造の要点はシンプルで、HTML はパターン1 から一括コントロールを取り除いた素朴な構造、CSS は一括ボタン用のトークン(--accordion-color-bulk-*)を抜いた snippet 001 と同等のセットです。排他制御は JS 側のロジックでのみ実現するため、HTML/CSS を作り直す必要はありません。

排他制御の核 — 「対象以外を全クローズしてから対象をトグル」

排他制御の手順は toggleItem(header, root) 関数に集約されています。手順は次の2ステップです。

  • (1) collectHeaders(root).forEach(...) で「対象以外」を全クローズする(h !== header ガードで対象自身は除外)
  • (2) 対象自身は forEach から除外されているため、最後の setItemState(header, !isExpanded) で1回だけトグルされる(元が閉じていたら開く・元が開いていたら閉じる)

この設計のメリットは、対象を「閉じてから再度開く」二重操作にならない点と、元が開いていた対象を閉じるパスで自然に全閉状態へ遷移できる点です。ユーザーが任意のタイミングで畳めるため、「全項目を閉じてリスト全体を俯瞰したい」というニーズにも応えられます。

「常に1つは開いた状態を維持する」設計(厳格モード)にはせず、全閉状態を許容するのが本スニペットの選択です。

なぜ aria-live の追加通知が要らないのか

排他制御は他項目が自動的に閉じる挙動を含むため、「他項目クローズもスクリーンリーダーに通知すべき?」という疑問が浮かぶかもしれません。結論から言うと、追加通知は不要です。

各項目の aria-expanded を JS で切り替えることで、支援技術には個別項目の状態変化が通知されます。WAI-ARIA Authoring Practices の Accordion パターンも、排他制御に関する追加通知(aria-live 等)を要求していません。

パターン1(一括操作)と異なるのは、フォーカス位置と状態変化の起こる場所の関係です。一括操作はフォーカス位置(一括ボタン)と状態変化の起こる場所(個別項目)が離れているため aria-live 領域が必要でしたが、排他制御は両者が同じスコープ内(クリックされたヘッダーと、その周辺の他項目)にあるため、aria-expanded の切替だけで支援技術への通知として十分です。

パターン2 が向く場面

排他制御型が向くのは次のような場面です。

  • 読ませたい順序がある場面(ステップ式チュートリアル / 学習導線)
  • 画面領域を1項目分しか確保できない場面(モバイルの FAQ / サイドメニュー)
  • ユーザーの注意を1箇所に集中させたい設計(問い合わせフォームの注意書き / 重要情報の絞り込み表示)
  • 全項目を同時に表示すると圧迫感が出る縦長コンテンツ

コラム: 常に1つは開いた状態を維持する厳格モード(パターンD)について

排他制御の派生形として、「常に1つは開いた状態を維持する」厳格モード(パターンD)の実装も可能です。snippet 005 のヘッダーコメントでも不採用パターンとして言及されています。

実装は1行で済みます。toggleItem 関数の冒頭に if (isExpanded) return; を追加すれば、開いている項目の再クリックを無効化する厳格モードになります。

ユースケースとしては、ステップ式ウィザードの最終確認画面・ユーザーに「常にどれか1つを参照させたい」設計などが想定されます。一方、ユーザーが任意のタイミングで畳めない不自由さを生む側面もあります。本スニペットでは汎用 snippet として「全閉許容」の方を選びましたが、用途次第では厳格モードが適切な選択になる場面もあります。「不採用=悪」ではなく、設計判断のひとつと捉えるのが穏当です。

2つを支える共通実装 — setItemState を真実の源にする

ここまでの2パターンは、見た目こそ異なりますが、内部設計には共通の核があります。それが setItemState(header, open) 関数です。

パターン1(snippet 004)と パターン2(snippet 005)はこの関数を同じシグネチャ・同じ実装で持っています。役割は、引数で渡された open の値(true / false)に応じて、対象ヘッダーの aria-expanded 属性とパネルの hidden 属性を同時に更新するだけ、というシンプルなものです。

この設計の意義は3つあります。

  • 個別操作・一括操作・排他制御のどの経路でも、最終的に同じ setItemState を経由するため、aria-expandedhidden の状態がずれることが構造的にない
  • aria-expanded を「真実の源(single source of truth)」とする原則を維持できるため、CSS([aria-expanded="true"] セレクタ)と JS が同じ属性を見て動く
  • パターンを切り替える時(複数同時 → 排他制御 / 排他制御 → 複数同時)は、toggleItem 内の他項目クローズ処理だけを書き換えればよく、HTML/CSS は触らずに済む

要件が変わっても作り直さない、というのは地味ですが効きます。ベース実装の setItemState を保ったまま、上に乗せる制御ロジックだけを差し替える、という設計の核を持っておくと、要件追加にも壊れにくくなります。

2つを比べてどう選ぶか

ここまでを踏まえて、選択の流れを整理します。上から順に判断していくと迷いにくくなります。

  • 画面領域に複数項目を並べて見せられるか? → No(モバイル等の縦長 1 列)ならパターン2(排他制御)
  • 項目同士が独立した情報か?比較・並行参照のニーズがあるか? → Yes ならパターン1(複数同時オープン)
  • 読ませたい順序がある / 注意を1箇所に集中させたいか? → Yes ならパターン2(排他制御)
  • 「全部見たい / 全部畳みたい」一括操作のニーズがあるか? → Yes ならパターン1(複数同時 +「すべて開く / すべて閉じる」)
  • どちらでも要件を満たせる場合 → ユースケースの主目的で決める(FAQ で焦点を絞らせる→パターン2 / 比較リスト→パターン1)

迷ったら、ユーザーに同時に何項目を意識させたいかで決める、というのが結論です。複数を見比べさせたいならパターン1、1つに集中させたいならパターン2、という素朴な軸で選ぶのが穏当です。

「どちらでも作れるからこそ迷う」場面では、まずはパターン1(複数同時オープン)から始めて、要件が固まってきたらパターン2(排他制御)に切り替える、という順序での検討も有効です。setItemState を真実の源にしておけば、HTML/CSS を作り直さずに JS の制御ロジックだけ差し替えられます。

よくある質問

排他制御の中で「常に1つは開いた状態を維持する」厳格モードにしたいです。

snippet 005 の toggleItem 関数の冒頭に if (isExpanded) return; を追加すれば、開いている項目の再クリックを無視する厳格モードになります。ユーザーが任意のタイミングで畳めなくなる不自由さを生む側面はあるため、ステップ式ウィザード等の「常にどれか1つを参照させたい」設計でのみ採用するのが穏当です。

JavaScript を使わずに「1つだけ開く」を実現できますか?

CSSのみで実装する選択肢として、HTML 標準の <details name="グループ名"> で同名グループ内を排他化する方法と、ラジオボタン + :checked 兄弟セレクタで作る方法があります。それぞれ a11y と挙動上のクセがあるため、別途解説した記事を関連記事ブロックから参照してください。

同一ページに複数のアコーディオンを置きたい場合、一括コントロールはどう動きますか?

snippet 004 では <div data-bulk-controls="<対象 c-accordion の id>"> の値で対象アコーディオンを id 経由で特定する仕組みのため、同一ページに複数のアコーディオン+それぞれの一括コントロールを置いても、互いに干渉しません。aria-live 領域も bulkRoot 配下にスコープを限定しているため、他のアコーディオン用ステータス領域へ書き込まれる事故を構造的に防いでいます。

パターン1 と パターン2 を切り替える時、HTML や CSS はどこまで書き換えますか?

HTML と CSS は基本的に書き換え不要です。両スニペットとも aria-expanded を真実の源として [aria-expanded="true"] セレクタで見た目を切り替える設計なので、JS 側で toggleItem の他項目クローズ処理を入れるかどうか(と一括コントロールの有無)だけが差分になります。snippet 004 → 005 のように切り替える時は、JS のロジックだけ差し替えれば済みます。

まとめ

アコーディオンの開閉制御を、複数同時オープン型と排他制御型の2パターンで対比してきました。ポイントを整理します。

  • 開閉制御は「複数同時オープン+一括コントロール」と「排他制御(常に最大1つ)」の2パターンに大別できる
  • 複数同時 + 一括 は各項目が独立した情報・複数を比較したい・設定パネル等に向く
  • 排他制御 は FAQ・モバイル・ステップ式チュートリアル等の「注意を1箇所に集中させる」設計に向く
  • 一括操作は aria-live での通知が必要、排他制御は個別 aria-expanded の切替だけで足りる
  • 両パターンとも setItemState(header, open) を真実の源として一元化することで、HTML/CSS を書き換えずに JS の制御ロジックだけ差し替え可能

ユーザーに同時に何項目を意識させたいかで選び、迷ったらまずは複数同時オープンから始めて、要件が固まってきたら排他制御に切り替える、という順序で検討するとシンプルです。

関連記事

【シリーズ:アコーディオンUIシリーズ】
→ アコーディオン実装パターンまとめ(記事000・公開予定)
→ バニラJSで作るアコーディオン|aria-expanded で実装するARIA対応の基本形(公開済 / accordion-js-toggle)
→ JavaScript不要でアコーディオンを作る2つの方法|<details> とチェックボックスハックを比較(公開済 / accordion-without-js)
→ 高さアニメーション付きアコーディオン(記事004・公開予定)
→ 入れ子アコーディオン(記事005・公開予定)