零弐壱蜂

[CSS] `折りたたみ要素の開閉を気持ちよくアニメーションさせる方法

開閉UIの印象はタイミングで変わる

アコーディオンや details 要素のような開閉するUIは、開閉そのものよりもアニメーションの質で印象が変わる。瞬時に切り替わるだけでも機能は果たすが、高さがなめらかに伸び、中身が少し遅れて現れると、操作に対する反応として心地良くなる。

この心地良さの正体は、高さと中身のアニメーションの差にある。高さと中身を同じ時間でまとめて動かすのではなく、別のタイミング・別の長さで動かし、開く時と閉じる時で順序を反転させる。気持ちよく見せる鍵は、個々のCSS機能よりもこの時間差の設計にある。

ただし、この設計をCSSだけで実現するには長らく2つの壁があった。高さ auto をアニメーションできないことと、中身の表示と非表示が中間なしで切り替わり、フェードを挟めないことである。2024年から2025年にかけて標準化されたCSS機能によって、この2つの壁はラッパー要素やJavaScriptなしで越えられるようになった。

このうち高さの伸縮に必要な機能は、2026年6月時点でChromium系ブラウザだけが対応する。未対応ブラウザでは標準の details として即時に開閉するため、この動きは機能要件ではなく、対応ブラウザ向けの演出(漸進的強化)として位置付ける。この記事では、対応ブラウザで演出をどう設計するかに絞る。

サンプルコードは次の通りである。details の中身を高さの伸縮とフェードで開閉し、開く時と閉じる時で動きの順序を変えている。さらに全体を @supports で囲んだ出し分けと、prefers-reduced-motion への対応も組み込んでいる。

@supports (interpolate-size: allow-keywords) {
  details::details-content {
    height: 0;
    overflow-y: clip;
    opacity: 0;
    transform: translateY(-8px);
    /* 閉じる時: コンテンツが先にフェードアウト → 高さ収縮 */
    transition:
      height var(--durations-slow) var(--easings-ease-out-expo) 0.08s,
      opacity var(--durations-normal),
      transform var(--durations-normal),
      content-visibility var(--durations-slow) ease allow-discrete;
  }

  details[open]::details-content {
    height: auto; /* fallback */
    height: calc-size(auto, size);
    opacity: 1;
    transform: translateY(0);
    /* 開く時: 高さが先に展開 → コンテンツがフェードイン */
    transition:
      height var(--durations-slower) var(--easings-ease-out-expo),
      opacity var(--durations-slower) var(--easings-ease-out-expo) var(--durations-fast),
      transform var(--durations-slower) var(--easings-ease-out-expo) var(--durations-fast),
      content-visibility var(--durations-slower) ease allow-discrete;
  }

  @media (prefers-reduced-motion: reduce) {
    details::details-content,
    details[open]::details-content {
      /* 移動系は即時化し、状態変化を伝えるフェードだけ残す */
      transition: opacity 0.2s;
    }
  }
}

セレクタに現れる ::details-content は、summary を除いた details の展開部分を指す擬似要素である。

変数には次の値を置いた前提で読む。

:root {
  --durations-slower: 500ms; /* 開く時の高さ展開にかける時間 */
  --durations-slow: 350ms; /* 閉じる時の高さ収縮にかける時間 */
  --durations-fast: 100ms; /* 開く時に中身を遅らせる時間 */
  --durations-normal: 200ms; /* 閉じる時に中身を消す時間 */
  --easings-ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}

まず、このコードが作る動きをデモで確かめる。

気持ちよさを決めているのはタイミングの設計である。先にその設計とイージングを読み解き、それを支える個々のCSS機能は後半で解説する。content-visibilitycalc-size() のような見慣れない記述があっても、この時点でコードを読み切る必要はない。

開く時と閉じる時で動きの順序を変える

サンプルコードの中心はここである。開く時と閉じる時で transition の遅延を変え、高さと中身が動く順序を反転させている。以下の抜粋は主題の height / opacity / transform に絞り、後半で解説する content-visibility の行を省いている。

抜粋を読む前に、transition の各行の読み方を確認する。1行は プロパティ名 所要時間 イージング 遅延 の順で並び、時間の値が2つある場合は1つ目が所要時間、2つ目が遅延である。opacity var(--durations-normal) のように後半を省いた行では、イージングは標準の ease、遅延は 0s として扱われる。

開く時は、高さに遅延がなくすぐ伸び始める。中身は遅れてフェードインする。

details[open]::details-content {
  /* 高さは遅延なしで伸び、中身は --durations-fast だけ遅れて追従する */
  transition:
    height var(--durations-slower) var(--easings-ease-out-expo),
    opacity var(--durations-slower) var(--easings-ease-out-expo) var(--durations-fast),
    transform var(--durations-slower) var(--easings-ease-out-expo) var(--durations-fast);
}

閉じる時は逆になる。中身が即座にフェードアウトし、高さは遅れて、開く時より短い時間で収縮する。

details::details-content {
  /* 中身は遅延なしで消え、高さは 0.08s 遅れて開く時より短い時間で収縮する */
  transition:
    height var(--durations-slow) var(--easings-ease-out-expo) 0.08s,
    opacity var(--durations-normal),
    transform var(--durations-normal);
}

開く時と閉じる時で別の transition を書き分けられるのは、プロパティが変化した瞬間に、変化後の状態に適用されている transition の値が使われるためである。open 属性が付くと details[open]::details-content 側の指定で開き、外れると基底の details::details-content 側の指定で閉じる。閉じる時の動きを基底セレクタに書いているのは、この仕組みによる。

重ねて動かし、閉じる側を短くする

変数の値を当てはめて時系列に並べると以下の通りである。

開く時はheightが0〜500ms、中身が100〜600ms、閉じる時は中身が0〜200ms、heightが80〜430msで動くタイムライン

閉じる時の遅延 0.08s は、中身のフェードに使う 200ms より短い。フェードの完了を待たず、途中から高さの収縮が重なって始まる。前の動きが終わるのを待つと間延びするため、重ねて動かす。この 0.08s は式から導いた値ではなく、動かしながら合わせた調整値である。

「構造を先に動かし、中身を後から見せる」順序は、Material Designのchoreographyが示す原則に沿う。複数の要素を一度に動かさず、時間差で見せると視線が迷わない。開閉で順序を反転させると、開く時は外枠から内側へ、閉じる時は内側から外枠へと視線の流れがつながる。

閉じる側は開く側より短い。中身の退場は 200ms、高さの収縮は 350ms で、開く時の 500ms より速く片付く。画面から去る要素は注意を引く必要が薄いため、短く速く処理する方が快適になる。これはMaterial Designが退場側の時間を短くする方針とも一致する。

transition: all@keyframes を避ける

この設計には実装上の作法が2つある。ひとつは、transition にプロパティを個別に列挙することである。transition: all では全プロパティが同じ時間と遅延で動き、時間差を作る余地がない。

もうひとつは、@keyframes ではなく transition を使うことである。開閉は連打されうるが、transition なら途中で反転しても現在の値からなめらかに引き返す。@keyframes はアニメーションを毎回最初から再生する。

何もないところから現れさせない

サンプルコードは opacity だけでなく transform: translateY(-8px) を併用している。中身は少し上にずれた位置から、スライドしながらフェードインする。

現実の物体は何もないところから突然現れない。opacity の変化に小さな移動を添えると入場が自然になる。移動量は 8px と小さくとどめる。距離を大きくすると演出が前に出て、操作への反応から離れていく。transform はGPUで処理されレイアウトを再計算しないため、負荷の面でも opacity と相性がよい。一方で height のアニメーションは毎フレームのレイアウト計算を伴う。中身が大きい details では負荷に注意する。

イージングで質感を作る

イージングは、経過時間に対する進行度の曲線である。cubic-bezier(x1, y1, x2, y2) は、横軸を時間・縦軸を進行度とする平面上の2つの制御点で曲線の形を定義する。

開閉のようなUIには ease-out 系が向く。web.devは、序盤の速い ease-out が反応の良さを感じさせ、終盤の減速が自然に見えると説明する。逆に ease-in は序盤が遅く、操作への反応が鈍く感じられるためUIには向かない。

ただし、標準の ease-out キーワードは減速が弱く、着地の表情を作れない。意図を感じさせる強い減速は、カスタムカーブで作る。サンプルコードが高さに使っている ease-out-expo は、指数関数的に減速するイージングをコミュニティが名付けたもので、CSSの標準キーワードではなく cubic-bezier() で近似する。

広く使われている近似値は2つある。サンプルコードの cubic-bezier(0.16, 1, 0.3, 1)easings.net由来で、Radixやshadcnでも使われる。Robert Penner系の cubic-bezier(0.19, 1, 0.22, 1) も視覚的には近い動きをする。:root のカスタムプロパティに一ヵ所で定義し、統一して使うとよい。

サンプルコードの 500ms は、UIの開閉に使われる一般的な目安(Material Design 3のmediumトークンで250〜400ms)より長めである。ただしexpo系のイージングは動きの大半が序盤に集中するため、体感は数値ほど遅くならない。長めの時間は終盤の減速を引き延ばし、着地を柔らかく見せるために働く。時間を短くすれば機敏に、長くすれば上品に寄る。どの質感に寄せるかは、UI全体の性格に合わせて決める。

設計を支える3つのCSS機能

前半で読み解いた時間差の設計は、3つのCSS機能で実装できる。

  • ::details-content — 展開部分を直接指定する
  • interpolate-size / calc-size() — 高さ 0 から auto への補間を有効にする
  • transition-behavior: allow-discrete — 離散プロパティの切り替えをつなぐ

::details-content で展開部分を直接指定する

CSSだけで開閉を動かす場合、これまでは2つの回避策が使われてきた。

ひとつは、height の代わりに max-height0 から実際の中身より大きな推測値(例えば 1000px)へ遷移させる方法である。トランジションは推測値までの距離を基準に進むのに、見かけの動きは中身の高さで止まる。見える時間が中身の量で変わり、設計した時間どおりに動かない。

もうひとつは、grid-template-rows0fr から 1fr へ遷移させる方法である。中身の高さに追従するが、どちらの方法もアニメーション用のラッパー要素を前提とする。この方法は次の記事で扱った。

https://b.0218.jp/202301311714.html

前述のとおり、::details-contentsummary を除いた展開部分を表す擬似要素である。対象のHTMLは通常の details のままでよく、ラッパー要素を追加する必要がない。

<details>
  <summary>見出し</summary>
  <p>開閉する中身。</p>
</details>
details要素の構造。summaryを除いた展開部分が::details-contentにあたる
/* 閉じた状態 */
details::details-content {
  opacity: 0;
  transition:
    opacity 0.4s,
    content-visibility 0.4s allow-discrete;
}

/* 開いた状態 */
details[open]::details-content {
  opacity: 1;
}

閉じた状態のスタイルは基底の details::details-content に書き、開いた状態は details[open]::details-content で上書きする。

高さ 0 から auto への補間を有効にする

時間差の設計は、高さを 0 から auto まで直接アニメーションできてはじめて成立する。しかし auto はレイアウト時に決まる値であり、補間の始点と終点になる数値を持たないため、長らくアニメーションできなかった。これを解決するのが interpolate-size プロパティと calc-size() 関数である。

サンプルコードは calc-size() を使っている。

details[open]::details-content {
  height: auto;
  height: calc-size(auto, size);
}

calc-size(auto, size)auto を計算できるサイズとして扱う記法である。第2引数の size キーワードが第1引数の auto を指す。値に calc-size() を含めると interpolate-size: allow-keywords が自動で適用されるため、これだけで 0 から auto への補間が有効になる。

height: autoheight: calc-size(auto, size) を続けて書いているのはフォールバックを兼ねる。calc-size() に対応しないブラウザは2行目を無視して height: auto を採用し、アニメーションせずに開く。

単純な高さの補間だけなら、Chromeのドキュメント:rootinterpolate-size: allow-keywords を一度書き、文書全体へ継承させる方法を推奨する。calc-size() は、automax-content のような内在サイズ(intrinsic size)へ計算を加えたい場合に使う。

高さだけを縮めても中身はその場に残り、箱からはみ出して見えてしまう。サンプルコードが閉じた状態に overflow-y: clip を指定しているのはこのためである。cliphidden と違いスクロールコンテナを作らないため、はみ出しを切り取るだけで意図しないスクロールも発生しない。

離散プロパティを allow-discrete でつなぐ

CSSのプロパティには、中間値を計算できるものとできないものがある。opacity01 の間で 0.5 のような中間値を取れるため、なめらかに補間できる。一方、displaynoneblockcontent-visibilityhiddenvisible には中間の状態が存在しない。このように値を補間できず、ある瞬間に切り替わるしかないプロパティを離散プロパティ(discrete)と呼ぶ。離散プロパティは通常トランジションの対象にならず、値の変化が即座に反映される。プロパティが離散かどうかは、MDNの各プロパティのページにある「Animation type」の欄で確認できる。

これが問題になるのは閉じる時である。details を閉じると、::details-content にはUAスタイルシートによって content-visibility: hidden が設定される。content-visibility も離散プロパティであるため、何もしなければ中身は閉じた瞬間に消え、フェードアウトが見えない。

transition-behavior: allow-discrete は、離散プロパティをトランジションに参加させる指定である。切り替え自体は一瞬で起きるが、そのタイミングは原則としてトランジションの50%地点になる。ただし display: nonecontent-visibility: hidden は例外で、表示する方向では開始時に、非表示にする方向では終了時に切り替わる。この例外により、閉じる時も中身はトランジションが終わるまで表示され続け、opacitytransform のアニメーションが最後まで見える。

allow-discretetransition 全体ではなく、プロパティごとの指定の末尾に置く。サンプルコードの content-visibility var(--durations-slower) ease allow-discrete がこれにあたる。

対応ブラウザとフォールバック

前述のとおり、高さの伸縮に必要な calc-size()interpolate-size は、2026年6月時点でChromium系だけが対応する。サンプルコードが全体を @supports (interpolate-size: allow-keywords) で囲んでいるのは、この動きを対応ブラウザ向けの漸進的強化として扱うためである。未対応ブラウザでも details の開閉機能は保たれる。

実装の詳細は details 要素に畳んでおく。

対応状況と実装の詳細
機能対応状況(2026年6月時点)
::details-contentBaseline 2025。Chrome / Edge 131、Firefox 143、Safari 18.4
transition-behavior: allow-discreteBaseline 2024。主要ブラウザが対応
calc-size() / interpolate-sizeChromium系のみ。Firefox・Safariは未対応

未対応ブラウザには @supports ブロック内のスタイルが適用されない。height: auto の二重宣言と合わせて二段構えの保険になっている。

フェードだけをより広いブラウザに届ける構成もできるが、この記事では扱わない。

アクセシビリティへの配慮

サンプルコードは動きを減らす設定にも対応している。prefers-reduced-motion: reduce は、ユーザーがOSで動きの抑制を選んだ状態を表す。前庭障害やそれに伴うめまい、片頭痛への配慮として、移動を伴う動きは省く。

@media (prefers-reduced-motion: reduce) {
  details::details-content,
  details[open]::details-content {
    /* 移動系は即時化し、状態変化を伝えるフェードだけ残す */
    transition: opacity 0.2s;
  }
}

transitionopacity だけに上書きすると、高さの伸縮や transform の移動は即時に切り替わり、状態の変化を伝えるフェードだけが残る。reduce は動きの全廃ではなく、前庭刺激になりやすい移動の削減を目的とするためである。開く時はフェードインが見える。閉じる時は高さが即時に畳まれるため、見た目は即時に閉じる。抑制の指定も @supports ブロックの内側に置けば、動きを定義した範囲と過不足なく一致する。

すべての変化を即時化したい場合は、transition: none に置き換える。どちらの方式でも開閉の機能は保たれる。

まとめ

印象を左右するのは、個々の機能よりもタイミングの設計である。開く時は構造から中身へ、閉じる時は中身から構造へと順序を反転させ、閉じる側を短くする。この非対称が、開閉を操作への自然な反応に見せる。

その設計は3つの機能で実装できる。::details-content で展開部分を直接指定し、calc-size() で高さの補間を有効にする。離散プロパティは transition-behavior: allow-discrete でつなぐ。ラッパー要素は不要である。

高さの伸縮はChromium系限定の漸進的強化として扱い、フォールバックと prefers-reduced-motion を用意すれば、対応ブラウザではなめらかに、それ以外でも壊れない開閉になる。

なお、この演出は開閉がときどき起きる程度の操作だから成り立つ。コマンドパレットの開閉のように毎日100回以上繰り返す操作では、アニメーションそのものが遅延になり、付けない判断が正しくなる。

参考