FAQ を多階層化したくて親アコーディオンの中に子アコーディオンを入れたら、子の見出しをクリックしたつもりが親まで一緒に開閉してしまった、ということはないでしょうか。event.stopPropagation() で止めようとしたら別の機能に副作用が出た、同じ id を使い回したら別の項目が開いてしまった、という相談もよく耳にします。

この記事では、親アコーディオンの中に子アコーディオンを入れる「入れ子アコーディオン」を、コード付きで紹介します。HTML 構造、CSS の視覚区別、closest(".c-accordion") ベースのスコープガード、id 命名規則、親閉時の子状態維持までを順に取り上げます。コピペでそのまま動く HTML / CSS / JS を提供しつつ、なぜそう書くのかまで踏み込んで解説します。

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

🌐 デモを見る(GitHub Pages)

💻 ソースコード(GitHub)

この記事で分かること
  • .c-accordion の本文 __content の中に子 .c-accordion を置くだけで入れ子化できる HTML 構造
  • 子アコーディオンを視覚的に区別する .c-accordion--child モディファイア(背景色+左インデント)
  • 親と子のクリックが二重発火する仕組みと、header.closest(".c-accordion") === root で防ぐスコープガード
  • event.stopPropagation() ではなく closest を使う理由(他の機能に副作用を与えないため)
  • id 衝突を防ぐ「親 = parent-{連番} / 子 = child-{親連番}-{子連番}」の命名規則
  • 親を閉じた時に子の状態をリセットするかしないかの設計判断

入れ子アコーディオンとは(ユースケース)

入れ子アコーディオンは、親アコーディオンの本文の中にもう1つアコーディオンを置いた構造のことです。1つの大カテゴリの下に複数のサブ項目をぶら下げ、必要なものだけ展開して読ませる UI に向いています。

代表的な使いどころは次のようなものです。

  • FAQ の多階層化(大カテゴリ → 個別質問の2階層)
  • ヘルプセンターのカテゴリ階層表示
  • サイトマップ風ナビゲーション(章 → 節 の2階層)
  • 規約・利用ガイドの章節構造
  • 設定パネルの大セクション → 小セクション

ネストの深さは「親 → 子の1段階まで」を目安にしておくと読みやすさを保てます。2段以上に深くすると視覚的な階層が伝わりにくく、操作経路も長くなります。本記事の実装も親 → 子の1段階に絞っています。

HTML構造 — 親 c-accordion の中に子 c-accordion を入れる

入れ子化の HTML はとてもシンプルです。基本のアコーディオンと同じ <button> + aria-expanded + <div hidden> 構造を踏襲します。親の __content の中にもう1つ .c-accordion ブロックを入れるだけで完成します。

HTML
<div class="c-accordion" data-accordion id="c-accordion-nested-parent">
  <!-- 親項目 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-nested-parent-1-panel"
        id="c-accordion-nested-parent-1-header"
      >
        <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-nested-parent-1-panel"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          親アコーディオンの本文 <code>.c-accordion__body</code> の内側に、もう1つ <code>.c-accordion</code> を入れるだけで作れます。
          以下のサブ質問は、親アコーディオンを開いた時にだけ表示される「ネスト子アコーディオン」です。
          親と子は独立した <code>data-accordion</code> root として扱われ、それぞれにクリックハンドラが仕掛けられます。
        </p>

        <!-- 子アコーディオン(深さ1段階のみ) -->
        <div class="c-accordion c-accordion--child" data-accordion id="c-accordion-nested-child-1">
          <!-- 子項目 1-1 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-1-1-panel"
                id="c-accordion-nested-child-1-1-header"
              >
                <span class="c-accordion__title">親と子で <code>data-accordion</code> の値は変える必要がある?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-1-1-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  変える必要はありません。親も子も <code>data-accordion</code><strong>値なしの共通属性</strong> として付けるだけで動きます。
                  JS 側で各 <code>.c-accordion[data-accordion]</code> を独立した root として取得し、それぞれに個別のクリックハンドラを仕掛けるためです。
                </p>
              </div>
            </div>
          </div>

          <!-- 子項目 1-2 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-1-2-panel"
                id="c-accordion-nested-child-1-2-header"
              >
                <span class="c-accordion__title">どこまでネストできる?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-1-2-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  技術的にはさらに深くもできますが、UI として読みやすいのは <strong>1段階まで</strong> です。
                  2段以上の入れ子は視覚的な階層が伝わりにくく、操作経路も長くなるため、
                  本実装も「親 → 子の1段階のみ」に絞っています。
                </p>
              </div>
            </div>
          </div>

          <!-- 子項目 1-3 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-1-3-panel"
                id="c-accordion-nested-child-1-3-header"
              >
                <span class="c-accordion__title">子の状態は親を閉じても残る?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-1-3-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  残ります。親を閉じても子の <code>aria-expanded</code> は変更しないため、
                  親を再度開いた時に子の前回状態(開いていた項目は開いたまま)が復元されます。
                  「親を閉じたら子もすべて閉じる」リセット仕様にしたい場合は別途実装してください。
                </p>
              </div>
            </div>
          </div>
        </div>
        <!-- 子アコーディオンここまで -->
      </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-nested-parent-2-panel"
        id="c-accordion-nested-parent-2-header"
      >
        <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-nested-parent-2-panel"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          クリックハンドラ内で、ヘッダーから最も近い <code>.c-accordion</code> が自分の root と一致するかを判定します。
          これにより、親 root のハンドラが子の <code>.c-accordion__header</code> を拾って二重発火する事故を防げます。
        </p>

        <!-- 子アコーディオン(深さ1段階のみ) -->
        <div class="c-accordion c-accordion--child" data-accordion id="c-accordion-nested-child-2">
          <!-- 子項目 2-1 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-2-1-panel"
                id="c-accordion-nested-child-2-1-header"
              >
                <span class="c-accordion__title"><code>root.contains(header)</code> ではダメ?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-2-1-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  <code>root.contains(header)</code> は親 root が子の <code>.c-accordion__header</code><code>true</code> で拾ってしまうため、
                  二重発火を防げません。<code>header.closest(".c-accordion") === root</code> で「ヘッダーが直属する root」を厳密に判定する必要があります。
                </p>
              </div>
            </div>
          </div>

          <!-- 子項目 2-2 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-2-2-panel"
                id="c-accordion-nested-child-2-2-header"
              >
                <span class="c-accordion__title">イベントの伝播は止めないの?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-2-2-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  止めません。<code>event.stopPropagation()</code> は他の機能(モーダルの外側クリック判定など)に副作用を与えるため、
                  スコープガード方式で「自分の管轄外なら何もしない」と振る舞う方が安全です。
                  子 root の <code>addEventListener</code> 自体は独立しているため、子のクリックは子のハンドラだけで処理されます。
                </p>
              </div>
            </div>
          </div>
        </div>
        <!-- 子アコーディオンここまで -->
      </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-nested-parent-3-panel"
        id="c-accordion-nested-parent-3-header"
      >
        <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-nested-parent-3-panel"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          独立します。クリック対象に <code><button type="button"></code> を使っているため、Tab フォーカスは親ヘッダー → 子ヘッダーの順にネイティブな DOM 順で移動し、
          Enter / Space は各ボタンが個別に発火します。スコープガードは「親の click ハンドラから子を見ない」「子の click ハンドラから親を見ない」を実現するためのもので、
          キーボード操作のフォーカス順序や発火タイミング自体には影響しません。
        </p>
        <p>
          なお、親項目を閉じると子のボタンは <code>hidden</code> 配下に入るため Tab フォーカスからも外れます。
          親を再度開けば自然に Tab 順序へ戻ります。
        </p>
      </div>
    </div>
  </div>
