テクニック

アコーディオンをdetails summaryで実装|開閉アニメーション付きの作り方

この記事の対象: HTML/CSSでアコーディオンUIを実装する制作者、JSの量を減らしたい人
読了時間: 約12分

アコーディオン(開閉パネル)を作るとき、まだ <div> にクリックイベントを付けて classList.toggle() していないでしょうか。開閉そのものは <details><summary> だけで動きます。JavaScriptはゼロ、外部ライブラリもゼロ。アニメーションもCSSだけで付けられるようになりました。

この記事では、素のHTMLから始めて、デフォルトの三角マークを消す方法、矢印を回転させる書き方、開閉に高さのアニメーションを付ける方法、同時に1つしか開かせない制御まで、コピーしてそのまま動く形で載せます。検索エンジンとスクリーンリーダーからどう見えるかも、確認方法つきで扱います。


前提と対応環境を先に確認する

前提と対応環境を先に確認する

作業に入る前に、どこまでが安全に使えて、どこからが新しい機能なのかを分けておきます。ここを曖昧にしたまま実装すると、古い環境で「開かない」のではなく「アニメーションだけ効かない」という分かりにくい壊れ方をします。

使う技術と必要な環境

機能 何に使うか 対応状況
<details> / <summary> 開閉そのもの 主要ブラウザで長く実装済み
::-webkit-details-marker WebKit系の三角マークを消す WebKit/Blink系向けの接頭辞つき指定
list-style で marker 非表示 Firefox含む三角マークの制御 現行の主要ブラウザ
name 属性による排他制御 1つだけ開く(アコーディオン) 比較的新しい機能。後述の確認方法で判定する
interpolate-size: allow-keywords height: auto へのアニメーション 対応状況に差がある新しい機能
::details-content 中身だけをCSSで掴む 新しい擬似要素。フォールバック前提で使う

対応状況はブラウザのバージョンで変わるため、この表を鵜呑みにせず、案件の対応要件と照らして毎回確認してください。判断材料になるのはMDNとcaniuse、そして次の実測です。

確認方法: 手元のブラウザで使えるかは、DevToolsのコンソールで次を実行すると判定できます。

// 排他アコーディオン(name属性)が使えるか
console.log('name属性:', 'name' in document.createElement('details'));

// height:auto へのアニメーションが使えるか
console.log('interpolate-size:', CSS.supports('interpolate-size', 'allow-keywords'));

// ::details-content が使えるか
console.log('::details-content:', CSS.supports('selector(::details-content)'));

false が返る機能は、この記事の中で必ずフォールバックを併記しています。そちらを採用してください。実機での確認が要るのは、手元のブラウザとユーザーのブラウザが同じとは限らないからです。特にiOSは、機種ではなくOSバージョンで挙動が変わります。

CMSごとの置き場所

環境 HTMLの置き場所 CSSの置き場所
静的HTML index.html などの本文内 assets/css/style.css
WordPress(ブロックテーマ) 投稿編集画面の「カスタムHTML」ブロック 子テーマの style.css
WordPress(クラシックテーマ) エディタのテキストタブ 子テーマの style.css
Shopify(Online Store 2.0) sections/ 内のセクションファイル assets/ のCSSファイル

WordPressで子テーマを持っていない場合は、親テーマの更新でCSSが消えます。子テーマの作り方から確認したい場合はこちらが早いです。


details と summary の最小構成をまず動かす

details と summary の最小構成をまず動かす

余計なものを足す前に、素の状態でどう動くかを見ておきます。ここが理解できていないと、後でCSSを当てたときに「何を上書きしているのか」が分からなくなります。

コピーして動く最小コード

<details>
  <summary>配送にかかる日数を教えてください</summary>
  <p>ご注文確定から2〜4営業日で発送します。離島は追加で2日ほどかかります。</p>
</details>

これだけで開閉します。クリックでもEnterキーでもSpaceキーでも開きます。フォーカスも当たります。CSSもJavaScriptも書いていません。

覚えておく点は3つです。

  • <summary> は最初の子要素に置く。2番目以降に書くと見出しとして扱われません
  • <summary> 以外のすべてが中身になる<p> でも <div> でも <table> でも入ります
  • 初期状態で開いておきたいときは <details open> と書く

属性でできること

属性 効果 書き方
open 最初から開いた状態にする <details open>
name 同じ値のもの同士で1つだけ開く <details name="faq">
id URLハッシュでの直リンク先にする <details id="shipping">

open は真偽値属性なので open="false" と書いても開きます。閉じたいなら属性ごと消してください。ここは書き間違いの定番です。

id を振っておくと、#shipping 付きのURLで該当箇所へ飛ばせます。対応ブラウザでは折りたたまれた中身へのリンクでも自動で開くようになっていますが、確実に開かせたいなら次の1行を足します。

<script>
// ハッシュ付きで来訪したときにその details を開く
const openByHash = () => {
  const el = document.querySelector(location.hash || '#\\0');
  if (el && el.tagName === 'DETAILS') el.open = true;
};
window.addEventListener('hashchange', openByHash);
openByHash();
</script>

