アコーディオンの開閉が一瞬で切り替わって不格好に見えることはないでしょうか。max-height ハックで動かしてみたものの、コンテンツの行数によって速度が不自然になる、height: auto をトランジションさせたいができないと聞いて代替策に迷っている、という相談もよく耳にします。

この記事では、grid-template-rows: 0fr → 1fr を主役にしたアコーディオンの高さアニメーションを、:has() 親方向セレクタ・transitionend の単独待機・prefers-reduced-motion フォールバックまで含めてコード付きで紹介します。コピペでそのまま動くHTML・CSS・JSを提供しつつ、なぜそう書くのかまで踏み込んで解説します。

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

🌐 デモを見る(GitHub Pages)

💻 ソースコード(GitHub)

この記事で分かること
  • grid-template-rows: 0fr → 1frmax-height ハックを使わずに高さアニメーションを実装する方法
  • __body + __content の2層構造を維持する理由(min-height: 0overflow: hidden の役割)
  • :has() で親方向セレクタを書く必要がある場面(__header__body が直接の兄弟関係にないケース)
  • JS は開く時に hidden を即時除去・閉じる時に transitionendhidden を遅延付与する順序ルール
  • prefers-reduced-motion: reduce 設定時に CSS / JS が同じ「真実の源」を読んで自動フォールバックする仕組み

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

クリックで開閉できる4項目のアコーディオンUIです。開閉時に高さが 200ms ease-out でスムーズに変化する、現代的な高さアニメーション付きの実装になっています。

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

  • 開閉時に高さが 200ms でスムーズに動く(grid-template-rows: 0fr → 1fr
  • max-height のような仮の最大値を指定する必要がなく、コンテンツの行数が変わっても正確に「ちょうどの高さ」まで動く
  • 開く時は即時に hidden を外して描画し、aria-expanded="true" に切替えてから高さアニメを開始する
  • 閉じる時は aria-expanded="false" に切替えてから高さアニメを再生し、transitionend の完了後に hidden を遅延付与する
  • prefers-reduced-motion: reduce 設定時は transition を無効化し、JS も即時 hidden 付与パスへフォールバックする
  • 開閉トグル・キーボード操作・支援技術通知は <button> + aria-expanded + aria-controls のARIA対応構造を維持

完成イメージ

grid-template-rows: 0fr → 1fr で高さをアニメーションする

ここがこの記事の本筋です。アコーディオン本体の HTML から見ていきましょう。基本のアコーディオン実装と同じ <button> + aria-expanded + <div hidden> 構造を踏襲しています。

HTML
<div class="c-accordion" data-accordion id="c-accordion-animation">
  <!-- 項目 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-animation-panel-1"
        id="c-accordion-animation-header-1"
      >
        <span class="c-accordion__title">なぜ <code>grid-template-rows: 0fr → 1fr</code> を使うのか?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-animation-panel-1"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          <code>height: auto</code> はトランジション対象になりませんが、
          <code>grid-template-rows</code><code>0fr → 1fr</code> はモダンブラウザでアニメーション可能です。
          外側 <code>__body</code> をグリッドコンテナにし、内側 <code>__content</code><code>min-height: 0</code>
          <code>overflow: hidden</code> を当てることで、コンテンツの実寸を計測せずに「閉じきった状態」と「中身ぴったりの高さ」を行き来できます。
        </p>
        <p>
          <code>max-height</code> ハック(仮の最大値を指定する手法)と違って、コンテンツが何行になっても
          正確に「ちょうどの高さ」へアニメーションする点が利点です。
        </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-animation-panel-2"
        id="c-accordion-animation-header-2"
      >
        <span class="c-accordion__title">開閉時の <code>hidden</code> 属性はいつ付け外しする?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-animation-panel-2"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          <strong>開く時は即時に<code>hidden</code>を外し</strong>、その直後に <code>aria-expanded="true"</code> を立ててから
          高さアニメーションを開始します。<code>hidden</code> が付いたままだと CSS の高さアニメーション対象が描画されないためです。
        </p>
        <p>
          <strong>閉じる時は逆に<code>hidden</code>を遅延付与</strong>します。<code>aria-expanded="false"</code> を即座に切り替えてから高さアニメーションを再生し、
          完了(<code>transitionend</code>)の通知を受けてから <code>hidden</code> を付けます。
          アニメ中に <code>hidden</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-animation-panel-3"
        id="c-accordion-animation-header-3"
      >
        <span class="c-accordion__title"><code>prefers-reduced-motion</code> への配慮は?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-animation-panel-3"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          OS の「視差効果を減らす」設定が有効な場合、<code>transition: none</code> を当てて
          高さアニメーションを無効化します。前庭機能障害等で動きの強い演出が苦手なユーザーへの配慮です。
        </p>
        <p>
          この時 JS 側は <code>transitionend</code> が発火しないため、閉じる時の <code>hidden</code> 付与を
          即時パスへ切り替えています(<code>transition</code><code>duration</code> が 0 のときは
          視覚的な遷移がないので「アニメ中の不一致」も発生しません)。
        </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-animation-panel-4"
        id="c-accordion-animation-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-animation-panel-4"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          HTML 構造・FLOCSS 命名(<code>c-accordion</code> / <code>__header</code> / <code>__icon</code> / <code>__body</code> / <code>__content</code>)・
          JS シグネチャ(<code>setItemState(header, open)</code> / <code>collectHeaders(root)</code> / <code>bindAccordion(root)</code>)は
          すべて共通です。
        </p>
        <p>
          違いは <strong>CSS の <code>__body</code><code>display: grid</code> + <code>grid-template-rows: 0fr/1fr</code> のアニメーションを当てた点</strong>と、
          <strong>JS の <code>setItemState</code> 内で <code>hidden</code> 付与のタイミングを <code>transitionend</code> に遅延させた点</strong>の2つだけです。
          個別開閉の挙動は基本実装と同等(複数同時に開ける)になります。
        </p>
      </div>
    </div>
  </div>
</div>
OPEN

続いて CSS 全体です。grid-template-rows のアニメーションを __body に当てつつ、__content 側で min-height: 0overflow: hidden を効かせて「閉じきった高さ0」を成立させる構造になっています。なお、サイト側で *, *::before, *::after { box-sizing: border-box; } のリセットがある前提で記載しています。

CSS
/* ================================================
   カスタムプロパティ
   共通設計のトークンセットを継承する。
   本実装では高さアニメーションのために --accordion-anim-duration /
   --accordion-anim-easing を新規追加する(既存トークンの値は改変しない)。
   命名規則は --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;

  /* 高さアニメーション用トークン(本実装で追加)
     アイコン回転と高さアニメーションを 200ms ease-out で統一する。 */
  --accordion-anim-duration:     200ms;
  --accordion-anim-easing:       ease-out;
}