</div>
OPEN

押さえどころは次の通りです。

  • 親アコーディオンの .c-accordion__body > .c-accordion__content の内側に、もう1つ .c-accordion を置くだけで入れ子化できる
  • 子アコーディオンには .c-accordion--child モディファイアを追加(視覚区別用・後述)
  • 親も子も data-accordion を値なしの共通属性として付ける(属性値を親子で分ける必要はない)
  • 子アコーディオンは親アコーディオンを開いた時にだけ表示される(親 __bodyhidden 属性が外れることで子が描画対象に戻る)

親が <h2> なら子は <h3> にする

入れ子では、親見出しと子見出しのレベルを揃えるのではなく1段階ずらすのが基本です。本実装では親項目を <h2 class="c-accordion__heading"> で囲み、子項目を <h3 class="c-accordion__heading"> で囲んでいます。

HTML
<!-- 親項目 -->
<h2 class="c-accordion__heading">
  <button type="button" class="c-accordion__header" ...>...</button>
</h2>

<!-- 子項目(親の本文の中に置く) -->
<h3 class="c-accordion__heading">
  <button type="button" class="c-accordion__header" ...>...</button>
</h3>

見出しタグの選定は アウトラインの階層と視覚階層を一致させる ためのものです。__heading クラスは見出しのデフォルト余白・サイズを打ち消すリセット用です。<h2> <h3> どちらに付けても見た目は変わりません。スクリーンリーダーの見出しジャンプ(H キー)でも、親 → 子の階層が自然に伝わります。

ページ全体の見出し構造に合わせて、親が <h3> なら子は <h4> のようにずらしてください。

CSS — 子アコーディオンを視覚的に区別する .c-accordion--child

入れ子化を「見た目」で伝えるのが .c-accordion--child モディファイアの役割です。子アコーディオンに薄い背景色+左インデントを当てます。これで親(白背景)との階層差を一目で示せます。

CSS 全体は次の通りです。.c-accordion 関連のスタイルに絞って掲載しています。サイト側で *, *::before, *::after { box-sizing: border-box; } のリセットがある前提で記載しています。お使いのプロジェクトに同等のリセットが入っているかご確認ください。

CSS
/* ================================================
   カスタムプロパティ
   共通のトークンセットを継承する(既存値は改変しない)。
   入れ子(ネスト)専用に子アコーディオン用の背景色と
   左インデントの2トークンを追加する。
   命名規則は --accordion-{役割}-{プロパティ} を維持。
   ================================================ */
: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;

  /* 入れ子用トークン(本実装で追加)
     子アコーディオンを視覚的に区別するための背景色(薄)と
     左インデント幅を一元管理する。 */
  --accordion-color-bg-child:    #f7f9fc;
  --accordion-child-indent:      16px;
}

/* ================================================
   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> / <h3> をボタンのラッパーに使うため、見出しの
   デフォルト余白・サイズを打ち消す。
   ================================================ */
.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> 属性で非表示にする。
   ================================================ */
.c-accordion__body {
  background-color: var(--accordion-color-bg);
}

