アコーディオンUIを作りたいけれど、<details> 要素だと見た目の制御が難しく感じることはないでしょうか。クリックで開閉する仕組みは書けても、キーボード操作やスクリーンリーダー対応まで自信を持って実装できる、と言える方は意外と少ないかもしれません。

この記事では、バニラJavaScriptと aria-expanded 属性を使った「現場のデファクト」と言えるアコーディオン実装を紹介します。コピペでそのまま動くHTML・CSS・JSを提供しつつ、なぜそう書くのかまで踏み込んで解説します。

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

🌐 デモを見る(GitHub Pages)

💻 ソースコード(GitHub)

この記事で分かること
  • バニラJSと aria-expanded で作るアクセシビリティ対応アコーディオンの基本形
  • なぜ <button> 要素を使うのか・aria-controls の役割
  • hidden 属性を使うメリット(CSSが落ちても安全に閉じる)
  • イベント委譲で書くことで、後から拡張しやすくなる設計

このスニペットで作れるもの

クリックで開閉できる4項目のアコーディオンUIです。複数の項目を同時に開ける、シンプルな「ベース実装」となっています。

動作の特徴は次の通りです。

  • クリックで開閉(aria-expandedtrue / false に切り替え)
  • Enter / Space キーで操作可能(<button> 要素のネイティブ機能)
  • スクリーンリーダーが「展開ボタン」として認識
  • 複数項目を同時に開ける(独立した開閉動作)
  • hidden 属性で非表示制御(CSSが無効でも閉じた状態を維持)
  • 開閉インジケータ(▼)がCSSのみで回転

完成イメージ

HTMLの構造を見てみよう

まずはHTML全体を見てみましょう。各項目は <button>(ヘッダー)と <div hidden>(パネル)の2要素構造になっています。

HTML
<div class="c-accordion" data-accordion>
  <!-- 項目 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-panel-1"
        id="c-accordion-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-panel-1"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          アコーディオンUIとは、見出し(ヘッダー)をクリックすると本文(パネル)が折りたたみ式で開閉するUIパターンです。
          限られた画面領域に多くの情報を整理して配置できるため、FAQ・サイドメニュー・スマートフォン向けナビゲーション等で広く使われています。
        </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-panel-2"
        id="c-accordion-header-2"
      >
        <span class="c-accordion__title">なぜ <code><button></code> 要素を使うのか?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-panel-2"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          <code><button></code> はクリックイベントだけでなく Enter / Space キーでの操作・タブキーでのフォーカス移動・スクリーンリーダーへのボタン通知をブラウザがネイティブに処理してくれるためです。
        </p>
        <p>
          <code><div></code><code>role="button"</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-panel-3"
        id="c-accordion-header-3"
      >
        <span class="c-accordion__title"><code>aria-expanded</code><code>aria-controls</code> の役割は?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-panel-3"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          <code>aria-expanded</code> はボタンが制御する領域が「現在開いているか閉じているか」をスクリーンリーダーに伝える属性です。
          <code>true</code> / <code>false</code> をJSで切り替えます。
        </p>
        <p>
          <code>aria-controls</code> はボタンがどの要素を制御しているかを <code>id</code> で関連付ける属性です。
          本文側の <code>id</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-panel-4"
        id="c-accordion-header-4"
      >
        <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-panel-4"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          このスニペット(001 js-toggle)は <strong>複数同時に開けるベース実装</strong> です。
          各項目は他の項目の状態に影響されず、それぞれ独立して開閉します。
        </p>
        <p>
          「1つだけ開いて他は閉じる」排他制御や「複数同時に開ける」明示的なパターンは、それぞれ別スニペットとして用意しています。
        </p>
      </div>
    </div>
  </div>
</div>
OPEN

クラス名の c-accordion は FLOCSS という設計手法に沿った命名です。c- は再利用可能なコンポーネントを示す接頭辞で、ここでは詳細には踏み込まず、命名の意図だけ把握すれば十分です。

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

  • ルート .c-accordion[data-accordion] をJSが委譲先として参照する
  • 各項目は <h2> で囲んだ <button>(ヘッダー)と <div hidden>(パネル)の2要素で構成
  • aria-expandedaria-controlsid の3属性で「ボタン⇄パネル」を関連付ける