/* ================================================
   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 の値に応じて
   回転させることで開閉状態を視覚的に伝える。
   本実装では高さアニメと回転速度を統一するため、
   --accordion-anim-duration / --accordion-anim-easing を使う。
   ================================================ */
.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-anim-duration) var(--accordion-anim-easing);
}

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

/* ================================================
   c-accordion__body: 本文パネル(アニメーション対象の外側)
   - display: grid + grid-template-rows: 0fr で「閉じきった高さ0」を表現。
   - aria-expanded="true" になったヘッダー直後の __body は 1fr に切替え、
     コンテンツの実寸ぴったりまで伸びる(max-height ハックと違い実寸計測不要)。
   - transition は grid-template-rows のみに絞る。
     ほかのプロパティで transitionend が多重発火するのを防ぐため。
   - hidden 属性が付いている間は display:none 相当で完全に描画から外れる。
     開く時は JS が即時 hidden を外してから 0fr → 1fr のアニメーションを開始する。
   ================================================ */
.c-accordion__body {
  display: grid;
  grid-template-rows: 0fr;
  background-color: var(--accordion-color-bg);
  transition: grid-template-rows var(--accordion-anim-duration) var(--accordion-anim-easing);
}

/* 開いている項目(=配下の __header に aria-expanded="true" を持つ __item)の __body
   :has() で「開状態の __header を子孫に持つ __item」を起点に __body を選択する。
   __header は __heading(h2) の子で __body と直接の兄弟関係にないため、
   隣接兄弟セレクタ(+)ではなく :has() を使う必要がある。
   :has() 対応: Chrome 105+ / Safari 15.4+ / Firefox 121+(grid-template-rows 0fr の対応より広い) */
.c-accordion__item:has(.c-accordion__header[aria-expanded="true"]) > .c-accordion__body {
  grid-template-rows: 1fr;
}

/* ================================================
   c-accordion__content: 本文の余白を持つ内側ラッパー
   本実装で grid-template-rows アニメーションを
   実装するため、外側の __body をコンテナ、内側の __content を
   コンテンツとして分離する2層構造を活用する。
   - min-height: 0   → grid 子要素のデフォルト最小高さ制約(auto)を解除し、
                       0fr が物理的に「高さ0」になるようにする。
   - overflow: hidden → 0fr へ折り畳む過程で内容物が枠外にはみ出さないようにする。
   ================================================ */
.c-accordion__content {
  min-height: 0;
  overflow: hidden;
  padding: 0 var(--accordion-body-padding-x);
  color: var(--accordion-color-text);
  border-top: 0 solid var(--accordion-color-border);
  transition:
    padding-block var(--accordion-anim-duration) var(--accordion-anim-easing),
    border-top-width var(--accordion-anim-duration) var(--accordion-anim-easing);
}

/* 開いた状態では padding-block と border-top を復活させる
   閉じた状態の padding を 0 にしておくことで、見た目の高さが
   完全に 0 まで折り畳まれる(縦余白だけ残るのを防ぐ)。 */
.c-accordion__item:has(.c-accordion__header[aria-expanded="true"]) > .c-accordion__body .c-accordion__content {
  padding-block: var(--accordion-body-padding-y);
  border-top-width: 1px;
}

.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 を無効化する。
   本実装で追加した __body / __content の高さ・余白アニメーションも
   合わせて対象に含める。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon,
  .c-accordion__body,
  .c-accordion__content {
    transition: none;
  }
}
OPEN

CSS の構造を読み解くと、押さえどころは次の3点です。

  • __bodydisplay: grid + grid-template-rows: 0fr で「閉じきった高さ0」を表現する
  • 開いている項目は :has() セレクタで __body1fr に切替え、コンテンツ実寸ぴったりまで伸ばす
  • transitiongrid-template-rows のみに絞り、transitionend が他プロパティと多重発火するのを防ぐ

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

