CSSで、アコーディオンをなめらかに開閉する方法

​​CSS​HTML,JavaScript,Playwright,アクセシビリティ

<details> で作ったアコーディオンは、中身を包む ::details-content 疑似要素の高さを、0 から auto へ transition で動かすと、なめらかに開閉します。auto へのアニメーションを許す interpolate-size: allow-keywords と、閉じるときに中身がすぐ消えないようにする content-visibility の transition を足せば、JavaScript は要りません。<details> を使わない作りなら、grid-template-rows を 0fr から 1fr へ動かす方法があります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開いてクリックし、描画のコマごとに要素の高さを測りました。ブラウザの対応バージョンは、@mdn/browser-compat-data 8.1.8 の値です。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げ、ブラウザのページ内検索で閉じた中身が見つかるかどうかは試していません。

2つの質問が並んだアコーディオン。「ごはんは1日に何回ですか」をクリックすると、答えの文が上から少しずつ現れ、下の「1つ目」の高さが 49px から 85px まで増える。もう一度クリックすると 49px まで縮んで閉じる。最後に「爪切りはいつしますか」をクリックすると、2つ目が同じように開き、「2つ目」の高さが 85px になる。高さの数字は、値が変わっているコマだけピンクの背景になる
summary を3回クリックした様子。開くときも閉じるときも、高さが少しずつ変わる。高さとクリック回数の表示を足した検証用の画面で、答えの文と幅が本文の測定に使ったページと違うため、開いた高さも違う。transition を10倍の長さにして撮り、実際の速さに戻している

details の中身の高さを、0 から auto へ動かす

HTML は、<details> と <summary> をそのまま並べます。summary をクリックしたときに開閉する仕組みは、ブラウザが受け持ちます。

html
<div class="faq">
  <details>
    <summary>ごはんは1日に何回ですか</summary>
    <p class="body">朝と夜の2回が目安です。量はフードの袋の表示を見て決めます。<a href="#">ごはんの記録</a></p>
  </details>
  <details>
    <summary>爪切りはいつしますか</summary>
    <p class="body">2週間に1回が目安です。</p>
  </details>
</div>

CSS の全体は次のとおりです。三角の代わりに ::after で矢印を描き、開いたときに回しています。

css
/* details と summary で作る、なめらかに開閉するアコーディオン */
.faq details {
  border-bottom: 1px solid #c9dfe5;
  /* auto への補間を、この中だけで許す */
  interpolate-size: allow-keywords;
}
.faq summary {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 8px;
  list-style: none;
  cursor: pointer;
  font-weight: bold;
}
.faq summary::-webkit-details-marker {
  display: none;
}
.faq summary::after {
  content: '';
  width: 8px;
  height: 8px;
  border-right: 2px solid #067a98;
  border-bottom: 2px solid #067a98;
  transform: rotate(45deg);
  transition: transform 300ms;
}
.faq details[open] > summary::after {
  transform: rotate(-135deg);
}
/* 開閉で動かすのは、中身を包む ::details-content の高さ */
.faq details::details-content {
  block-size: 0;
  overflow: hidden;
  transition:
    block-size 300ms ease,
    content-visibility 300ms allow-discrete;
}
.faq details[open]::details-content {
  block-size: auto;
}
/* 余白は ::details-content ではなく、中の要素に付ける */
.faq .body {
  margin: 0;
  padding: 0 8px 12px;
}
@media (prefers-reduced-motion: reduce) {
  .faq details::details-content,
  .faq summary::after {
    transition: none;
  }
}

::details-content は、<details> の中で <summary> 以外の、開閉する部分を指す疑似要素です。閉じているときは高さ 0、開いているときは auto にして、そのあいだを transition でつなぎます。

ただし、auto のような中身で決まる大きさへは、既定ではアニメーションしません。MDN の interpolate-size のページによると、既定の値は numeric-only で、allow-keywords にすると 0 のような長さと auto のあいだを補間できるようになります。この値は子孫に受け継がれるので、:root に書けばページ全体に効きます。ここでは、アコーディオンの details の中だけに効くように書きました。

最初の項目をクリックしてから、details の高さを描画のコマごとに測りました。次の 50ms ごとの値は、その時刻を過ぎて最初に描画されたコマの値です。コマはおよそ 16ms ごとなので、実行のたびに数 px ずれます。

text
[details の基本形] 閉じた状態 49px
  開く: 0ms 49px / 50ms 70px / 100ms 89px / 150ms 100px / 200ms 106px / 250ms 109px / 300ms 109px / 350ms 109px
  閉じる: 0ms 109px / 50ms 88px / 100ms 69px / 150ms 58px / 200ms 52px / 250ms 49px / 300ms 49px / 350ms 49px