FAQの特定の項目をメールやチャットで案内したいとき、この1手間があると相手が探す時間がなくなります。

toggle イベントで開閉を検知する

JavaScriptを一切書かなくても開閉しますが、計測を入れたいときだけは1行必要になります。toggle イベントが使えます。

<script>
document.querySelectorAll('details').forEach((el) => {
  el.addEventListener('toggle', () => {
    if (el.open) {
      // ここを自分の計測コードに差し替える
      console.log('開いた:', el.querySelector('summary').textContent.trim());
    }
  });
});
</script>

FAQのどの項目がよく開かれているかは、そのままコンテンツ改善のネタになります。GA4に送るならイベント名を決めて gtag('event', ...) に置き換えてください。計測ツールの構成そのものを見直したいときはこちらが参考になります。


三角マークを消して自前の矢印に差し替える

デフォルトの三角マーク(disclosure triangle)はブラウザによって形も位置も違います。デザインを合わせるなら、まず消してから自分で描くのが確実です。

マーカーを確実に消す

ブラウザによって消し方が違うので、2種類まとめて書きます。

/* Firefox / Chromium / Safari すべてで三角を消す */
summary {
  list-style: none;
}
summary::-webkit-details-marker {
  display: none;
}

list-style: none だけだと古いWebKit系で三角が残り、::-webkit-details-marker だけだとFirefoxで残ります。両方書くのが安全です。片方だけで済ませて「Macで見たら三角が二重になっていた」というのがよくある事故です。

自前の矢印を右側に置いて回転させる

CSSだけで矢印を描き、開いたときに回します。

.acc {
  border-bottom: 1px solid #e5e5e5;
}

.acc summary {
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1em;
  padding: 1.1em 0.2em;
  font-weight: 700;
  cursor: pointer;
  /* タップ時の青いハイライトを消す */
  -webkit-tap-highlight-color: transparent;
}

.acc summary::-webkit-details-marker {
  display: none;
}

/* 矢印本体:CSSだけで山形(シェブロン)を描く */
.acc summary::after {
  content: "";
  flex: 0 0 auto;
  width: 0.55em;
  height: 0.55em;
  border-right: 2px solid #333;   /* ← 線の色を変える箇所 */
  border-bottom: 2px solid #333;  /* ← 上と同じ色にする */
  transform: rotate(45deg);
  transform-origin: center;
  transition: transform 0.25s ease;
  /* 下向きに見えるよう少し持ち上げる */
  margin-top: -0.2em;
}

/* 開いたら上向きに */
.acc[open] summary::after {
  transform: rotate(-135deg);
  margin-top: 0.15em;
}

/* キーボード操作のフォーカスを見えるようにする */
.acc summary:focus-visible {
  outline: 2px solid #0057ff;     /* ← サイトのアクセントカラーに変える */
  outline-offset: 2px;
}

.acc__body {
  padding: 0 0.2em 1.3em;
  line-height: 1.9;
}
<details class="acc">
  <summary>返品はできますか</summary>
  <div class="acc__body">
    <p>商品到着後7日以内であれば返品を受け付けます。未開封であることが条件です。</p>
  </div>
</details>

変更する箇所は4つです。

変更点 場所 何に合わせるか
クラス名 .acc HTMLとCSSの両方 既存の命名規則
矢印の色 #333 border-right / border-bottom 本文の文字色
フォーカス色 #0057ff outline サイトのアクセントカラー
区切り線 #e5e5e5 .accborder-bottom 既存のボーダー色

rotate(45deg) が下向き、rotate(-135deg) が上向きです。135deg ではなく マイナス にしているのは、回転の向きを揃えて動きを自然に見せるためです。プラスにすると開くときと閉じるときで逆方向に回り、視線が引っかかります。

矢印の当たり判定を広げたい場合は、summary 側の padding を増やします。::after のサイズを大きくしても、クリックできる範囲は summary 全体なので操作性は変わりません。モバイルでは行の高さが44px前後になるよう padding を調整すると、指で押し外しにくくなります。

プラスマイナス記号にする場合

矢印ではなく「+」「−」で表現したいときは ::after の中身を差し替えます。

.acc summary::after {
  content: "+";
  font-size: 1.2em;
  line-height: 1;
  transition: transform 0.25s ease;
}

.acc[open] summary::after {
  content: "−";
  transform: rotate(180deg);
}

content を切り替えると文字が入れ替わるだけでアニメーションはかかりません。動きを付けたいなら、線を2本重ねて片方だけ倒す方法を採ります。

/* + が × ではなく − に変形する */
.acc summary {
  position: relative;
  padding-right: 2em;
}

.acc summary::before,
.acc summary::after {
  content: "";
  position: absolute;
  right: 0.4em;
  top: 50%;
  width: 0.85em;
  height: 2px;
  background: #333;              /* ← 線の色を変える箇所 */
  transition: transform 0.3s ease;
}