なぜ height: auto をトランジションできないのか

height: auto は CSS のトランジション対象に含まれません。height: 0pxheight: 200px の間ならアニメーション可能ですが、auto キーワードは数値ではないため、ブラウザが補間する起点と終点を持てないからです。

代わりに使うのが grid-template-rows0fr → 1fr です。グリッドコンテナの行サイズは数値(fr 単位)でトランジションでき、1fr はそのコンテンツの実寸を表すため、コンテンツが何行になっても「ちょうどの高さ」までアニメーションします。max-height ハックのように仮の最大値を指定する必要がない点が利点です。

__body + __content の2層構造を維持する理由

__body(外側・コンテナ)と __content(内側・コンテンツ)の2層構造は、本記事のアニメ実装で「閉じきった高さ0」を成立させる前提条件です。アニメーションを破綻させないために、この2層構造は必ず維持します。

それぞれが担う役割は次の通りです。

  • 外側 __body: display: grid + grid-template-rows: 0fr / 1fr のアニメーション対象。グリッドコンテナとして子要素の高さを制御する
  • 内側 __content: min-height: 0overflow: hidden でグリッド子要素のデフォルト最小高さ制約(auto)を解除し、0fr が物理的に「高さ0」になるようにする
  • 内側 __content には padding-blockborder-top-width も連動アニメーション対象として追加し、閉じた状態の余白も0まで折り畳む

min-height: 0 を入れない場合、グリッド子要素は「自身のコンテンツが収まる高さ」を最小値として確保しようとするため、0fr を指定しても折り畳めません。overflow: hidden は折り畳み中に内容物が枠外へはみ出すのを防ぐためのものです。

トランジション値の統一

本実装では新規トークン --accordion-anim-duration: 200ms--accordion-anim-easing: ease-out:root に追加しています。既存トークン --accordion-transition は改変せず、追加トークンを別軸として並列で持たせる方針です。

この値は __bodygrid-template-rows__contentpadding-block / border-top-width、そしてアイコン回転(__icontransform)すべてで共有されます。アイコンの回転(▼ ⇄ ▲)と高さアニメが同じ 200ms ease-out で揃うため、視覚的な統一感が保たれます。

:has() で親方向セレクタを書く

このアニメ実装の核は :has() セレクタです。CSS の該当部分を抜き出してみます。

.c-accordion__item:has(.c-accordion__header[aria-expanded="true"]) > .c-accordion__body {
  grid-template-rows: 1fr;
}

このセレクタが行っているのは「aria-expanded="true"__header を子孫に持つ __item」を起点として、その配下の __body を選択する、という動きです。__item から見て親方向に上がる動作ではなく、__header の状態を __item が読み取り、そこから子方向の __body へ降りていく構造になっています。

なぜ :has() が必要かというと、__header__body直接の兄弟関係にない からです。HTML 構造を改めて見てみましょう。

HTML
<div class="c-accordion__item">
  <h2 class="c-accordion__heading">           <!-- __heading が間に入る -->
    <button class="c-accordion__header" aria-expanded="false">...</button>
  </h2>
  <div class="c-accordion__body" hidden>      <!-- __body は __item の子 -->
    ...
  </div>
</div>

__header__heading(h2)の中に入っているため、__body から見ると __header は「2階層上の親(__item)の、別の子(__heading)の、さらに子」という位置にあります。隣接兄弟セレクタ(+)や一般兄弟セレクタ(~)は「同じ親を持つ要素間」でしか効かないため、この構造では機能しません。

:has() のブラウザ対応は Chrome 105+ / Safari 15.4+ / Firefox 121+ です。grid-template-rows: 0fr → 1fr のアニメーション対応より対応範囲が広く、本記事の実装ではアニメが切れる古いブラウザでもセレクタ自体は効くようになっています。

コラム: __header + __body で書こうとして詰まった話

実装初期に .c-accordion__header[aria-expanded="true"] + .c-accordion__body で書こうとしたところ、グリッドが切り替わらず動きませんでした。原因は HTML 構造で、__header__body の親が違う(前者は __heading の子、後者は __item の子)ため、隣接兄弟セレクタが効いていなかったのです。

解決の道筋は2つあります。__heading(h2)を取り除けば + でも書けますが、見出しのセマンティクス(FAQ なら <h2> で各質問を見出し化したい)を維持したい場合は、:has() で親方向に上がってから > で子方向に降りる形に書き換えるしかありません。「+ が不採用=ダメ」ではなく、HTML 構造の選択がセレクタの選択を決める、という関係です。

セレクタが効いていない時のデバッグは、outline: 2px solid red を一時的に当てて目視確認するのが手早いです。書いたセレクタが本当にその要素に当たっているかを先に確認してから、プロパティの調整に入ると無駄が減ります。

JS は transitionendhidden を遅延付与する

JS の役割は、開閉時の aria-expanded / hidden の操作と、transitionend を待っての遅延付与です。本実装では「open 即時 / close 遅延」の順序ルールに従って、開く時と閉じる時で操作の順序を変えています。

操作ステップ順序
開く(open)(1) panel.removeAttribute("hidden")(即時)→ (2) header.setAttribute("aria-expanded", "true")(即時)→ (3) 高さアニメーション開始
閉じる(close)(1) header.setAttribute("aria-expanded", "false")(即時)→ (2) 高さアニメーション → (3) 完了後 panel.setAttribute("hidden", "")(遅延付与)