なぜ <button> 要素を使うのか

<div>role="button" を付与しても見た目上は同じものが作れます。それでも <button> を選ぶ理由は、自前で実装すべき機能の量が大きく違うからです。

<button> を使えば、ブラウザが次の機能をネイティブに提供してくれます。

  • クリックイベントの受け取り
  • Enter / Space キーでの操作
  • Tabキーでのフォーカス移動
  • スクリーンリーダーへの「ボタン」通知

<div> で代替する場合、これらをすべて自前で書く必要があり、抜け漏れが事故につながります。「コピペで楽する場面でこそ、ネイティブHTMLに乗っかる」のが安全な選択です。

aria-expanded と aria-controls の役割

ARIA属性は支援技術(スクリーンリーダー等)に追加情報を伝えるための仕組みです。アコーディオンでは次の2つを必ずセットで使います。

  • aria-expanded: ボタンが制御する領域が「開いているか閉じているか」を true / false で表現する
  • aria-controls: ボタンが操作するパネルを id で関連付ける

aria-controls の値は、対応するパネル側の id 属性と一致させます。両方をセットで指定して、ARIA対応の形が整います。

CSSの設計(カスタムプロパティとフォーカスリング)

CSSは「必要な部分は丁寧に、深追いはしない」設計です。各セクションのコメントを見ながら全体を眺めてみてください。

CSS
/* ================================================
   カスタムプロパティ
   後続スニペット(004 multi-open / 005 single-open / 007 nested)
   が同じトークンで色やサイズを揃えられるよう、
   ここで一元管理する。
   ================================================ */
: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;
}

/* 開いている項目のヘッダーは背景色を変えて視認性を上げる
   後続スニペット(004/005/007)も同じセレクタで状態表現できる */
.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: 本文の余白を持つ内側ラッパー
   後続スニペット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 を無効化する。
   後続スニペット 007(animation)でも同じメディアクエリを
   採用するため、軸となる 001 で先行して導入する。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon {
    transition: none;
  }
}
OPEN

CSSで押さえておきたいのは次の4点です。コード内コメントが「なぜそう書いたか」を説明しているので、ここでは要点だけ示します。

  • :root のカスタムプロパティで色・余白・角丸・トランジションを一元管理(後から色変更が1箇所で済む)
  • ヘッダーの min-height: 44px でモバイルのタップ領域を最低保証(アクセシビリティ要件)
  • :focus-visible で「キーボード操作時のみ」フォーカスリングを表示(マウスクリックでは出さない)
  • prefers-reduced-motion: reduce で「視差効果を減らす」設定のユーザーへ配慮

開閉インジケータ(▼)は border 2辺をrotateさせるだけのCSSシェブロンで、aria-expanded="true" のとき -135deg に回って ▲ に変わります。

JavaScriptで開閉を実装する

ここがこの記事の本筋です。JS全体は次の通りで、80行に満たないシンプルな構成です。

JavaScript
/**
 * 001_js-toggle
 * バニラJS + ARIA対応アコーディオン(複数同時開閉許容のベース実装)
 *
 * 設計方針:
 * - ルート要素 .c-accordion[data-accordion] に対してイベント委譲する
 *   これにより、後続スニペット(004 multi-open / 005 single-open /
 *   007 nested)が同じパターンで拡張できる。
 * - 開閉状態は aria-expanded 属性を真実の源(single source of truth)とし、
 *   CSSは [aria-expanded="true"] セレクタで見た目を切り替える。
 *   余分な is-open クラスは付与しない。
 * - 本文の表示/非表示は HTML の hidden 属性で制御する。
 *   display:none と同等の振る舞いをHTML側で表現でき、
 *   JSが落ちた場合でも閉じた状態として安全にフォールバックする。
 *
 * 機能:
 * - ヘッダー(<button class="c-accordion__header">)のクリックで開閉
 * - <button> 要素を使用しているため Enter / Space キーでの操作は
 *   ブラウザがネイティブに処理する(追加のキーハンドラ不要)
 * - 各項目は独立して開閉する(複数同時に開ける)
 */