/* ================================================
   c-accordion__content: 本文の余白を持つ内側ラッパー
   外側 __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;
}

/* ================================================
   c-accordion--child: 子アコーディオン(入れ子)
   本実装で追加。親の本文 __content の中に置かれた
   2つ目以降の .c-accordion を視覚的に区別する。
   - 背景色を薄い --accordion-color-bg-child に変えて
     「親に内包された情報」であることを示す。
   - 左インデント(--accordion-child-indent)で階層を表現。
   - 親本文の末尾に余白を残すため margin-top を持たせる。
   ================================================ */
.c-accordion--child {
  background-color: var(--accordion-color-bg-child);
  margin-top: 16px;
  margin-left: var(--accordion-child-indent);
}

/* 子アコーディオンのヘッダーは背景色を継承して、
   親ヘッダー(白)との差を視覚的に保つ。 */
.c-accordion--child .c-accordion__header {
  background-color: var(--accordion-color-bg-child);
}

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

/* 子の開状態は親と同じアクセント色(--accordion-color-bg-active)を使う。
   背景色のコントラストで開状態の項目が見つけやすくなる。 */
.c-accordion--child .c-accordion__header[aria-expanded="true"] {
  background-color: var(--accordion-color-bg-active);
  color: var(--accordion-color-accent);
}

/* 子アコーディオンの本文 __body / __content も背景色を継承する */
.c-accordion--child .c-accordion__body {
  background-color: var(--accordion-color-bg-child);
}

/* ================================================
   prefers-reduced-motion: 動きを減らす設定への配慮
   前庭機能障害等で OS の「視差効果を減らす」を有効に
   しているユーザーに対し、transition を無効化する。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon {
    transition: none;
  }
}
OPEN

視覚区別の核は次の3点です。

  • 子アコーディオンには薄い背景色 --accordion-color-bg-child#f7f9fc)を当てて、親(白)との差を出す
  • margin-left: var(--accordion-child-indent)16px)で階層を視覚化
  • margin-top: 16px で親本文の末尾に余白を残し、子アコーディオンが親本文の文章に貼り付かないようにする

子アコーディオンのヘッダー・本文も同じ薄い背景色を継承させているので、白の親と濁らず階層が分かりやすくなります。新規に追加したカスタムプロパティは --accordion-color-bg-child--accordion-child-indent の2つです。既存トークン(--accordion-color-text --accordion-color-bg 等)は改変していません。機能拡張時はトークンを追加して使うのが基本です。

BEM のモディファイアで「子用バリエーション」を表現する

子アコーディオンには、ベースの .c-accordion クラスを残したまま .c-accordion--child を追加しています。

HTML
<div class="c-accordion c-accordion--child" data-accordion id="c-accordion-nested-child-1">
  <!-- 子項目たち -->
</div>

この書き方には2つのメリットがあります。

  • 「子アコーディオンも基本ふるまいは親と同じ」「視覚だけ差別化したい」を両立できる
  • JS 側の .c-accordion[data-accordion] セレクタが子にもそのまま効く

別クラス名(例: .nested-accordion)にすると JS 側で root 取得ができなくなり、再実装が必要になります。ベースクラスを残したまま差別化するのが核です。.c-accordion__heading.c-accordion__header も親子共通で使い回せます。

クラス名の c-accordion は FLOCSS という設計手法に沿った命名です。c- は再利用可能なコンポーネントを示す接頭辞、--child は派生バリエーション(モディファイア)を示します。本記事の文脈では「ベースを残してモディファイアで差分を表現する」考え方だけ押さえれば十分です。

JS の落とし穴とスコープガード

ここがこの記事の本筋です。基本のアコーディオン JS をそのまま使うと、子のヘッダーをクリックした時に親と子のハンドラが両方発火してしまいます。これを防ぐのが header.closest(".c-accordion") === root のスコープガードです。

JS 全体は次の通りです。基本実装と同じ IIFE + "use strict" + イベント委譲のパターンを踏襲しています。各ルートのクリックハンドラ内にスコープガードを1行入れた拡張版です。

JavaScript
/**
 * バニラJS + ARIA対応アコーディオン(入れ子・ネスト対応)
 *
 * 設計方針(共通実装を継承):
 * - ルート要素 .c-accordion[data-accordion] に対してイベント委譲する。
 *   親アコーディオンと子アコーディオンはそれぞれ独立した root として
 *   取得し、bindAccordion を個別に仕掛ける(イベント委譲をネストごとに分離)。
 * - 開閉状態は aria-expanded 属性を真実の源(single source of truth)とし、
 *   CSSは [aria-expanded="true"] セレクタで見た目を切り替える。
 *   余分な is-open クラスは付与しない。
 * - 本文の表示/非表示は HTML の hidden 属性で制御する(JS が落ちた場合の
 *   フォールバックとして閉状態が HTML 単体で読み取れる)。
 * - setItemState(header, open) を経由して aria-expanded と hidden を
 *   同時更新するため、状態が必ず一致する(共通シグネチャ)。
 *
 * 本実装の核(入れ子対策):
 * - bindAccordion 内のクリックハンドラに
 *   `header.closest(".c-accordion") !== root` のスコープガードを入れて、
 *   親ハンドラが子の header を拾って二重発火することを防ぐ。
 *   `root.contains(header)` では親が子の header を true で拾うため、
 *   `closest` で「ヘッダーが直属する root」を厳密に判定する必要がある。
 * - collectHeaders(root) でも同様に `header.closest(".c-accordion") === root`
 *   のガードを入れ、root 直下のヘッダーだけに絞り込む。
 * - 親項目を閉じた時に子の aria-expanded はリセットしない
 *   (親を再度開いた時に子の前回状態が復元される設計)。
 *
 * 親と子の data-accordion 属性は値なしの共通属性として扱う
 * (closest(".c-accordion") ベースのスコープガードが機能するため、
 *  親子で属性値を分ける必要はない)。
 */