この順序にする理由は3つです。

  • 開く時に hidden を先に外す: hidden 属性が付いたままだと CSS 側の高さアニメ対象(grid-template-rows: 1fr)が描画されないため、最初に hidden を外して描画対象に戻す
  • 閉じる時に hidden をアニメ完了後に付ける: アニメ中に hidden を付与すると支援技術が「途中で消えた」状態になり、視覚と読み上げが乖離するため、transitionend を待ってから付ける
  • aria-expanded は両方とも即時切替: 状態遷移の意図は支援技術へ即座に伝えるべきで、アニメーションの完了を待つ理由がない

JS 全体は次の通りです。基本実装と同じ IIFE + "use strict" のパターンを踏襲しつつ、setItemState 内に transitionend の遅延付与パスを足した拡張版になっています。

JavaScript
/**
 * バニラJS + ARIA対応アコーディオン(高さアニメーション付き)
 *
 * 設計方針(共通設計を継承):
 * - ルート要素 .c-accordion[data-accordion] に対してイベント委譲する。
 * - 開閉状態は aria-expanded 属性を真実の源(single source of truth)とし、
 *   CSSは [aria-expanded="true"] セレクタで見た目を切り替える。
 *   余分な is-open クラスは付与しない。
 * - 本文の表示/非表示は HTML の hidden 属性で制御する(JS が落ちた場合の
 *   フォールバックとして閉状態が HTML 単体で読み取れる)。
 * - setItemState(header, open) を経由して aria-expanded と hidden を
 *   同時更新するため、状態が必ず一致する(共通シグネチャ)。
 *
 * 本実装の特徴:
 * - 高さアニメーションは CSS 側で grid-template-rows: 0fr → 1fr で実現する。
 *   __body をグリッドコンテナ、__content を min-height:0 + overflow:hidden の
 *   グリッド子要素にすることで、コンテンツの実寸を計測せずに高さ 0 ⇄ auto を遷移できる。
 * - 開く時と閉じる時で hidden / aria-expanded の操作順序が異なる
 *   (「open 即時 / close 遅延」の順序ルール):
 *   - 開く: hidden 除去 → aria-expanded="true"(即時) → 高さアニメーション開始
 *   - 閉じる: aria-expanded="false"(即時) → 高さアニメーション → 完了後に hidden 付与
 * - 閉じる時の hidden 付与は transitionend を grid-template-rows のみに絞って
 *   待機する(複数プロパティで多重発火するのを防ぐ)。
 * - prefers-reduced-motion または duration 0 の環境では transitionend が発火しないため、
 *   即時 hidden 付与パスへフォールバックする。
 *
 * 個別開閉の挙動は基本実装と同等(複数同時に開ける)。
 */