.acc summary::before {
  transform: translateY(-50%);
}

.acc summary::after {
  transform: translateY(-50%) rotate(90deg);
}

/* 開いたら縦線を倒して「−」にする */
.acc[open] summary::after {
  transform: translateY(-50%) rotate(0deg);
}

縦線が回って横線に重なるので、「+」から「−」への変化が滑らかにつながります。

矢印と記号のどちらを選ぶかは、そのアコーディオンが何を伝えたいかで決まります。矢印は「この先に続きがある」という方向を示し、+−は「増える/減る」という量を示します。FAQのように回答が独立している場面では+−、サイドナビの階層のように下に潜っていく構造では矢印が読みやすくなります。

【画像挿入: 矢印回転パターンとプラスマイナスパターンの開閉前後を並べた比較キャプチャ】


開閉アニメーションをCSSだけで付ける

開閉アニメーションをCSSだけで付ける

ここが本題です。長らく <details> の弱点は「パカッと一瞬で開く」ことでした。高さが auto から auto へ変わるだけなので、CSSトランジションが効かなかったためです。

現在は2つのアプローチがあります。新しいCSSで書く方法と、どこでも動くグリッド行を使う方法です。

開閉アニメーションの2つの実装ルート

grid-template-rows 方式

  • 現行ブラウザで広く動く
  • ラッパーのdivが1つ必要
  • 閉じるときの制御にひと工夫要る

interpolate-size 方式

  • CSS3行で済む
  • 余計なマークアップ不要
  • 対応ブラウザがまだ限定的

対応範囲を優先するなら左、記述量を優先するなら右です。両方を1つのCSSに入れて、使えるほうが勝つ書き方も後で示します。

方式A:grid-template-rows で高さを動かす

0fr から 1fr へのトランジションは効きます。この性質を使うと、中身の高さを測らずにアニメーションできます。

<details class="acc2">
  <summary>支払い方法は何がありますか</summary>
  <div class="acc2__wrap">
    <div class="acc2__inner">
      <p>クレジットカード、銀行振込、代金引換、コンビニ後払いに対応しています。</p>
      <p>法人のお客様は請求書払いも選べます。詳しくはお問い合わせください。</p>
    </div>
  </div>
</details>
.acc2 {
  border-bottom: 1px solid #e5e5e5;
}

.acc2 summary {
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.1em 0.2em;
  font-weight: 700;
  cursor: pointer;
}

.acc2 summary::-webkit-details-marker {
  display: none;
}

.acc2 summary::after {
  content: "";
  width: 0.55em;
  height: 0.55em;
  border-right: 2px solid #333;
  border-bottom: 2px solid #333;
  transform: rotate(45deg) translateY(-0.1em);
  transition: transform 0.3s ease;
}

.acc2[open] summary::after {
  transform: rotate(-135deg) translateY(-0.1em);
}

/* 高さのアニメーション本体 */
.acc2 .acc2__wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.3s ease;
}

.acc2[open] .acc2__wrap {
  grid-template-rows: 1fr;
}

/* overflow:hidden は内側に置く。ここが要点 */
.acc2 .acc2__inner {
  overflow: hidden;
}

.acc2 .acc2__inner > *:first-child {
  margin-top: 0;
}

.acc2 .acc2__inner > *:last-child {
  margin-bottom: 1.3em;
}

overflow: hidden を内側の .acc2__inner に置くのが要点です。.acc2__wrap(gridコンテナ側)に付けると、行の高さが0になっても中身がはみ出して見えます。ここを間違えると「閉じているのに文字が1行だけ見える」という症状になります。

もうひとつ、.acc2__inner の中の要素に margin が付いていると、閉じた状態でもその分の高さが残ることがあります。上のCSSで最初の子の margin-top を0にしているのはそのためです。中身をCMSから流し込む場合は、<p> 以外の要素(<ul><h4>)が来ても崩れないよう、> *:first-child のセレクタで受けておきます。

このままだとひとつ問題が残ります。<details>open 属性が外れた瞬間に中身をDOMから隠すため、閉じるアニメーションが再生されず一瞬で消えます。開くときだけ動いて、閉じるときはパッと消える状態です。

閉じるアニメーションまで効かせる(JS 15行)

閉じる動きも見せたい場合だけ、短いJavaScriptを足します。「閉じる指示を受けたら、アニメーションが終わるまで open を維持する」という処理です。

<script>
document.querySelectorAll('.acc2').forEach((details) => {
  const summary = details.querySelector('summary');
  const wrap = details.querySelector('.acc2__wrap');

  summary.addEventListener('click', (e) => {
    // 開くときはブラウザ任せでよい
    if (!details.open) return;

    // 閉じるときだけ横取りして、閉じ状態のグリッド行へ切り替える
    e.preventDefault();
    details.classList.add('is-closing');

    wrap.addEventListener('transitionend', (ev) => {
      // 高さ以外のプロパティで発火した分は無視する
      if (ev.propertyName !== 'grid-template-rows') return;
      details.open = false;
      details.classList.remove('is-closing');
    }, { once: true });
  });
});
</script>
/* 閉じアニメーション中は 0fr を強制する */
.acc2.is-closing .acc2__wrap {
  grid-template-rows: 0fr;
}