(function () {
  "use strict";

  // 親 / 子に関わらず .c-accordion[data-accordion] を全て取得し、
  // 各 root に対して bindAccordion を仕掛ける。
  // 親の root と子の root は別物として扱われるため、それぞれのハンドラが
  // 独立した root を持つ(同一 click イベントが両方で発火しないよう
  // bindAccordion 内でスコープガードを入れる)。
  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 直下のヘッダーのみを返す。
   * (root.querySelectorAll だけだと子アコーディオンのヘッダーまで拾うため、
   *  closest(".c-accordion") === 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) {
      if (header.closest(".c-accordion") === root) {
        result.push(header);
      }
    });
    return result;
  }

  /**
   * ルート要素1つに対してイベント委譲を仕掛ける。
   * 親 root と子 root の両方で呼ばれ、それぞれが独立したハンドラを持つ。
   *
   * @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;

      // 入れ子対策のスコープガード(親と子の二重発火防止):
      // ヘッダーから最も近い .c-accordion が「自分自身(root)」でない場合は
      // 子アコーディオンのヘッダーなので処理しない。
      // 子 root のハンドラは別途登録されているため、子のクリックは
      // 子のハンドラだけで処理される。
      if (header.closest(".c-accordion") !== root) return;

      toggleItem(header);
    });
  }

  // collectHeaders は本実装の click 処理では直接呼ばれないが、
  // 後続の拡張(一括操作・排他制御)と API を揃えるためエクスポートに準じた
  // 形で定義しておく(共通シグネチャの維持)。
  void collectHeaders;

  // 全ての root(親も子も)に対して個別にバインドする
  accordionRoots.forEach(bindAccordion);
})();
OPEN

設計の柱は3つです。コードの先頭コメントにある通り基本実装と同じ構造を踏襲した上で、入れ子対応の核となるスコープガードを bindAccordioncollectHeaders の2箇所に入れた構成になっています。

  • 共通シグネチャ維持: setItemState(header, open) / collectHeaders(root) / bindAccordion(root) の3関数は基本実装と同名・同引数
  • 真実の源: aria-expanded 属性を「単一の真実の源」として、CSS も [aria-expanded="true"] セレクタで見た目を切り替える。is-open 等のクラスは付与しない
  • イベント委譲をネストごとに分離: 親 root と子 root はそれぞれ独立した addEventListener を持つ。ハンドラ内でスコープガードを入れて、自分の管轄外なら何もしないと振る舞う

二重発火が起きる仕組み

入れ子化を素朴に実装した場合、子のヘッダーをクリックすると次のような流れが起きます。

  1. 子ヘッダーで click イベントが発生する
  2. click イベントは子ヘッダー → 子 root(子の .c-accordion)→ 親 root(親の .c-accordion)と上方向にバブリングする
  3. 子 root のハンドラが「自分のヘッダーがクリックされた」と判定して toggleItem を呼ぶ
  4. 続けて親 root のハンドラも発火する。event.target の祖先には子のヘッダーがいるため、event.target.closest(".c-accordion__header")子のヘッダーが取れてしまう
  5. 親 root のハンドラも toggleItem(子ヘッダー) を呼んでしまい、子のヘッダーが2回連続でトグルされる(二重発火

結果として子のヘッダーが開いてすぐ閉じる(または閉じてすぐ開く)見た目になり、UI として破綻します。

スコープガードの実装 — closest(".c-accordion") === root

二重発火を防ぐ核は、各ルートのクリックハンドラに入れた1行のガードです。

JavaScript
function bindAccordion(root) {
  root.addEventListener("click", function (event) {
    const header = event.target.closest(".c-accordion__header");
    if (!header) return;

    // 入れ子対策のスコープガード
    if (header.closest(".c-accordion") !== root) return;

    toggleItem(header);
  });
}

このガードは「クリックされたヘッダーから最も近い .c-accordion が、このハンドラの root と一致するか」を判定しています。

  • 一致する → 自分のスコープに直属するヘッダー → 処理する
  • 一致しない → 子(または孫)アコーディオンのヘッダー → 何もしない(子 root のハンドラに任せる)

子のヘッダーがクリックされた時の各ハンドラの動きを並べると次のようになります。

  • 親 root のハンドラ: header.closest(".c-accordion") は子 root を返す → 親 root とは一致しないので return
  • 子 root のハンドラ: header.closest(".c-accordion") は子 root を返す → 子 root と一致するので toggleItem を実行

結果として、親と子のハンドラが完全に独立して動作し、二重発火が起きなくなります。

なぜ root.contains(header) ではダメか

「子ヘッダーが root の中にいるかどうかを判定したい」と思うと、まず root.contains(header) を使いたくなります。これは「root の子孫に header が含まれるか」を判定する API です。

ところがこの方法では二重発火を防げません。親 root から見ると、子のヘッダーも「子孫」に含まれるので true が返ってしまうのです。

// ダメな例(親 root のハンドラから子ヘッダーまで true で拾ってしまう)
if (!root.contains(header)) return;

一方の closest(".c-accordion") は「ヘッダーから上に辿って最も近い .c-accordion」を返します。子のヘッダーの closest は途中で子 root に当たって止まるため、親 root とは別物として判定されます。

つまり違いはこうです。

  • contains包含関係を見る(root の子孫に header がいるか)
  • closest所属関係を見る(header の直属の root はどれか)

入れ子では「包含関係」では足りず、「所属関係」を見る必要があります。これがスコープガードに closest を使う本質的な理由です。

なぜ event.stopPropagation() を使わないか

「子のヘッダーでバブリングを止めれば親 root に届かないのでは?」と考えるのも自然です。実際 event.stopPropagation() を呼べば、確かにバブリングは止まり親 root のハンドラは発火しません。

ただし stopPropagation は他の機能に副作用を与えます。

  • モーダルの「外側クリックで閉じる」判定が効かなくなる
  • ドロップダウンメニューの「他の場所をクリックして閉じる」が効かなくなる
  • ページ全体のクリック解析(GA、ヒートマップ等)にクリックが届かなくなる

「自分以外には何も伝えない」と強く宣言してしまう書き方なので、サイト全体の挙動に影響が出やすいのです。

これに対してスコープガード方式は「自分の管轄外なら何もしない」と振る舞うだけで、バブリング自体は止めません。他のリスナーは通常通り発火するため、サイト全体の動きを壊しません。入れ子の二重発火対策では、stopPropagation よりスコープガードのほうが副作用が少なく安全です。

collectHeaders(root) でも同じガードを使う

スコープガードは click ハンドラだけでなく、ヘッダーを取得する補助関数 collectHeaders(root) でも必要です。

JavaScript
function collectHeaders(root) {
  const candidates = root.querySelectorAll(".c-accordion__header");
  const result = [];
  candidates.forEach(function (header) {
    if (header.closest(".c-accordion") === root) {
      result.push(header);
    }
  });
  return result;
}

root.querySelectorAll(".c-accordion__header") だけだと、root 内の全ヘッダー(子アコーディオンのヘッダーも含む)を拾ってしまいます。同じ closest(".c-accordion") === root のガードで「自分の root に直属するヘッダー」だけに絞り込む形です。

この補助関数は本記事の click 処理では直接呼ばれません。ただし、後から「すべて開く / すべて閉じる」「同時に1つだけ開く(排他制御)」のような一括操作を追加する時に必要になります。スコープガードを入れておけば、一括操作を追加しても子アコーディオンに誤動作が及びません。

id 衝突を防ぐ命名規則

入れ子では同じ HTML 構造が繰り返されるため、id が衝突しやすいことに注意が必要です。aria-controlsaria-labelledby は id 参照で動くため、id が重複すると ARIA 属性が機能せず、支援技術が誤った要素を読み上げてしまいます。

命名の原則はシンプルで、id にスコープ識別子を含めて親子を分離します。

  • 親: c-accordion-{識別子}-parent-{連番}-{種別}
  • 子: c-accordion-{識別子}-child-{親連番}-{子連番}-{種別}

{種別}header(ボタン用)と panel(パネル用)の2種類です。同一ページに複数の入れ子アコーディオンを置く場合は、{識別子} の部分を faq / help のように分けてさらにスコープを切ります。

親と子の id 命名例

要素id 命名例役割
親項目1 のヘッダーc-accordion-nested-parent-1-header<button> の id(aria-labelledby 参照用)
親項目1 のパネルc-accordion-nested-parent-1-panel<div class="__body"> の id(aria-controls 参照用)
親項目1配下 / 子項目1 のヘッダーc-accordion-nested-child-1-1-header親1配下の子1番目のヘッダー
親項目1配下 / 子項目1 のパネルc-accordion-nested-child-1-1-panel親1配下の子1番目のパネル
親項目1配下 / 子項目2 のヘッダーc-accordion-nested-child-1-2-header親1配下の子2番目のヘッダー
親項目2配下 / 子項目1 のヘッダーc-accordion-nested-child-2-1-header親2配下の子1番目のヘッダー

このルールでは「親連番」と「子連番」を id に含めるため、新しい親項目や子項目を追加しても既存の id とぶつかりません。実案件では nestedfaqhelp 等の案件固有の識別子に置き換えて使ってください。

親を閉じた時に子の状態をリセットしない設計判断

本実装では、親を閉じても子の aria-expanded は変更しません。親を閉じる時は親のヘッダーの状態だけを false にし、子の開閉状態はそのまま残します。

この設計のメリットは、親を再度開いた時に子の前回状態(開いていた項目は開いたまま)が復元されることです。「さっき開いた子項目をもう一度開く手間」がない分、操作の連続性が保たれます。FAQ やヘルプセンターのように「親を一度閉じてもまた同じ親を開きにくる」操作が多い UI に向きます。リセットしないほうが体験は穏当です。

一方で「親を閉じたら子もすべて閉じる」リセット仕様にしたい場合は、toggleItem 内で親を閉じる時に「子 root 内の全ヘッダーを閉じる」処理を追加してください。具体的には次のような流れになります。

JavaScript
// 親を閉じる時に子もリセットする実装例(差分のイメージ)
if (!open) {
  // 親 .c-accordion__item 配下の子 root を取得
  const childRoot = header.closest(".c-accordion__item").querySelector(".c-accordion--child");
  if (childRoot) {
    collectHeaders(childRoot).forEach(function (h) {
      setItemState(h, false);
    });
  }
}

どちらの設計が良いかはサイトの性質次第です。「操作の連続性」と「常にクリーンな初期状態」のどちらを優先するかで選び分けてください。

コピペで使うための完成コード

ここまでの内容をまとめて、HTML / CSS / JS をフルで再掲します。このまま貼り付ければ動作する状態です。

HTML
<div class="c-accordion" data-accordion id="c-accordion-nested-parent">
  <!-- 親項目 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-nested-parent-1-panel"
        id="c-accordion-nested-parent-1-header"
      >
        <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-nested-parent-1-panel"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          親アコーディオンの本文 <code>.c-accordion__body</code> の内側に、もう1つ <code>.c-accordion</code> を入れるだけで作れます。
          以下のサブ質問は、親アコーディオンを開いた時にだけ表示される「ネスト子アコーディオン」です。
          親と子は独立した <code>data-accordion</code> root として扱われ、それぞれにクリックハンドラが仕掛けられます。
        </p>

        <!-- 子アコーディオン(深さ1段階のみ) -->
        <div class="c-accordion c-accordion--child" data-accordion id="c-accordion-nested-child-1">
          <!-- 子項目 1-1 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-1-1-panel"
                id="c-accordion-nested-child-1-1-header"
              >
                <span class="c-accordion__title">親と子で <code>data-accordion</code> の値は変える必要がある?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-1-1-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  変える必要はありません。親も子も <code>data-accordion</code><strong>値なしの共通属性</strong> として付けるだけで動きます。
                  JS 側で各 <code>.c-accordion[data-accordion]</code> を独立した root として取得し、それぞれに個別のクリックハンドラを仕掛けるためです。
                </p>
              </div>
            </div>
          </div>

          <!-- 子項目 1-2 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-1-2-panel"
                id="c-accordion-nested-child-1-2-header"
              >
                <span class="c-accordion__title">どこまでネストできる?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-1-2-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  技術的にはさらに深くもできますが、UI として読みやすいのは <strong>1段階まで</strong> です。
                  2段以上の入れ子は視覚的な階層が伝わりにくく、操作経路も長くなるため、
                  本実装も「親 → 子の1段階のみ」に絞っています。
                </p>
              </div>
            </div>
          </div>

          <!-- 子項目 1-3 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-1-3-panel"
                id="c-accordion-nested-child-1-3-header"
              >
                <span class="c-accordion__title">子の状態は親を閉じても残る?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-1-3-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  残ります。親を閉じても子の <code>aria-expanded</code> は変更しないため、
                  親を再度開いた時に子の前回状態(開いていた項目は開いたまま)が復元されます。
                  「親を閉じたら子もすべて閉じる」リセット仕様にしたい場合は別途実装してください。
                </p>
              </div>
            </div>
          </div>
        </div>
        <!-- 子アコーディオンここまで -->
      </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-nested-parent-2-panel"
        id="c-accordion-nested-parent-2-header"
      >
        <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-nested-parent-2-panel"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          クリックハンドラ内で、ヘッダーから最も近い <code>.c-accordion</code> が自分の root と一致するかを判定します。
          これにより、親 root のハンドラが子の <code>.c-accordion__header</code> を拾って二重発火する事故を防げます。
        </p>

        <!-- 子アコーディオン(深さ1段階のみ) -->
        <div class="c-accordion c-accordion--child" data-accordion id="c-accordion-nested-child-2">
          <!-- 子項目 2-1 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-2-1-panel"
                id="c-accordion-nested-child-2-1-header"
              >
                <span class="c-accordion__title"><code>root.contains(header)</code> ではダメ?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-2-1-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  <code>root.contains(header)</code> は親 root が子の <code>.c-accordion__header</code><code>true</code> で拾ってしまうため、
                  二重発火を防げません。<code>header.closest(".c-accordion") === root</code> で「ヘッダーが直属する root」を厳密に判定する必要があります。
                </p>
              </div>
            </div>
          </div>

          <!-- 子項目 2-2 -->
          <div class="c-accordion__item">
            <h3 class="c-accordion__heading">
              <button
                type="button"
                class="c-accordion__header"
                aria-expanded="false"
                aria-controls="c-accordion-nested-child-2-2-panel"
                id="c-accordion-nested-child-2-2-header"
              >
                <span class="c-accordion__title">イベントの伝播は止めないの?</span>
                <span class="c-accordion__icon" aria-hidden="true"></span>
              </button>
            </h3>
            <div
              class="c-accordion__body"
              id="c-accordion-nested-child-2-2-panel"
              hidden
            >
              <div class="c-accordion__content">
                <p>
                  止めません。<code>event.stopPropagation()</code> は他の機能(モーダルの外側クリック判定など)に副作用を与えるため、
                  スコープガード方式で「自分の管轄外なら何もしない」と振る舞う方が安全です。
                  子 root の <code>addEventListener</code> 自体は独立しているため、子のクリックは子のハンドラだけで処理されます。
                </p>
              </div>
            </div>
          </div>
        </div>
        <!-- 子アコーディオンここまで -->
      </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-nested-parent-3-panel"
        id="c-accordion-nested-parent-3-header"
      >
        <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-nested-parent-3-panel"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          独立します。クリック対象に <code><button type="button"></code> を使っているため、Tab フォーカスは親ヘッダー → 子ヘッダーの順にネイティブな DOM 順で移動し、
          Enter / Space は各ボタンが個別に発火します。スコープガードは「親の click ハンドラから子を見ない」「子の click ハンドラから親を見ない」を実現するためのもので、
          キーボード操作のフォーカス順序や発火タイミング自体には影響しません。
        </p>
        <p>
          なお、親項目を閉じると子のボタンは <code>hidden</code> 配下に入るため Tab フォーカスからも外れます。
          親を再度開けば自然に Tab 順序へ戻ります。
        </p>
      </div>
    </div>
  </div>
</div>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   共通のトークンセットを継承する(既存値は改変しない)。
   入れ子(ネスト)専用に子アコーディオン用の背景色と
   左インデントの2トークンを追加する。
   命名規則は --accordion-{役割}-{プロパティ} を維持。
   ================================================ */