(function () {
  "use strict";

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

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

  /**
   * 1つのヘッダーボタンの開閉状態を「指定の状態」に揃える。
   * 共通シグネチャ(後続の入れ子実装でも維持する)。
   *
   * 開く時: hidden 除去(即時) → aria-expanded="true"(即時) → CSS の transition が
   *   grid-template-rows 0fr → 1fr を 200ms で再生する。
   *
   * 閉じる時: aria-expanded="false"(即時) → CSS の transition が
   *   grid-template-rows 1fr → 0fr を 200ms で再生する → 完了後に hidden を付与する。
   *   transitionend は grid-template-rows プロパティのみで捕捉して多重発火を防ぐ。
   *   prefers-reduced-motion 等で transitionend が発火しない環境では
   *   即時 hidden 付与パスへフォールバックする。
   *
   * @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) {
      // 開く: hidden を先に外して描画対象に戻し、その直後に aria-expanded を切替えて
      // CSS の高さアニメーションを開始する(hidden が付いたままだと
      // grid-template-rows のトランジションが走らない)。
      panel.removeAttribute("hidden");
      header.setAttribute("aria-expanded", "true");
    } else {
      // 閉じる: aria-expanded を先に false に切替えて支援技術へ即時通知し、
      // CSS が grid-template-rows 1fr → 0fr のアニメーションを再生する。
      // hidden の付与はアニメーション完了を待ってから行う。
      header.setAttribute("aria-expanded", "false");

      // motion 設定や duration 0 で transitionend が発火しない環境を考慮して
      // CSS の実効 transition-duration を読み取り、ゼロなら即時付与する。
      const effectiveDurationMs = readTransitionDurationMs(panel);

      if (effectiveDurationMs <= 0) {
        panel.setAttribute("hidden", "");
        return;
      }

      // grid-template-rows プロパティのみで transitionend を待機する
      // (他プロパティと多重発火しないようガードを入れる)。
      const onTransitionEnd = function (event) {
        if (event.target !== panel) return;
        if (event.propertyName !== "grid-template-rows") return;
        panel.removeEventListener("transitionend", onTransitionEnd);

        // アニメ中に再度開かれていた場合は hidden を付けない(状態の取り違え防止)。
        if (header.getAttribute("aria-expanded") === "true") return;
        panel.setAttribute("hidden", "");
      };
      panel.addEventListener("transitionend", onTransitionEnd);
    }
  }

  /**
   * パネルの transition-duration を ms 単位で取得する。
   * grid-template-rows のトランジションが無効化されている(reduced-motion 等)
   * 場合に 0 を返し、setItemState の即時 hidden 付与パスを走らせる。
   *
   * @param {HTMLElement} panel - .c-accordion__body 要素
   * @returns {number} 実効 transition-duration(ms)
   */
  function readTransitionDurationMs(panel) {
    const cs = window.getComputedStyle(panel);
    const value = cs.transitionDuration || "0s";
    // "0.2s, 0.2s" のように複数指定される場合があるため、最初の値だけ見る。
    const first = value.split(",")[0].trim();
    if (first.endsWith("ms")) return parseFloat(first);
    if (first.endsWith("s")) return parseFloat(first) * 1000;
    return 0;
  }

  /**
   * 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;
  }

  /**
   * ルート要素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;

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

      toggleItem(header);
    });
  }

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

設計の柱は3つで、コードの先頭コメントにある通り基本実装と同じ構造を踏襲した上で、本記事固有の高さアニメ拡張を加えています。

  • シグネチャ維持: setItemState(header, open) / collectHeaders(root) / bindAccordion(root) の3関数は基本実装と同名・同引数で、入れ子実装でも同じ設計を維持する
  • 真実の源: aria-expanded 属性を「単一の真実の源」として、CSSも [aria-expanded="true"] セレクタで見た目を切り替える。is-open 等のクラスは付与しない
  • イベント委譲: ルート .c-accordion[data-accordion] に1つだけリスナーを付け、内部の closest('.c-accordion__header') でヘッダーを特定する

transitionendgrid-template-rows プロパティ単独で待機する

閉じる時の hidden 付与は panel.addEventListener("transitionend", onTransitionEnd) で待機しますが、リスナー内で2つのガードを入れています。

  • event.target !== panel で、内部要素のバブリング transitionend を弾く
  • event.propertyName !== "grid-template-rows" で、padding-block / border-top-width 等のほかのプロパティで発火する transitionend を弾く

このガードがないと、アニメ対象の各プロパティが順次 transitionend を発火し、hidden 付与が複数回試行される多重発火事故が起きます。完了時はリスナーを removeEventListener で確実に剥がし、メモリリークも防いでいます。

アニメ中に再オープンされたケースのガード

ユーザーが「閉じる」をクリックした直後、アニメーションの完了前に同じヘッダーをもう一度クリックして「開く」へ戻すケースがあります。このとき transitionend は元の「閉じる」アニメで遅延発火するため、既に aria-expanded="true" に切り替わっている状態で hidden を付けてしまう事故が起きかねません。

防御策は onTransitionEnd 内の最後のガードです。

if (header.getAttribute("aria-expanded") === "true") return;

「現在の真実の源」が true ならば hidden 付与を放棄する、という1行です。これにより「開いている状態を視覚と DOM で一致させる」原則が崩れません。

prefers-reduced-motion への配慮

OS の「視差効果を減らす」設定が有効なユーザーへの配慮として、CSS と JS の両方でフォールバックを用意しています。前庭機能障害等で動きの強い演出が苦手な方への必須対応です。

CSS 側では __body / __content も transition の無効化対象に含めています。

CSS
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon,
  .c-accordion__body,
  .c-accordion__content {
    transition: none;
  }
}

JS 側のフォールバックは readTransitionDurationMs(panel) 関数です。getComputedStyle(panel).transitionDuration で実効 transition-duration を読み取り、ゼロなら transitionend を待たずに即時 hidden を付与します。

CSS と JS が同じ「真実の源」(実効 transition-duration)を読むため、CSS 側で transition: none にすれば JS 側も自動的に即時パスへ切り替わります。設定の一貫性が構造的に保たれ、視覚的な遷移がない環境では「アニメ中の不一致」も発生しません。

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

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

HTML
<div class="c-accordion" data-accordion id="c-accordion-animation">
  <!-- 項目 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-animation-panel-1"
        id="c-accordion-animation-header-1"
      >
        <span class="c-accordion__title">なぜ <code>grid-template-rows: 0fr → 1fr</code> を使うのか?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-animation-panel-1"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          <code>height: auto</code> はトランジション対象になりませんが、
          <code>grid-template-rows</code><code>0fr → 1fr</code> はモダンブラウザでアニメーション可能です。
          外側 <code>__body</code> をグリッドコンテナにし、内側 <code>__content</code><code>min-height: 0</code>
          <code>overflow: hidden</code> を当てることで、コンテンツの実寸を計測せずに「閉じきった状態」と「中身ぴったりの高さ」を行き来できます。
        </p>
        <p>
          <code>max-height</code> ハック(仮の最大値を指定する手法)と違って、コンテンツが何行になっても
          正確に「ちょうどの高さ」へアニメーションする点が利点です。
        </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-animation-panel-2"
        id="c-accordion-animation-header-2"
      >
        <span class="c-accordion__title">開閉時の <code>hidden</code> 属性はいつ付け外しする?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-animation-panel-2"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          <strong>開く時は即時に<code>hidden</code>を外し</strong>、その直後に <code>aria-expanded="true"</code> を立ててから
          高さアニメーションを開始します。<code>hidden</code> が付いたままだと CSS の高さアニメーション対象が描画されないためです。
        </p>
        <p>
          <strong>閉じる時は逆に<code>hidden</code>を遅延付与</strong>します。<code>aria-expanded="false"</code> を即座に切り替えてから高さアニメーションを再生し、
          完了(<code>transitionend</code>)の通知を受けてから <code>hidden</code> を付けます。
          アニメ中に <code>hidden</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-animation-panel-3"
        id="c-accordion-animation-header-3"
      >
        <span class="c-accordion__title"><code>prefers-reduced-motion</code> への配慮は?</span>
        <span class="c-accordion__icon" aria-hidden="true"></span>
      </button>
    </h2>
    <div
      class="c-accordion__body"
      id="c-accordion-animation-panel-3"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          OS の「視差効果を減らす」設定が有効な場合、<code>transition: none</code> を当てて
          高さアニメーションを無効化します。前庭機能障害等で動きの強い演出が苦手なユーザーへの配慮です。
        </p>
        <p>
          この時 JS 側は <code>transitionend</code> が発火しないため、閉じる時の <code>hidden</code> 付与を
          即時パスへ切り替えています(<code>transition</code><code>duration</code> が 0 のときは
          視覚的な遷移がないので「アニメ中の不一致」も発生しません)。
        </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-animation-panel-4"
        id="c-accordion-animation-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-animation-panel-4"
      hidden
    >
      <div class="c-accordion__content">
        <p>
          HTML 構造・FLOCSS 命名(<code>c-accordion</code> / <code>__header</code> / <code>__icon</code> / <code>__body</code> / <code>__content</code>)・
          JS シグネチャ(<code>setItemState(header, open)</code> / <code>collectHeaders(root)</code> / <code>bindAccordion(root)</code>)は
          すべて共通です。
        </p>
        <p>
          違いは <strong>CSS の <code>__body</code><code>display: grid</code> + <code>grid-template-rows: 0fr/1fr</code> のアニメーションを当てた点</strong>と、
          <strong>JS の <code>setItemState</code> 内で <code>hidden</code> 付与のタイミングを <code>transitionend</code> に遅延させた点</strong>の2つだけです。
          個別開閉の挙動は基本実装と同等(複数同時に開ける)になります。
        </p>
      </div>
    </div>
  </div>
</div>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   共通設計のトークンセットを継承する。
   本実装では高さアニメーションのために --accordion-anim-duration /
   --accordion-anim-easing を新規追加する(既存トークンの値は改変しない)。
   命名規則は --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;

  /* 高さアニメーション用トークン(本実装で追加)
     アイコン回転と高さアニメーションを 200ms ease-out で統一する。 */
  --accordion-anim-duration:     200ms;
  --accordion-anim-easing:       ease-out;
}

/* ================================================
   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 の値に応じて
   回転させることで開閉状態を視覚的に伝える。
   本実装では高さアニメと回転速度を統一するため、
   --accordion-anim-duration / --accordion-anim-easing を使う。
   ================================================ */
.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-anim-duration) var(--accordion-anim-easing);
}

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