transitionend は複数のプロパティで発火することがあるので、propertyName で絞り込んでいます。この1行が無いと、矢印の回転が終わった時点でパネルが消えることがあります。

閉じる動きにこだわらないなら、このJSは不要です。開くときだけ滑らかで、閉じるときは即座という挙動は実用上まったく問題ありません。むしろFAQのように項目数が多い場面では、閉じるのが速いほうが操作感が軽く感じられます。

方式B:interpolate-size で3行だけ書く

対応ブラウザなら、これで済みます。

/* ページ全体でキーワードへのアニメーションを許可する */
:root {
  interpolate-size: allow-keywords;
}

.acc3::details-content {
  height: 0;
  overflow: hidden;
  transition: height 0.3s ease, content-visibility 0.3s ease allow-discrete;
  /* 中身の表示切り替えも一緒にアニメーションさせる */
  content-visibility: hidden;
}

.acc3[open]::details-content {
  height: auto;
  content-visibility: visible;
}
<details class="acc3">
  <summary>領収書は発行できますか</summary>
  <p>マイページからPDFでダウンロードできます。宛名の変更も可能です。</p>
</details>

ラッパーの <div> が不要になり、閉じるアニメーションも自動で効きます。content-visibilityallow-discrete を併記しているのは、高さが0になる前に中身が消えてしまうのを防ぐためです。allow-discrete を書き忘れると、content-visibility が中間値を持たない値なので切り替わりが瞬間的になり、高さだけが動いて中身が先に消えます。

interpolate-size:root に置いているのは、ページ全体で auto へのアニメーションを解禁するためです。影響範囲を狭めたいなら .acc3 { interpolate-size: allow-keywords; } のように個別に指定しても動きます。既存サイトに後入れするなら、後者のほうが事故が少なくなります。

両方式を1つのCSSにまとめる

@supports で分岐すれば、新しいブラウザは方式B、それ以外は方式Aで動きます。マークアップは方式Aのものを使います。

/* 既定:grid方式(どこでも動く) */
.acc2 .acc2__wrap {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.3s ease;
}

.acc2[open] .acc2__wrap {
  grid-template-rows: 1fr;
}

.acc2 .acc2__inner {
  overflow: hidden;
}

/* 対応ブラウザだけ上書きして、閉じる動きも効かせる */
@supports (interpolate-size: allow-keywords) and (selector(::details-content)) {
  :root {
    interpolate-size: allow-keywords;
  }

  /* grid方式を無効化 */
  .acc2 .acc2__wrap {
    display: block;
    grid-template-rows: none;
    transition: none;
  }

  .acc2::details-content {
    height: 0;
    overflow: hidden;
    content-visibility: hidden;
    transition: height 0.3s ease, content-visibility 0.3s ease allow-discrete;
  }

  .acc2[open]::details-content {
    height: auto;
    content-visibility: visible;
  }
}

この形にする場合、前述の「閉じるアニメーション用JS」は入れないでください。方式Bが効いている環境では grid-template-rows のトランジションが起きないため、transitionend が発火せず、パネルが開いたまま固まります。閉じる動きが必要でJSも併用したいなら、JS側の先頭で CSS.supports('selector(::details-content)') を見て、true なら何もしないようにします。

新しい環境では余計なJSなしで閉じる動きまで付き、古い環境でも開閉自体は必ず動きます。この「壊れても機能は残る」形が、<details> を使う一番の利点です。

動きを減らす設定への配慮

OS側で「視差効果を減らす」を有効にしているユーザー向けに、アニメーションを止めます。1ブロック足すだけです。

@media (prefers-reduced-motion: reduce) {
  .acc2 .acc2__wrap,
  .acc2 summary::after,
  .acc2::details-content {
    transition: none;
  }
}

前庭障害のあるユーザーにとって、高さが動く演出は不調の引き金になり得ます。工数はほぼゼロなので、必ず入れてください。なお、閉じるアニメーション用のJSを入れている場合は、この設定下では transitionend が発火しないため閉じられなくなります。JS側の先頭に次の1行を足して回避します。

if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;

複数開かせない排他アコーディオンの書き方

「1つ開いたら他が閉じる」挙動は、以前はJavaScriptで書くしかありませんでした。今は name 属性だけで実現できます。

name 属性を使う(HTMLだけ)

<div class="faq">
  <details class="acc2" name="faq-group">
    <summary>送料はいくらですか</summary>
    <div class="acc2__wrap"><div class="acc2__inner">
      <p>全国一律550円です。8,000円以上のご購入で無料になります。</p>
    </div></div>
  </details>

  <details class="acc2" name="faq-group">
    <summary>配送日時は指定できますか</summary>
    <div class="acc2__wrap"><div class="acc2__inner">
      <p>ご注文から3日後以降で、午前・午後・夜間の3区分から指定できます。</p>
    </div></div>
  </details>

  <details class="acc2" name="faq-group">
    <summary>海外に発送できますか</summary>
    <div class="acc2__wrap"><div class="acc2__inner">
      <p>現在は国内のみの発送です。海外配送は準備中です。</p>
    </div></div>
  </details>