開くときも閉じるときも、およそ 300ms かけて高さが変わりました。interpolate-size を既定の numeric-only に戻すと、クリックした次のコマで、もう開ききっていました。

クリックしてからの details の高さを、描画のコマごとに並べたグラフ。interpolate-size: allow-keywords の水色の線は、49px から 300ms 近くかけて 109px まで曲線で伸びる。numeric-only(既定)の灰色の線は、クリックから 14ms のコマでもう 109px になっている
開くときの details の高さ。水色が allow-keywords、灰色が既定の numeric-only

閉じるときは、content-visibility にも transition を付ける

transition に content-visibility 300ms allow-discrete を入れているのは、閉じるアニメーションのためです。block-size だけに transition を付けた版も測ると、開くときは同じように動きましたが、閉じるときはクリックした直後から閉じた高さになりました。

text
[content-visibility の transition を外す] 閉じた状態 49px
  開く: 0ms 49px / 50ms 62px / 100ms 84px / 150ms 97px / 200ms 104px / 250ms 108px / 300ms 109px / 350ms 109px
  閉じる: 0ms 49px / 50ms 49px / 100ms 49px / 150ms 49px / 200ms 49px / 250ms 49px / 300ms 49px / 350ms 49px

閉じた details では、::details-content の content-visibility が hidden になります。MDN の content-visibility のページによると、hidden の要素は中身の描画を省きます。閉じている途中の値を読むと、transition がない版では 50ms の時点でもう hidden でした。付けた版では、150ms の時点でも visible のままで、400ms の時点で hidden になっていました。

text
[details の基本形、::details-content の content-visibility] 閉じる: 50ms visible / 150ms visible / 400ms hidden
[content-visibility の transition を外す] 閉じる: 50ms hidden / 150ms hidden / 400ms hidden

同じページにあるとおり、visible から hidden へのアニメーションでは、値が hidden に変わるのは最後の時点です。ただし、transition でこの動きにするには、transition-behavior の allow-discrete が要ります。allow-discrete を付けないと、content-visibility のような離散的なプロパティには transition が始まりません。

余白は ::details-content ではなく、中の要素に付ける

中身の余白を ::details-content に付けると、閉じた状態でも余白の分だけ高さが残ります。.body の padding を外し、同じ padding: 0 8px 12px を ::details-content に付けて測りました。

text
[余白を ::details-content に付ける] 閉じた状態 61px
  開く: 0ms 61px / 50ms 72px / 100ms 89px / 150ms 100px / 200ms 105px / 250ms 108px / 300ms 109px / 350ms 109px
  閉じる: 0ms 109px / 50ms 92px / 100ms 77px / 150ms 68px / 200ms 63px / 250ms 61px / 300ms 61px / 350ms 61px

閉じた高さが 49px ではなく 61px になり、block-size: 0 にしても下の余白 12px が残りました。余白は、上の CSS の .body のように、::details-content の中の要素に付けます。

calc-size(auto, size) でも同じように動く

interpolate-size を使わずに、開いたときの高さを calc-size(auto, size) と書く方法もあります。interpolate-size を numeric-only に戻したうえで、次の指定に差し替えて測りました。

css
.faq details[open]::details-content {
  block-size: calc-size(auto, size);
}
text
[calc-size(auto, size) で書く] 閉じた状態 49px
  開く: 0ms 49px / 50ms 62px / 100ms 84px / 150ms 97px / 200ms 104px / 250ms 108px / 300ms 109px / 350ms 109px
  閉じる: 0ms 109px / 50ms 88px / 100ms 69px / 150ms 58px / 200ms 52px / 250ms 49px / 300ms 49px / 350ms 49px

開くときも閉じるときも、interpolate-size の場合と同じく 300ms ほどかけて動きました。MDN の calc-size() のページでは、auto への補間を許したいだけなら interpolate-size を使い、calc-size() は計算もしたいときに使うよう勧めています。

動きを減らす設定では、transition を外す

OS の設定で、画面の動きを減らすよう指定している人もいます。その場合は、開閉を動かさないようにします。MDN の prefers-reduced-motion のページにあるとおり、この設定は @media (prefers-reduced-motion: reduce) で受け取れます。上の CSS では、このときに transition: none にしています。Playwright で reducedMotion: 'reduce' を指定して測ると、クリックした直後のコマで開ききり、閉じるときも同じく一度に閉じました。