: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;

  /* 入れ子用トークン(本実装で追加)
     子アコーディオンを視覚的に区別するための背景色(薄)と
     左インデント幅を一元管理する。 */
  --accordion-color-bg-child:    #f7f9fc;
  --accordion-child-indent:      16px;
}

/* ================================================
   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> / <h3> をボタンのラッパーに使うため、見出しの
   デフォルト余白・サイズを打ち消す。
   ================================================ */
.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> 属性で非表示にする。
   ================================================ */
.c-accordion__body {
  background-color: var(--accordion-color-bg);
}

/* ================================================
   c-accordion__content: 本文の余白を持つ内側ラッパー
   外側 __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;
}

/* ================================================
   c-accordion--child: 子アコーディオン(入れ子)
   本実装で追加。親の本文 __content の中に置かれた
   2つ目以降の .c-accordion を視覚的に区別する。
   - 背景色を薄い --accordion-color-bg-child に変えて
     「親に内包された情報」であることを示す。
   - 左インデント(--accordion-child-indent)で階層を表現。
   - 親本文の末尾に余白を残すため margin-top を持たせる。
   ================================================ */
.c-accordion--child {
  background-color: var(--accordion-color-bg-child);
  margin-top: 16px;
  margin-left: var(--accordion-child-indent);
}