</div>

name の値(ここでは faq-group)を揃えたもの同士が1グループになります。変更する箇所は name の値だけです。ページ内に複数のアコーディオン群があるなら、群ごとに違う値にしてください。

ラジオボタンの name と同じ考え方です。グループ内で開けるのは常に1つになります。ただしラジオボタンと違い、開いているものをもう一度クリックすれば全部閉じた状態にもできます。「必ずどれか1つが開いている」状態にはなりません。

排他にするかどうかは中身の性質で決めます。項目同士を見比べる必要があるなら排他にしない。料金プランや仕様の比較は、2つ並べて読めることに価値があります。逆に独立した問答が並ぶなら排他が効きます。FAQは1つずつ読むものなので、前に開いたものが残っているとスクロール位置が狂います。

未対応ブラウザ向けのフォールバック

name 属性に対応していない環境では、単に全部開けるだけです。壊れません。それでも排他を徹底したいなら、対応判定つきでJSを足します。

<script>
// name属性に未対応の環境だけJSで補う
if (!('name' in document.createElement('details'))) {
  document.querySelectorAll('details[name]').forEach((details) => {
    details.addEventListener('toggle', () => {
      if (!details.open) return;
      const group = details.getAttribute('name');
      document.querySelectorAll(`details[name="${group}"]`).forEach((other) => {
        if (other !== details) other.open = false;
      });
    });
  });
}
</script>

対応済みブラウザでは1行も実行されないので、無駄な負荷になりません。

「すべて開く/すべて閉じる」ボタン

長いFAQでは一括操作があると親切です。ただし name 属性で排他にしている場合、「すべて開く」は成立しません(1つしか開けないため)。排他にしないグループでのみ使ってください。

<div class="faq-controls">
  <button type="button" data-acc-all="open">すべて開く</button>
  <button type="button" data-acc-all="close">すべて閉じる</button>
</div>

<script>
document.querySelectorAll('[data-acc-all]').forEach((btn) => {
  btn.addEventListener('click', () => {
    const shouldOpen = btn.dataset.accAll === 'open';
    // ↓ セレクタを自分のクラス名に変える
    document.querySelectorAll('.faq details').forEach((d) => {
      d.open = shouldOpen;
    });
  });
});
</script>

.faq details の部分を自分のマークアップに合わせて変更してください。ページ内検索(Ctrl+F)で中身を探したいユーザーにとって「すべて開く」は実用的な機能なので、項目数が10を超えるようなFAQでは置いておく価値があります。


検索エンジンとスクリーンリーダーからの見え方

検索エンジンとスクリーンリーダーからの見え方

実装が終わったあと必ず聞かれるのが「閉じている中身はインデックスされるのか」です。結論から書きます。

検索エンジンからは読める

<details> の中身は、閉じていてもHTMLソースに存在します。display: none で消しているわけではなく、ブラウザの表示制御で折りたたまれているだけです。クローラーはHTMLを読むので、中身のテキストは取得されます。

確認方法は単純です。ブラウザで「ページのソースを表示」(Ctrl+U / Cmd+Option+U)して、閉じている <details> の中身が出てくるかを見てください。出ていればクローラーからも見えています。

注意すべきなのは JavaScriptで中身を後から差し込んでいる場合 です。この記事の実装はすべてHTMLに直接書いているので該当しませんが、Ajaxで読み込む作りにすると話が変わります。素のHTMLで済ませる理由のひとつがここにあります。

ただし「読める」ことと「主要なコンテンツとして評価される」ことは別です。ページの中心的な内容を全部アコーディオンに畳んでしまうより、要点は開いた状態で見せ、補足を畳むほうが読み手にとっても自然です。折りたたみは情報を隠すための道具ではなく、優先順位を付けるための道具として使います。

FAQPage 構造化データを併記する

FAQとして使うなら、構造化データを足しておくと検索結果での見え方が変わることがあります。HTMLと内容を一致させるのが条件です。

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "送料はいくらですか",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "全国一律550円です。8,000円以上のご購入で無料になります。"
      }
    },
    {
      "@type": "Question",
      "name": "配送日時は指定できますか",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "ご注文から3日後以降で、午前・午後・夜間の3区分から指定できます。"
      }
    }
  ]
}
</script>

変更する箇所は name(質問)と text(回答)です。HTMLに書いた文言と一字一句そろえてください。ずれていると構造化データとして無効と判定されることがあります。書いたあとはGoogleのリッチリザルトテストで検証してください。

