零弐壱蜂

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

導入

開閉UIの印象はタイミングで変わる。アコーディオンや details 要素は、瞬時に切り替わるだけでも機能は満たす。だが、高さがなめらかに伸び、中身が少し遅れて現れると、押した操作への反応が見えやすくなる。

この差は、高さと中身を動かすタイミングで作る。同じ時間でまとめて動かさず、別のタイミングと別の長さで動かし、開く時と閉じる時で順序を反転させる。この記事では、個々のCSS機能より、この時間差の組み方を扱う。

先にデモで、開く時と閉じる時の順序、時間差、イージングを整理する。後半では、同じ見せ方をCSSだけで実装する例を扱う。

デモでは、外枠と中身がどの順序で動くかを見る。

以降のコードでは、時間とイージングを次の変数で表す。

: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);
}

タイミング設計

開く時と閉じる時で動きの順序を変える。以下の抜粋では、時間差の核である height / opacity / transform だけを取り上げ、後半で解説する content-visibility の行を省いている。

ここでは transition の所要時間と遅延を見る。時間の値が2つある場合は、1つ目が所要時間、2つ目が遅延である。遅延を省いた行では、遅延は 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のduration and easingが示す、退場側の時間を短くする方針とも一致する。

見え方の調整

中身には小さな移動を足す。この実装では opacity だけでなく transform: translateY(-8px) を併用している。中身は少し上にずれた位置から、スライドしながらフェードインする。

opacity の変化に小さな移動を添えると、入場の唐突さが減る。移動量は 8px と小さくとどめる。距離を大きくすると演出が前に出て、操作への反応から離れていく。

イージング

イージングは止まり方を決める。ここでは曲線の定義より、操作直後にすばやく反応し、終端で減速して柔らかく止まるかを確認する。

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

この実装では、標準の ease-out より強く減速する cubic-bezier(0.16, 1, 0.3, 1) を使う。easings.neteaseOutExpo に近い曲線で、動きの大半が序盤に集中する。500ms は数値だけ見ると長めだが、終盤の減速を引き延ばすため、体感は数値ほど遅くならない。

実装

ここからはCSSで実装する例である。ここまでのタイミング設計を details 要素に落とし込む。CSSだけで実装する場合は、高さ auto を補間することと、閉じる瞬間に中身を消さないことが課題になる。

サンプルでは、開く時と閉じる時で transition を分ける。高さの伸縮、中身のフェード、移動量、動きを減らす設定も同じ場所にまとめる。

コードは次の通りである。

@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;
    }
  }
}

実装上の注意

transition: all@keyframes は避ける。この時間差を保つには、実装で避けたい指定が2つある。ひとつは transition: all である。全プロパティが同じ時間と遅延で動き、時間差を作る余地がない。

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

このコードでは、CSS機能ごとに次の役割を持たせている。

  • ::details-content で展開部分を直接動かす
  • calc-size() で外枠を伸縮させる
  • allow-discrete で閉じる時に中身を残す

展開部分

外枠と中身の時間差を作るには、summary ではなく展開部分だけを動かす必要がある。::details-contentsummary を除いた展開部分を表すため、通常の details のまま、ラッパー要素なしで中身にスタイルを当てられる。

閉じた状態のスタイルは基底の details::details-content に書き、開いた状態は details[open]::details-content で上書きする。こう分けると、閉じる時と開く時で別の transition を使える。

外枠の伸縮

時間差の設計をCSSだけで表すには、高さを 0 から auto まで直接アニメーションできる必要がある。この例では指定を details の展開部分の height だけに閉じ込めたいので、値の側で指定できる calc-size() を使う。複数の要素やプロパティで内在サイズキーワードを補間したい場合は、interpolate-size: allow-keywords をスコープ指定する方が向く。

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

calc-size(auto, size)auto を計算できるサイズとして扱う記法である。height: autoheight: calc-size(auto, size) を続けて書いているのは、値単位のフォールバックを兼ねる。ただし、このサンプルでは外側の @supports によって、未対応ブラウザには開閉演出全体を適用しない。

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

閉じる時の表示

閉じる時は、中身をフェードアウトさせる間だけ表示したままにする必要がある。details を閉じると、::details-content にはUAスタイルシートによって content-visibility: hidden が設定される。何もしなければ中身は閉じた瞬間に消え、フェードアウトが見えない。

この切り替えを遅らせるために allow-discrete を使う。content-visibility のように中間状態を持たないプロパティは離散プロパティ(discrete)であり、通常は値が即座に切り替わる。transition-behavior: allow-discrete を指定すると、離散プロパティもトランジションのタイミングに参加できる。

display: nonecontent-visibility: hidden は例外的に、非表示にする方向ではトランジションの終了時に切り替わる。この例外により、閉じる時もフェードアウトが終わるまで中身が表示され、opacitytransform のアニメーションが見える。サンプルコードの content-visibility var(--durations-slow) ease allow-discrete が閉じる時の指定である。

対応範囲

CSSだけで実装する場合、対応範囲は主に高さの伸縮で決まる。高さの伸縮に必要な calc-size()interpolate-size は、2026年6月時点でChromium系だけが対応する。未対応ブラウザでも標準の details として内容を開閉できるなら、この実装は追加演出として使える。

対応状況の内訳は details 要素に畳んでおく。

対応状況と実装の詳細
機能対応状況(2026年6月時点)
::details-contentBaseline 2025。Chrome / Edge 131、Firefox 143、Safari 18.4
transition-behavior: allow-discreteBaseline 2024。ただし content-visibility の離散遷移はFirefox未対応
calc-size() / interpolate-sizeChromium系のみ。Firefox・Safariは未対応

未対応ブラウザには @supports ブロック内のスタイルが適用されない。主なフォールバックはこの分岐であり、height: auto の二重宣言は値単位の保険である。

フェードだけをより広いブラウザに届ける構成もあるが、このCSS例の主題から外れるため扱わない。

アクセシビリティ

開閉アニメーションを入れるなら、動きを減らしたい人の設定を反映する。prefers-reduced-motion: reduce では、高さの伸縮や transform の移動を即時化し、開く時のフェードだけを残す。

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

reduce の目的は、前庭刺激になりやすい移動を減らすことである。閉じる時は高さが即時に畳まれるため、見た目もすぐ閉じる。

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

まとめ

開閉の印象は、個々の機能よりタイミングで決まる。開く時は外枠から中身へ、閉じる時は中身から外枠へと順序を反転させ、閉じる側を短くする。こうすると、開閉が押した操作への反応として見えやすい。

今回のCSS実装例では、::details-content で展開部分を直接指定し、calc-size() で高さを補間し、allow-discrete で閉じる時に中身を残す。この組み合わせなら、ラッパー要素なしでも設計した順序を実装できる。

このCSS実装例は、Chromium系向けのprogressive enhancementとして扱う。未対応環境では標準の details の開閉を保つ。見せ方自体は、必要ならJSなど別の手段でも実装できる。

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

参考