text
[details、prefers-reduced-motion: reduce] 閉じた状態 49px
  開く: 0ms 109px / 50ms 109px / 100ms 109px / 150ms 109px / 200ms 109px / 250ms 109px / 300ms 109px / 350ms 109px
  閉じる: 0ms 49px / 50ms 49px / 100ms 49px / 150ms 49px / 200ms 49px / 250ms 49px / 300ms 49px / 350ms 49px

details を使わないなら、grid-template-rows を 0fr から 1fr へ動かす

ボタンと aria-expanded で開閉を作る場合は、::details-content が使えません。その場合は、中身を包む要素を1行だけのグリッドにして、行の高さを 0fr から 1fr へ動かします。下の CSS には interpolate-size を書いていませんが、高さは少しずつ変わりました。

html
<button class="trigger" type="button" aria-expanded="false" aria-controls="p1">ごはんは1日に何回ですか</button>
<div class="panel" id="p1">
  <div class="inner">
    <p class="body">朝と夜の2回が目安です。量はフードの袋の表示を見て決めます。<a href="#">ごはんの記録</a></p>
  </div>
</div>
css
/* details を使わない場合:grid-template-rows を 0fr から 1fr へ動かす */
.panel {
  display: grid;
  grid-template-rows: 0fr;
  visibility: hidden;
  transition:
    grid-template-rows 300ms ease,
    visibility 300ms;
}
.trigger[aria-expanded='true'] + .panel {
  grid-template-rows: 1fr;
  visibility: visible;
}
/* 1fr の行に入る中身は、min-height: 0 と overflow: hidden で縮める */
.panel > .inner {
  min-height: 0;
  overflow: hidden;
}
.panel .body {
  margin: 0;
  padding: 0 8px 12px;
}
@media (prefers-reduced-motion: reduce) {
  .panel {
    transition: none;
  }
}
js
// ボタンを押すたびに aria-expanded を切り替える
document.querySelectorAll('.trigger').forEach((button) => {
  button.addEventListener('click', () => {
    const open = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', String(!open));
  });
});

ボタンを押してから、.panel の高さを測りました。

text
[grid-template-rows の基本形] 閉じた状態 0px
  開く: 0ms 0px / 50ms 21px / 100ms 40px / 150ms 51px / 200ms 57px / 250ms 60px / 300ms 60px / 350ms 60px
  閉じる: 0ms 60px / 50ms 39px / 100ms 20px / 150ms 9px / 200ms 3px / 250ms 0px / 300ms 0px / 350ms 0px

details の場合と違って、閉じた中身を隠す仕組みは自分で用意する必要があります。ここでは visibility を使いました。閉じた状態で、見出しの summary やボタンにフォーカスして Tab を押し、次にフォーカスが移る要素を調べました。

text
[details、閉じた状態] Tab のあと: <summary> 爪切りはいつしますか
[grid、閉じた状態(visibility あり)] Tab のあと: <button> 後ろのボタン
[grid、閉じた状態(visibility を外す)] Tab のあと: <a> ごはんの記録

visibility: hidden を外した版では、高さ 0 で見えていないパネルの中のリンクに、フォーカスが移りました。details は、閉じた中身のリンクを飛ばして、次の summary に移っています。MDN の visibility のページにあるとおり、visibility: hidden の要素はフォーカスを受け取れません。

visibility の transition には allow-discrete を付けていません。MDN の同じページによると、visibility は、片方の端が visible なら、transition が終わるまでのあいだ visible として扱われます。閉じている途中の値を読むと、150ms の時点では visible で、閉じ終わった 400ms の時点で hidden になっていました。

text
[grid、.panel の visibility] 閉じる: 50ms visible / 150ms visible / 400ms hidden

対応しているブラウザ

@mdn/browser-compat-data 8.1.8 で、使ったプロパティが最初に使えるようになったバージョンを調べました。

機能 Chrome Edge Firefox Safari
::details-content 131 131 143 18.4
interpolate-size 129 129 なし なし
calc-size() 129 129 なし なし
transition-behavior 117 117 129(一部) 17.4
grid-template-rows のアニメーション 107 107 66 16

details の作り方で auto への補間に使う interpolate-size と calc-size() に対応しているのは、表の4つのブラウザのうち Chrome と Edge だけです。Firefox の transition-behavior は、互換性データでは一部だけの対応とされています。MDN でも interpolate-size は実験的な機能とされています。Firefox と Safari でも高さを動かしたいときは、grid-template-rows の作り方を選びます。

​Posted by 86