リッチリザルトとして表示されるかどうかは検索側の判断であり、書けば必ず出るものではありません。表示条件は変わるので、実装時点で最新の仕様を確認してから入れてください。それでも、AI検索やアシスタントが問答のペアを拾う手がかりにはなるので、FAQを持つページなら入れておく側に倒します。

スクリーンリーダーからの見え方

<summary> はボタンとして扱われ、開閉状態が読み上げられます。aria-expanded を自分で書く必要はありません。むしろ手で足すと二重管理になり、ずれたときに嘘の状態を読み上げます。

やってはいけないことを整理します。

やること 理由
aria-expanded を手動で付けない ブラウザが自動で管理する
role="button" を付けない <summary> はすでにその役割を持つ
tabindex="0" を付けない 標準でフォーカスされる
:focus-visible のスタイルは必ず書く ブラウザ既定を消したままにしない
<summary> の中に <a><button> を入れない クリックが競合して開閉と遷移が混ざる

見出しとして扱いたい場合だけ、<summary> の中に見出しタグを入れます。

<details class="acc2">
  <summary><h3>返品はできますか</h3></summary>
  <div class="acc2__wrap"><div class="acc2__inner">
    <p>商品到着後7日以内であれば返品を受け付けます。</p>
  </div></div>
</details>
/* 見出しタグの余白とサイズをリセットする */
.acc2 summary h3 {
  margin: 0;
  font-size: 1rem;
  display: inline;
}

<h3> を入れると <summary> が横幅いっぱいのブロックになり、display: flex のレイアウトが崩れます。display: inline を当てて回避してください。ここは実装時に必ずぶつかる箇所です。

見出しにするかどうかの判断基準は、そのページの目次に載るべき項目かどうかです。FAQの質問文を全部 <h3> にすると、支援技術の見出し一覧が質問だらけになって他の構造が埋もれます。質問が10も20もあるなら、見出しにせず素のテキストのままにするほうが読みやすくなります。

実装確認の手順

公開前に通す4つの確認
キーボードTabで到達しEnterで開くか
ソース表示閉じた中身がHTMLにあるか
動き軽減OS設定でアニメが止まるか
実機iOSとAndroidで矢印が二重にならないか

この4つを通せば、あとから直すことはほぼなくなります。特に4つ目は、Macを持っていない環境で見落としがちです。


Soreiine!!掲載サイトに見る使いどころ

Soreiine!!掲載サイトに見る使いどころ

ギャラリーに集まっているサイトを眺めると、アコーディオンの使い方には明確な傾向があります。

【画像挿入: Soreiine!!ギャラリーでアコーディオンを採用しているサイトのFAQエリア3例】

FAQセクションが最も多い用途です。質問文だけを一覧させ、答えを畳んでおく。ページの縦方向が短くなり、ユーザーは自分の疑問だけを開けます。共通しているのは、質問文が「〜ですか」で終わる完全な文になっていること。「送料について」ではなく「送料はいくらですか」と書かれているものは、閉じた状態の一覧を眺めるだけで、答えが自分に関係あるかを判断できます。この差は実装の巧拙ではなく文言の設計です。

採用ページの募集要項も定番です。職種ごとの詳細を畳んでおき、興味のある職種だけ開かせる。ここで効いているのが name 属性による排他です。複数の職種を同時に開かれると縦に長くなりすぎて比較しにくくなりますが、1つだけ開く形にすると常に同じ位置で読めます。

見どころとしては、閉じている状態でも情報が足りている設計です。<summary> に職種名だけでなく「東京/正社員」といった条件を添えているものは、開かなくても選べるので、開く回数そのものが減ります。アコーディオンは「隠す装置」ではなく「並べ替えて見せる装置」だと分かる作りです。閉じた状態の1行に何を入れるかを決める作業は、実装ではなく情報設計の仕事になります。

モバイルのフッターナビにも使われています。カテゴリ名だけを並べ、タップで下層リンクが開く。デスクトップではCSSで open 相当に見せ、モバイルだけ折りたたむという分岐も、メディアクエリで書けます。

/* 768px以上では常に開いた見た目にする */
@media (min-width: 768px) {
  .footer-nav summary {
    pointer-events: none;   /* クリックを無効化 */
  }
  .footer-nav summary::after {
    display: none;          /* 矢印を消す */
  }
  .footer-nav .acc2__wrap {
    grid-template-rows: 1fr;
  }
}

pointer-events: none でクリックを封じ、grid行を開いた状態に固定します。HTMLはモバイルと共通のまま、CSSだけで見え方を変えられます。ただし pointer-events: none はキーボードのフォーカスまでは外さないため、Tabで到達しても何も起きない要素が残ります。厳密にやるなら、デスクトップ側で summarytabindex="-1" を当てるスクリプトを併用するか、フッターナビだけはCSSではなくマークアップを分けてください。

料金表や仕様表の補足にも向きます。表の各行の下に「この項目の詳細」を畳んでおくと、比較のための一覧性を保ったまま、必要な人だけ深く読めます。この場合は排他にせず、複数開けるようにします。

サイト構築の別パーツも同じ考え方でライブラリなしに組めます。スライダーの実装はこちらにまとめています。