/* ================================================
   c-accordion__body: 本文パネル(アニメーション対象の外側)
   - display: grid + grid-template-rows: 0fr で「閉じきった高さ0」を表現。
   - aria-expanded="true" になったヘッダー直後の __body は 1fr に切替え、
     コンテンツの実寸ぴったりまで伸びる(max-height ハックと違い実寸計測不要)。
   - transition は grid-template-rows のみに絞る。
     ほかのプロパティで transitionend が多重発火するのを防ぐため。
   - hidden 属性が付いている間は display:none 相当で完全に描画から外れる。
     開く時は JS が即時 hidden を外してから 0fr → 1fr のアニメーションを開始する。
   ================================================ */
.c-accordion__body {
  display: grid;
  grid-template-rows: 0fr;
  background-color: var(--accordion-color-bg);
  transition: grid-template-rows var(--accordion-anim-duration) var(--accordion-anim-easing);
}

/* 開いている項目(=配下の __header に aria-expanded="true" を持つ __item)の __body
   :has() で「開状態の __header を子孫に持つ __item」を起点に __body を選択する。
   __header は __heading(h2) の子で __body と直接の兄弟関係にないため、
   隣接兄弟セレクタ(+)ではなく :has() を使う必要がある。
   :has() 対応: Chrome 105+ / Safari 15.4+ / Firefox 121+(grid-template-rows 0fr の対応より広い) */
.c-accordion__item:has(.c-accordion__header[aria-expanded="true"]) > .c-accordion__body {
  grid-template-rows: 1fr;
}

/* ================================================
   c-accordion__content: 本文の余白を持つ内側ラッパー
   本実装で grid-template-rows アニメーションを
   実装するため、外側の __body をコンテナ、内側の __content を
   コンテンツとして分離する2層構造を活用する。
   - min-height: 0   → grid 子要素のデフォルト最小高さ制約(auto)を解除し、
                       0fr が物理的に「高さ0」になるようにする。
   - overflow: hidden → 0fr へ折り畳む過程で内容物が枠外にはみ出さないようにする。
   ================================================ */
.c-accordion__content {
  min-height: 0;
  overflow: hidden;
  padding: 0 var(--accordion-body-padding-x);
  color: var(--accordion-color-text);
  border-top: 0 solid var(--accordion-color-border);
  transition:
    padding-block var(--accordion-anim-duration) var(--accordion-anim-easing),
    border-top-width var(--accordion-anim-duration) var(--accordion-anim-easing);
}