/* 子アコーディオンのヘッダーは背景色を継承して、
   親ヘッダー(白)との差を視覚的に保つ。 */
.c-accordion--child .c-accordion__header {
  background-color: var(--accordion-color-bg-child);
}

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

/* 子の開状態は親と同じアクセント色(--accordion-color-bg-active)を使う。
   背景色のコントラストで開状態の項目が見つけやすくなる。 */
.c-accordion--child .c-accordion__header[aria-expanded="true"] {
  background-color: var(--accordion-color-bg-active);
  color: var(--accordion-color-accent);
}

/* 子アコーディオンの本文 __body / __content も背景色を継承する */
.c-accordion--child .c-accordion__body {
  background-color: var(--accordion-color-bg-child);
}

/* ================================================
   prefers-reduced-motion: 動きを減らす設定への配慮
   前庭機能障害等で OS の「視差効果を減らす」を有効に
   しているユーザーに対し、transition を無効化する。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon {
    transition: none;
  }
}
OPEN
CSS
/**
 * バニラJS + ARIA対応アコーディオン(入れ子・ネスト対応)
 *
 * 設計方針(共通実装を継承):
 * - ルート要素 .c-accordion[data-accordion] に対してイベント委譲する。
 *   親アコーディオンと子アコーディオンはそれぞれ独立した root として
 *   取得し、bindAccordion を個別に仕掛ける(イベント委譲をネストごとに分離)。
 * - 開閉状態は aria-expanded 属性を真実の源(single source of truth)とし、
 *   CSSは [aria-expanded="true"] セレクタで見た目を切り替える。
 *   余分な is-open クラスは付与しない。
 * - 本文の表示/非表示は HTML の hidden 属性で制御する(JS が落ちた場合の
 *   フォールバックとして閉状態が HTML 単体で読み取れる)。
 * - setItemState(header, open) を経由して aria-expanded と hidden を
 *   同時更新するため、状態が必ず一致する(共通シグネチャ)。
 *
 * 本実装の核(入れ子対策):
 * - bindAccordion 内のクリックハンドラに
 *   `header.closest(".c-accordion") !== root` のスコープガードを入れて、
 *   親ハンドラが子の header を拾って二重発火することを防ぐ。
 *   `root.contains(header)` では親が子の header を true で拾うため、
 *   `closest` で「ヘッダーが直属する root」を厳密に判定する必要がある。
 * - collectHeaders(root) でも同様に `header.closest(".c-accordion") === root`
 *   のガードを入れ、root 直下のヘッダーだけに絞り込む。
 * - 親項目を閉じた時に子の aria-expanded はリセットしない
 *   (親を再度開いた時に子の前回状態が復元される設計)。
 *
 * 親と子の data-accordion 属性は値なしの共通属性として扱う
 * (closest(".c-accordion") ベースのスコープガードが機能するため、
 *  親子で属性値を分ける必要はない)。
 */