Shopify・WordPressへの組み込み

素のHTMLで動くものは、CMSに入れるのも簡単です。編集画面から中身を差し替えられる形にしておきます。

Shopify(Online Store 2.0)のセクション

sections/faq-accordion.liquid を新規作成します。

{% comment %} sections/faq-accordion.liquid {% endcomment %}
<div class="faq-acc page-width">
  {% if section.settings.heading != blank %}
    <h2>{{ section.settings.heading }}</h2>
  {% endif %}

  {% for block in section.blocks %}
    <details class="acc2" name="faq-{{ section.id }}" {{ block.shopify_attributes }}>
      <summary>{{ block.settings.question }}</summary>
      <div class="acc2__wrap">
        <div class="acc2__inner">{{ block.settings.answer }}</div>
      </div>
    </details>
  {% endfor %}
</div>

{% schema %}
{
  "name": "FAQアコーディオン",
  "settings": [
    { "type": "text", "id": "heading", "label": "見出し", "default": "よくある質問" }
  ],
  "blocks": [
    {
      "type": "qa",
      "name": "質問",
      "settings": [
        { "type": "text", "id": "question", "label": "質問文" },
        { "type": "richtext", "id": "answer", "label": "回答" }
      ]
    }
  ],
  "presets": [
    { "name": "FAQアコーディオン", "blocks": [ { "type": "qa" }, { "type": "qa" } ] }
  ]
}
{% endschema %}

CSSは assets/ のスタイルシートに前述のものを入れるか、このファイル末尾に {% stylesheet %} で書きます。

変更する箇所name の接頭辞(faq-)と、見出しのデフォルト文言です。{{ section.id }} を付けているのは、同じページに複数配置したときにグループが混ざらないようにするためです。{{ block.shopify_attributes }} はテーマエディタでブロックを選んだときにハイライトさせるために必要なので、消さないでください。

テーマエディタでブロックを選択すると、その位置までスクロールする挙動が入ります。閉じているアコーディオンの中を編集する場合、編集中のブロックだけ open にしたいことがあります。{% if request.design_mode %}open{% endif %}<details> に足せば、テーマエディタ上でだけ全部開いた状態になり、編集しやすくなります。公開画面には影響しません。

Shopifyでどこまでカスタマイズできるかの全体像はこちらで整理しています。

WordPressのショートコード

functions.php(子テーマ)に追加します。

<?php
// 子テーマの functions.php に追記
function soreiine_accordion_shortcode( $atts, $content = null ) {
    $a = shortcode_atts( array(
        'title' => '見出しを入力してください',
        'group' => '',
        'open'  => 'false',
    ), $atts );

    $name = $a['group'] !== '' ? ' name="' . esc_attr( $a['group'] ) . '"' : '';
    $open = $a['open'] === 'true' ? ' open' : '';

    return sprintf(
        '<details class="acc2"%s%s><summary>%s</summary>' .
        '<div class="acc2__wrap"><div class="acc2__inner">%s</div></div></details>',
        $name,
        $open,
        esc_html( $a['title'] ),
        do_shortcode( wp_kses_post( $content ) )
    );
}
add_shortcode( 'acc', 'soreiine_accordion_shortcode' );

投稿側ではこう書きます。

[acc title="送料はいくらですか" group="faq"]
全国一律550円です。8,000円以上のご購入で無料になります。
[/acc]

[acc title="配送日時は指定できますか" group="faq"]
ご注文から3日後以降で、午前・午後・夜間の3区分から指定できます。
[/acc]

group を同じ値にすれば排他になり、省略すれば複数開けます。esc_html()wp_kses_post() を通しているのは、編集者が貼った内容がそのままHTMLとして実行されるのを防ぐためです。ここは外さないでください

CSSの読み込みは子テーマの style.css に追記するのが最短です。ショートコードが使われているページでだけ読ませたいなら、wp_enqueue_style() をショートコード関数の中で呼ぶ方法もありますが、数十行のCSSであれば分割の手間に見合いません。まとめて読ませてしまうほうが管理が楽です。

ショートコードでUIパーツを作る手順は、ブログカードの実装記事でも同じ形を使っています。


うまくいかないとき

実装中につまずきやすい箇所を、症状から引ける形でまとめます。

三角マークが消えない

list-style: none::-webkit-details-marker両方を書いているか確認してください。片方だけだとどちらかのブラウザで残ります。それでも消えない場合は、summarydisplay: flexdisplay: block を当てているかを見てください。display: list-item のままだとマーカーが出続けます。

閉じているのに中身が1行だけ見える

overflow: hidden の位置が間違っています。grid方式では内側の要素.acc2__inner)に付けます。gridコンテナ側に付けても行の高さが0になったときに中身がはみ出します。

開くときは動くのに閉じるときは一瞬で消える

これは仕様どおりの動きです。open 属性が外れると中身がすぐ隠れるためです。閉じる動きも付けたいなら、記事中の「閉じるアニメーションまで効かせる」のJSを足すか、interpolate-size 方式が使える環境に限定してください。