(function () {
  "use strict";

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

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

  /**
   * 1つのヘッダーボタンの開閉状態をトグルする
   *
   * @param {HTMLButtonElement} header - クリックされたヘッダーボタン
   */
  function toggleItem(header) {
    const isExpanded = header.getAttribute("aria-expanded") === "true";
    const panelId = header.getAttribute("aria-controls");
    const panel = panelId ? document.getElementById(panelId) : null;

    if (!panel) return;

    if (isExpanded) {
      // 閉じる
      header.setAttribute("aria-expanded", "false");
      panel.setAttribute("hidden", "");
    } else {
      // 開く
      header.setAttribute("aria-expanded", "true");
      panel.removeAttribute("hidden");
    }
  }

  /**
   * ルート要素1つに対してイベント委譲を仕掛ける
   *
   * @param {HTMLElement} root - .c-accordion[data-accordion] 要素
   */
  function bindAccordion(root) {
    root.addEventListener("click", function (event) {
      // 拡張ポイント: 後続スニペットはここで closest の対象を変えたり、
      // 排他制御(他の項目を閉じる)処理を追加する。
      const header = event.target.closest(".c-accordion__header");
      if (!header) return;

      // 入れ子アコーディオン対策(後続 snippet 007 nested で必須):
      // ヘッダーから最も近い .c-accordion が「自分自身(root)」でない場合は
      // 子アコーディオンのヘッダーなので処理しない。
      // root.contains(header) では親が子の header を true で拾ってしまい、
      // 親と子の二重発火が起きるため、closest で厳密にスコープを絞る。
      if (header.closest(".c-accordion") !== root) return;

      toggleItem(header);
    });
  }

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

設計の柱は3つあります。コードの先頭コメントにも書かれている通り、これらは後続のパターン(複数同時 / 排他制御 / 入れ子等)を作るときの土台になります。

  • イベント委譲: ルート .c-accordion[data-accordion] に1つだけリスナーを付け、内部の closest('.c-accordion__header') でヘッダーを特定する
  • aria-expanded を真実の源にする: 状態管理を1箇所に寄せ、is-open 等のクラスは追加しない。CSSも [aria-expanded="true"] セレクタで見た目を切り替える
  • hidden 属性で表示制御: HTML仕様の「閉じている状態」を直接表現でき、JSが失敗しても安全に閉じた状態を保てる

toggleItem 関数の動き

toggleItem は1つのヘッダーボタンを受け取り、開閉状態を反転させる関数です。

getAttribute("aria-expanded") === "true" で現在の状態を読み取り、反対側の値を setAttribute で書き戻します。同時に、対応するパネル(aria-controls で関連付けた要素)の hidden 属性を setAttribute / removeAttribute で切り替えます。

panel.hidden = true というプロパティ経由の書き方もありますが、ここでは aria-expanded の操作と同じ「属性API」に揃えています。読むときに視線が一定になり、コードの意図がわかりやすくなります。

イベント委譲とスコープ分離

addEventListener を各ボタンに付けるのではなく、ルート要素1つに対してだけ付けています。クリックされた要素から closest('.c-accordion__header') でヘッダーを探す方式です。

加えて header.closest('.c-accordion') !== root というガード処理を入れています。これは入れ子アコーディオン(アコーディオンの中にアコーディオンを入れるパターン)を作るときに必要になる仕組みです。親のリスナーが子のクリックを拾って二重発火するのを防ぐ役割があります。

このスニペット単体では入れ子の対応は不要ですが、最初から組み込んでおくことで、後で拡張するときにこの部分を書き直さずに済みます。

なぜ Enter / Space キー対応を書かないのか

ヘッダーを <button> 要素にしているため、Enter / Space キーでの操作はブラウザが自動で処理してくれます。keydown イベントを自前で書く必要はありません。

もし <div> で作っていたら、キーボード対応コード・フォーカス管理・スクリーンリーダー対応をすべて手で書くことになります。「HTMLの選択がそのままJSのコード量を減らす」良い例だと言えます。

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

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

HTML
<div class="c-accordion" data-accordion>
  <!-- 項目 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-panel-1"
        id="c-accordion-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-panel-1"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          アコーディオンUIとは、見出し(ヘッダー)をクリックすると本文(パネル)が折りたたみ式で開閉するUIパターンです。
          限られた画面領域に多くの情報を整理して配置できるため、FAQ・サイドメニュー・スマートフォン向けナビゲーション等で広く使われています。
        </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-panel-2"
        id="c-accordion-header-2"
      >
        <span class="c-accordion__title">なぜ <code><button></code> 要素を使うのか?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-panel-2"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          <code><button></code> はクリックイベントだけでなく Enter / Space キーでの操作・タブキーでのフォーカス移動・スクリーンリーダーへのボタン通知をブラウザがネイティブに処理してくれるためです。
        </p>
        <p>
          <code><div></code><code>role="button"</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-panel-3"
        id="c-accordion-header-3"
      >
        <span class="c-accordion__title"><code>aria-expanded</code><code>aria-controls</code> の役割は?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-panel-3"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          <code>aria-expanded</code> はボタンが制御する領域が「現在開いているか閉じているか」をスクリーンリーダーに伝える属性です。
          <code>true</code> / <code>false</code> をJSで切り替えます。
        </p>
        <p>
          <code>aria-controls</code> はボタンがどの要素を制御しているかを <code>id</code> で関連付ける属性です。
          本文側の <code>id</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-panel-4"
        id="c-accordion-header-4"
      >
        <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-panel-4"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          このスニペット(001 js-toggle)は <strong>複数同時に開けるベース実装</strong> です。
          各項目は他の項目の状態に影響されず、それぞれ独立して開閉します。
        </p>
        <p>
          「1つだけ開いて他は閉じる」排他制御や「複数同時に開ける」明示的なパターンは、それぞれ別スニペットとして用意しています。
        </p>
      </div>
    </div>
  </div>
</div>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   後続スニペット(004 multi-open / 005 single-open / 007 nested)
   が同じトークンで色やサイズを揃えられるよう、
   ここで一元管理する。
   ================================================ */
: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;
}

/* 開いている項目のヘッダーは背景色を変えて視認性を上げる
   後続スニペット(004/005/007)も同じセレクタで状態表現できる */
.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: 本文の余白を持つ内側ラッパー
   後続スニペット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 を無効化する。
   後続スニペット 007(animation)でも同じメディアクエリを
   採用するため、軸となる 001 で先行して導入する。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon {
    transition: none;
  }
}
OPEN
JavaScript
/**
 * 001_js-toggle
 * バニラJS + ARIA対応アコーディオン(複数同時開閉許容のベース実装)
 *
 * 設計方針:
 * - ルート要素 .c-accordion[data-accordion] に対してイベント委譲する
 *   これにより、後続スニペット(004 multi-open / 005 single-open /
 *   007 nested)が同じパターンで拡張できる。
 * - 開閉状態は aria-expanded 属性を真実の源(single source of truth)とし、
 *   CSSは [aria-expanded="true"] セレクタで見た目を切り替える。
 *   余分な is-open クラスは付与しない。
 * - 本文の表示/非表示は HTML の hidden 属性で制御する。
 *   display:none と同等の振る舞いをHTML側で表現でき、
 *   JSが落ちた場合でも閉じた状態として安全にフォールバックする。
 *
 * 機能:
 * - ヘッダー(<button class="c-accordion__header">)のクリックで開閉
 * - <button> 要素を使用しているため Enter / Space キーでの操作は
 *   ブラウザがネイティブに処理する(追加のキーハンドラ不要)
 * - 各項目は独立して開閉する(複数同時に開ける)
 */