/* 開いた状態では padding-block と border-top を復活させる
   閉じた状態の padding を 0 にしておくことで、見た目の高さが
   完全に 0 まで折り畳まれる(縦余白だけ残るのを防ぐ)。 */
.c-accordion__item:has(.c-accordion__header[aria-expanded="true"]) > .c-accordion__body .c-accordion__content {
  padding-block: var(--accordion-body-padding-y);
  border-top-width: 1px;
}

.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 を無効化する。
   本実装で追加した __body / __content の高さ・余白アニメーションも
   合わせて対象に含める。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-accordion__header,
  .c-accordion__icon,
  .c-accordion__body,
  .c-accordion__content {
    transition: none;
  }
}
OPEN
JavaScript
/**
 * バニラJS + ARIA対応アコーディオン(高さアニメーション付き)
 *
 * 設計方針(共通設計を継承):
 * - ルート要素 .c-accordion[data-accordion] に対してイベント委譲する。
 * - 開閉状態は aria-expanded 属性を真実の源(single source of truth)とし、
 *   CSSは [aria-expanded="true"] セレクタで見た目を切り替える。
 *   余分な is-open クラスは付与しない。
 * - 本文の表示/非表示は HTML の hidden 属性で制御する(JS が落ちた場合の
 *   フォールバックとして閉状態が HTML 単体で読み取れる)。
 * - setItemState(header, open) を経由して aria-expanded と hidden を
 *   同時更新するため、状態が必ず一致する(共通シグネチャ)。
 *
 * 本実装の特徴:
 * - 高さアニメーションは CSS 側で grid-template-rows: 0fr → 1fr で実現する。
 *   __body をグリッドコンテナ、__content を min-height:0 + overflow:hidden の
 *   グリッド子要素にすることで、コンテンツの実寸を計測せずに高さ 0 ⇄ auto を遷移できる。
 * - 開く時と閉じる時で hidden / aria-expanded の操作順序が異なる
 *   (「open 即時 / close 遅延」の順序ルール):
 *   - 開く: hidden 除去 → aria-expanded="true"(即時) → 高さアニメーション開始
 *   - 閉じる: aria-expanded="false"(即時) → 高さアニメーション → 完了後に hidden 付与
 * - 閉じる時の hidden 付与は transitionend を grid-template-rows のみに絞って
 *   待機する(複数プロパティで多重発火するのを防ぐ)。
 * - prefers-reduced-motion または duration 0 の環境では transitionend が発火しないため、
 *   即時 hidden 付与パスへフォールバックする。
 *
 * 個別開閉の挙動は基本実装と同等(複数同時に開ける)。
 */