(function () {
  "use strict";

  // 親 / 子に関わらず .c-accordion[data-accordion] を全て取得し、
  // 各 root に対して bindAccordion を仕掛ける。
  // 親の root と子の root は別物として扱われるため、それぞれのハンドラが
  // 独立した root を持つ(同一 click イベントが両方で発火しないよう
  // bindAccordion 内でスコープガードを入れる)。
  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 直下のヘッダーのみを返す。
   * (root.querySelectorAll だけだと子アコーディオンのヘッダーまで拾うため、
   *  closest(".c-accordion") === 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) {
      if (header.closest(".c-accordion") === root) {
        result.push(header);
      }
    });
    return result;
  }

  /**
   * ルート要素1つに対してイベント委譲を仕掛ける。
   * 親 root と子 root の両方で呼ばれ、それぞれが独立したハンドラを持つ。
   *
   * @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;

      // 入れ子対策のスコープガード(親と子の二重発火防止):
      // ヘッダーから最も近い .c-accordion が「自分自身(root)」でない場合は
      // 子アコーディオンのヘッダーなので処理しない。
      // 子 root のハンドラは別途登録されているため、子のクリックは
      // 子のハンドラだけで処理される。
      if (header.closest(".c-accordion") !== root) return;

      toggleItem(header);
    });
  }

  // collectHeaders は本実装の click 処理では直接呼ばれないが、
  // 後続の拡張(一括操作・排他制御)と API を揃えるためエクスポートに準じた
  // 形で定義しておく(共通シグネチャの維持)。
  void collectHeaders;

  // 全ての root(親も子も)に対して個別にバインドする
  accordionRoots.forEach(bindAccordion);
})();
OPEN

カスタマイズしたい場面でよく触るのは次のポイントです。

  • 子アコーディオンの背景色: --accordion-color-bg-child を別の薄色(例: #fff8e8 等)に変更
  • 子アコーディオンの左インデント: --accordion-child-indent16px から 24px 等に変更
  • id の識別子: c-accordion-nested-nested- を案件名(faq- / help- 等)に置換
  • 親項目・子項目を増やす: .c-accordion__item を追加し、id の親連番・子連番をずらす
  • ネスト深度を変える: 推奨は1段階まで。2段以上にする場合も同じスコープガードが効くため技術的には可能だが、UI として読みづらくなる
  • 親閉時に子をリセットする: setItemState 内で親を閉じる時に子 root の全ヘッダーを setItemState(_, false) するロジックを追加

よくある質問

closest(".c-accordion")root.contains(header) の違いは何ですか?

contains は「root の子孫に header が含まれるか」を判定するので、親 root にとって子のヘッダーも true で拾ってしまいます(包含関係)。一方 closest は「header から上に辿って最も近い .c-accordion」を返すので、子のヘッダーの closest は子 root を返します(所属関係)。二重発火を防ぐには「所属関係」を判定する closest を使う必要があります。

親と子で data-accordion の属性値を変える必要はありますか?

変える必要はありません。親も子も data-accordion を値なしの共通属性として付けるだけで動きます。JS 側で各 .c-accordion[data-accordion] を独立した root として取得し、それぞれに個別のクリックハンドラを仕掛けるためです。スコープガードが closest(".c-accordion") で機能するため、属性値で親子を区別する必要はなく、値を付けるとセレクタが冗長になります。

親を閉じたら子もすべて閉じる仕様にできますか?

できます。本記事のコードでは「親を閉じても子の状態は維持する」設計を採用していますが、リセット仕様にしたい場合は setItemState で親を閉じる時に「子 root 内の全ヘッダーを閉じる」処理を追加してください。具体的には collectHeaders(子root).forEach(h => setItemState(h, false)) 相当のロジックを toggleItem の親閉時パスに足します。「親を再度開いた時の操作の連続性」と「常にクリーンな初期状態」のどちらを優先するかで選び分けてください。

何段までネストしてよいですか?

技術的にはスコープガードが効くため何段でもネスト可能です。ただし UI として読みやすいのは1段階までです。2段以上の入れ子は視覚的な階層が伝わりにくく、操作経路も長くなります。本記事の実装も「親 → 子の1段階のみ」に絞っています。3階層以上が必要な場合は、アコーディオン以外の UI パターン(タブ + アコーディオン / サイドナビ + ページ内アコーディオン 等)も検討してください。

キーボード操作は親子それぞれ独立しますか?

独立します。クリック対象に <button type="button"> を使っているため、Tab フォーカスは親ヘッダー → 子ヘッダーの順にネイティブな DOM 順で移動します。Enter / Space は各ボタンが個別に発火します。スコープガードは「親の click ハンドラから子を見ない」「子の click ハンドラから親を見ない」を実現するためのものです。キーボード操作のフォーカス順序や発火タイミング自体には影響しません。なお、親項目を閉じると子のボタンは hidden 配下に入るため Tab フォーカスからも外れ、親を再度開けば自然に Tab 順序へ戻ります。

まとめ

この記事では、入れ子アコーディオンの実装パターンと、親子の二重発火を防ぐスコープガードを紹介しました。ポイントを整理します。

  • 入れ子化は親 .c-accordion__content 内に子 .c-accordion を置くだけで HTML 構造は完成する
  • 子アコーディオンには .c-accordion--child モディファイアを追加し、背景色+左インデントで親と視覚区別する
  • JS の核は header.closest(".c-accordion") !== root のスコープガード。これで親と子のハンドラが独立して動き、二重発火が起きない
  • event.stopPropagation() ではなく closest ベースのガードを使う理由は「他の機能に副作用を与えない」ため
  • id 命名は親 = c-accordion-{識別子}-parent-{連番}-{種別} / 子 = c-accordion-{識別子}-child-{親連番}-{子連番}-{種別} でスコープを分離する
  • ネストの深さは1段階までを目安にすると読みやすさを保てる

「二重発火を起こさず親子を独立させる」設計が入れ子アコーディオンの本質です。スコープガードを closest で書く形は、メガメニュー・タブ内タブ・カード内のクリック要素など他の入れ子 UI にも応用できる考え方なので、まずはこのコードをコピペして動かし、closest の返り値を console.log で観察してみるところから始めるのがおすすめです。

【関連記事】

【シリーズ:アコーディオンUIシリーズ】
→ アコーディオン実装パターンまとめ(公開予定