(function () {
  "use strict";

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

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

  /**
   * 1つのヘッダーボタンの開閉状態をトグルする
   *
   * @param {HTMLButtonElement} header - クリックされたヘッダーボタン
   */
  function toggleItem(header) {
    const isExpanded = header.getAttribute("aria-expanded") === "true";
    const panelId = header.getAttribute("aria-controls");
    const panel = panelId ? document.getElementById(panelId) : null;

    if (!panel) return;

    if (isExpanded) {
      // 閉じる
      header.setAttribute("aria-expanded", "false");
      panel.setAttribute("hidden", "");
    } else {
      // 開く
      header.setAttribute("aria-expanded", "true");
      panel.removeAttribute("hidden");
    }
  }

  /**
   * ルート要素1つに対してイベント委譲を仕掛ける
   *
   * @param {HTMLElement} root - .c-accordion[data-accordion] 要素
   */
  function bindAccordion(root) {
    root.addEventListener("click", function (event) {
      // 拡張ポイント: 後続スニペットはここで closest の対象を変えたり、
      // 排他制御(他の項目を閉じる)処理を追加する。
      const header = event.target.closest(".c-accordion__header");
      if (!header) return;

      // 入れ子アコーディオン対策(後続 snippet 007 nested で必須):
      // ヘッダーから最も近い .c-accordion が「自分自身(root)」でない場合は
      // 子アコーディオンのヘッダーなので処理しない。
      // root.contains(header) では親が子の header を true で拾ってしまい、
      // 親と子の二重発火が起きるため、closest で厳密にスコープを絞る。
      if (header.closest(".c-accordion") !== root) return;

      toggleItem(header);
    });
  }

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

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

  • 色: :root のカスタムプロパティ(--accordion-color-*)を変更
  • 余白: --accordion-header-padding-y/x--accordion-body-padding-y/x
  • 角丸: --accordion-radius
  • アニメーション速度: --accordion-transition
  • 項目数: HTMLの .c-accordion__item を増やし、id の数字を連番でずらす
  • 初期で開いた状態にする: 該当項目の aria-expanded="true" + パネルから hidden 属性を削除

よくある質問

なぜ display:none ではなく hidden 属性を使うのですか?

hidden 属性はHTMLの仕様として「閉じている」状態を表現できる属性です。CSSが落ちた場合・JSが失敗した場合でも、ブラウザは hidden 要素を非表示にしてくれるため、最低限のフォールバックが効きます。display:none だけで制御するとCSSへの依存が大きくなり、CSSが読み込まれないと「閉じた状態」を保てません。

aria-expanded を切り替えるだけで十分ですか?クラス(.is-open など)も付けるべきですか?

このスニペットでは aria-expanded を「真実の源」として、CSSも [aria-expanded="true"] セレクタで見た目を切り替えています。属性とクラスの両方で状態を持つと、片方の更新を忘れたときにバグの温床になります。片方に寄せると管理がシンプルになり、ARIA属性に寄せれば支援技術への伝達と見た目の制御が同じ仕組みで完結します。

1項目だけ開いて他は閉じる排他制御にしたいです。

排他制御は別パターンとして用意しています。本スニペットは「複数同時に開けるベース実装」なので、排他制御にしたい場合は toggleItem の手前で「ルート内の他項目を全て閉じる」処理を1ループ追加する形になります。詳細は別記事をご覧ください。

キーボードの矢印キーで項目間を移動したいです。

WAI-ARIA Authoring Practicesに沿った完全対応では矢印キーでのナビゲーションが推奨されますが、本スニペットでは Tab キーでのフォーカス移動と Enter / Space キーでの開閉という、<button> 要素のネイティブ機能で必要十分な範囲を採用しています。矢印キー対応が必要な場合は、別途 keydown リスナーで実装可能です。

まとめ

この記事では、バニラJSと aria-expanded を使ったアコーディオン実装の基本形を紹介しました。ポイントを整理します。

  • <button> 要素を使うことで、キーボード操作と支援技術対応をブラウザに任せられる
  • aria-expanded を真実の源にすると、状態管理がシンプルでバグが減る
  • hidden 属性は JS / CSS が落ちても安全に閉じた状態を保つフォールバックとして機能する
  • イベント委譲とスコープ分離を入れておくことで、入れ子・排他制御パターンへの拡張がしやすい

まずはこのコードをコピペして動かしてみて、開発者ツールで aria-expanded の値が切り替わる様子を観察するところから始めるのがおすすめです。属性が変わるたびにCSSの見た目が連動する流れが体感できると、ARIA対応の感覚がつかめてきます。

【関連記事】

【シリーズ:アコーディオンUIシリーズ】
→ アコーディオン実装パターンまとめ(記事000・公開予定)
→ JavaScript不要でアコーディオンを作る2つの方法(記事002・公開予定)
→ アコーディオンの開閉制御パターン|複数同時 vs 排他制御(記事003・公開予定)
→ 高さアニメーション付きアコーディオン(記事004・公開予定)
→ 入れ子アコーディオン(記事005・公開予定)