(function () {
  "use strict";

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

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

  /**
   * 1つのヘッダーボタンの開閉状態を「指定の状態」に揃える。
   * 共通シグネチャ(後続の入れ子実装でも維持する)。
   *
   * 開く時: hidden 除去(即時) → aria-expanded="true"(即時) → CSS の transition が
   *   grid-template-rows 0fr → 1fr を 200ms で再生する。
   *
   * 閉じる時: aria-expanded="false"(即時) → CSS の transition が
   *   grid-template-rows 1fr → 0fr を 200ms で再生する → 完了後に hidden を付与する。
   *   transitionend は grid-template-rows プロパティのみで捕捉して多重発火を防ぐ。
   *   prefers-reduced-motion 等で transitionend が発火しない環境では
   *   即時 hidden 付与パスへフォールバックする。
   *
   * @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) {
      // 開く: hidden を先に外して描画対象に戻し、その直後に aria-expanded を切替えて
      // CSS の高さアニメーションを開始する(hidden が付いたままだと
      // grid-template-rows のトランジションが走らない)。
      panel.removeAttribute("hidden");
      header.setAttribute("aria-expanded", "true");
    } else {
      // 閉じる: aria-expanded を先に false に切替えて支援技術へ即時通知し、
      // CSS が grid-template-rows 1fr → 0fr のアニメーションを再生する。
      // hidden の付与はアニメーション完了を待ってから行う。
      header.setAttribute("aria-expanded", "false");

      // motion 設定や duration 0 で transitionend が発火しない環境を考慮して
      // CSS の実効 transition-duration を読み取り、ゼロなら即時付与する。
      const effectiveDurationMs = readTransitionDurationMs(panel);

      if (effectiveDurationMs <= 0) {
        panel.setAttribute("hidden", "");
        return;
      }

      // grid-template-rows プロパティのみで transitionend を待機する
      // (他プロパティと多重発火しないようガードを入れる)。
      const onTransitionEnd = function (event) {
        if (event.target !== panel) return;
        if (event.propertyName !== "grid-template-rows") return;
        panel.removeEventListener("transitionend", onTransitionEnd);

        // アニメ中に再度開かれていた場合は hidden を付けない(状態の取り違え防止)。
        if (header.getAttribute("aria-expanded") === "true") return;
        panel.setAttribute("hidden", "");
      };
      panel.addEventListener("transitionend", onTransitionEnd);
    }
  }

  /**
   * パネルの transition-duration を ms 単位で取得する。
   * grid-template-rows のトランジションが無効化されている(reduced-motion 等)
   * 場合に 0 を返し、setItemState の即時 hidden 付与パスを走らせる。
   *
   * @param {HTMLElement} panel - .c-accordion__body 要素
   * @returns {number} 実効 transition-duration(ms)
   */
  function readTransitionDurationMs(panel) {
    const cs = window.getComputedStyle(panel);
    const value = cs.transitionDuration || "0s";
    // "0.2s, 0.2s" のように複数指定される場合があるため、最初の値だけ見る。
    const first = value.split(",")[0].trim();
    if (first.endsWith("ms")) return parseFloat(first);
    if (first.endsWith("s")) return parseFloat(first) * 1000;
    return 0;
  }

  /**
   * 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;
  }

  /**
   * ルート要素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;

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

      toggleItem(header);
    });
  }

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

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

  • アニメーション速度: --accordion-anim-duration(200ms)と --accordion-anim-easing(ease-out)を変えるだけで、__body の高さアニメと __icon の回転が両方とも追従する
  • 色・余白・角丸: 既存トークン --accordion-color-* / --accordion-header-padding-* / --accordion-body-padding-* / --accordion-radius を変更
  • 項目数: HTML の .c-accordion__item を増やし、id の数字を連番でずらす(aria-controlsid を必ずペアで揃える)
  • 初期で開いた状態にする: 該当項目の aria-expanded="true" + パネルから hidden 属性を削除
  • アニメを完全に切る: CSS の @media (prefers-reduced-motion: reduce) 内のセレクタを :where(.c-accordion__body, .c-accordion__content) { transition: none; } に書き換えると、OS 設定によらず常時無効化できる

よくある質問

max-height ハックではダメですか?

仮の最大値(例: max-height: 1000px)を指定すれば動くようには見えますが、コンテンツの行数によって速度が変わってしまう(500px のコンテンツでも 1000px 分のトランジション時間がかかる)・最大値を超えるとそもそもアニメーションしない、といった不具合があります。grid-template-rows: 0fr → 1fr ならコンテンツの実寸を計測せずに「ちょうどの高さ」へアニメーションするため、ユースケース問わず安定します。

:has() のブラウザ対応が心配です。grid-template-rows のアニメーション対応とどちらが先に切れますか?

:has() のほうが対応範囲が広く、Chrome 105+ / Safari 15.4+ / Firefox 121+ で使えます。grid-template-rows: 0fr → 1fr のアニメーション対応はそれより狭いため、本記事の実装ではアニメーションが切れる先(古いブラウザ)でもセレクタ自体は効くようになっています。古いブラウザでは「アニメーションなしで一瞬で開閉する」状態に段階的劣化します。

開いている途中で閉じる、閉じている途中で開くを連打しても壊れませんか?

本実装では setItemState 内で transitionendevent.propertyName !== "grid-template-rows" ガードと、アニメ中に再オープンされた場合の aria-expanded === "true" ガードを2段で入れています。連打しても「現在の真実の源(aria-expanded)」を優先する設計のため、視覚と DOM の状態がずれません。アニメーション速度(200ms)が短いため、連打しても破綻なく追従します。

<details> 要素でも同じことができますか?

<details> のネイティブ開閉は JS なしで動く一方、デフォルトでは高さアニメーションが付きません。CSS の interpolate-size: allow-keywords プロパティを使えば <details> でも高さアニメーションが書けますが、ブラウザ対応が grid-template-rows より新しく(2025年時点で Chrome のみ実装)、本番で使うにはまだ早い段階です。<details> の素朴な実装と JS 不要の比較については別記事『JavaScript不要でアコーディオンを作る2つの方法』で詳しく扱っています(→ 末尾の関連記事を参照)。

まとめ

この記事では、grid-template-rows: 0fr → 1fr を主役にしたアコーディオンの高さアニメーション実装を紹介しました。ポイントを整理します。

  • 高さアニメは grid-template-rows: 0fr → 1fr を主役に、__body + __content の2層構造で実装する
  • 親方向への状態伝播は :has() を使う(__header__body が直接の兄弟関係にないため + / ~ では効かない)
  • JS は開く時に hidden を即時除去・閉じる時に transitionendgrid-template-rows 単独で待ってから hidden を遅延付与する(open 即時 / close 遅延の順序ルール)
  • prefers-reduced-motion 設定時は CSS で transition: none にし、JS 側は transitionDuration === 0 を読んで即時 hidden 付与パスへフォールバック
  • 既存トークン(--accordion-transition 等)は改変せず、--accordion-anim-duration / --accordion-anim-easing を追加して使う

まずはこのコードをコピペして動かし、prefers-reduced-motion の OS 設定をオン / オフで切り替えて、視覚と読み上げの一致を確認するところから始めるのがおすすめです。属性が変わるたびにCSSの見た目が連動する流れと、アニメ完了後に hidden が遅延付与されるタイミングを開発者ツールで観察すると、transitionend の動きがつかめてきます。

【関連記事】

【シリーズ:アコーディオンUIシリーズ】
→ アコーディオン実装パターンまとめ(記事000・公開予定)
→ バニラJSで作るアコーディオン|aria-expanded で実装するARIA対応の基本形(公開済 / accordion-js-toggle)
→ JavaScript不要でアコーディオンを作る2つの方法|<details> とチェックボックスハックを比較(公開済 / accordion-without-js)
→ アコーディオンの開閉制御パターン|複数同時オープン vs 排他制御の作り方を比較(公開済 / accordion-open-pattern)
→ 入れ子アコーディオン(記事005・公開予定)