開いたまま閉じなくなった

@supports で方式Bに切り替わっている環境で、方式A用の閉じるJSが動いています。grid-template-rows のトランジションが起きないため transitionend が発火せず、details.open = false に到達しません。JS側の先頭で if (CSS.supports('selector(::details-content)')) return; を入れて、方式Bが効く環境では横取りしないようにします。prefers-reduced-motion を有効にしている環境でも同じ症状が出ます。

高さが途中で止まる/中身が切れる

max-height を使った古い実装が残っていないか確認してください。max-height: 500px のような固定値は、中身がそれを超えると切れます。この記事のgrid方式か height: auto 方式に置き換えれば、中身の量にかかわらず正しく開きます。

name 属性で排他にならない

まずブラウザの対応を確認します。DevToolsのコンソールで 'name' in document.createElement('details') を実行し、false ならその環境は未対応です。true なのに効かない場合は、name の値がグループ内で一致しているかを見てください。1文字でも違うと別グループになります。

summary の中のリンクを押すと開いてしまう

<summary> の中に <a> を入れると、クリックが開閉と競合します。リンクは中身側に移すのが正解です。どうしても入れる必要があるなら、リンク側で e.stopPropagation() を呼びます。ただし操作の予測がつかなくなるので、設計そのものを見直すほうを勧めます。

iOSで開閉時に画面がガタつく

summary-webkit-tap-highlight-color: transparent を当て、user-select: none も足してみてください。タップ時のテキスト選択が原因のことがあります。

.acc2 summary {
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}

開いた瞬間にページが飛ぶ

ページ下部のアコーディオンを開くと、スクロール位置が変わって読んでいた場所を見失うことがあります。中身が長い場合は、summaryposition: sticky; top: 0; にして、開いている間だけ質問文が画面上部に残るようにすると迷いにくくなります。背景色を必ず指定してください。透明のままだと下の文字が透けます。

印刷時に閉じたまま出力される

印刷用CSSで開いた状態に固定します。

@media print {
  details:not([open]) > *:not(summary) {
    display: block !important;
  }
  .acc2 .acc2__wrap {
    grid-template-rows: 1fr !important;
  }
  .acc2 summary::after {
    display: none;
  }
}

マニュアルや料金表をアコーディオンに入れている場合、印刷で中身が消えると問い合わせにつながります。実装ついでに入れておくと後で助かります。


よくある質問

details要素の中身は検索エンジンにインデックスされますか

閉じた状態でもHTMLソースに存在するため、クローラーからは読めます。ブラウザで「ページのソースを表示」して中身が出ていれば問題ありません。ただしJavaScriptで中身を後から差し込む作りにすると、読まれない可能性が出てきます。HTMLに直接書いておくのが確実です。

アコーディオンにJavaScriptは本当に不要ですか

開閉、キーボード操作、フォーカス管理、アクセシビリティ属性は、すべてブラウザ側が処理します。開くときのアニメーションもCSSだけで書けます。JavaScriptが必要になるのは、閉じるときのアニメーションを付ける場合と、開閉を計測ツールに送る場合だけです。

同時に1つしか開かないようにするにはどうしますか

同じグループの <details> に同じ name 属性を付けます。<details name="faq"> のように書けば、ラジオボタンと同じように1つだけ開く状態になります。未対応ブラウザでは複数開けるだけで壊れないため、フォールバックは必須ではありません。

summary の中に見出しタグを入れてもよいですか

構いません。<summary><h3>質問文</h3></summary> の形で書けます。ただし見出しタグはブロック要素なのでレイアウトが崩れます。display: inlinemargin: 0 を当ててリセットしてください。質問が多数並ぶ場合は、見出しにせず素のテキストにするほうが支援技術の見出し一覧が読みやすくなります。

アニメーションの秒数はどれくらいが適切ですか

0.2〜0.35秒あたりが扱いやすい範囲です。これより長いと操作の反応が鈍く感じられ、短いと動いたことが認識されません。中身が長いパネルほど、同じ秒数でも速く感じられるので、FAQのように内容量がまちまちな場面では0.3秒前後に固定するのが無難です。


まとめ

アコーディオンは <details><summary> だけで成立します。開閉、キーボード操作、支援技術への状態通知は、ブラウザが持っている機能です。自分で書くのはデザインの部分だけで済みます。

作業の順番としてはこうです。

  1. <details> / <summary> で素のまま組む
  2. list-style: none::-webkit-details-marker で三角を消す
  3. ::after で矢印を描き、[open] で回転させる
  4. アニメーションは grid方式で入れ、@supports で新しい方式に上書きする
  5. prefers-reduced-motion でアニメーションを止める分岐を入れる
  6. 排他にしたいなら name 属性を揃える

外部ライブラリを読み込まないので、表示速度への影響もありません。JavaScriptが失敗しても開閉は動きます。壊れ方が浅いのが、この実装を選ぶ一番の理由です。