開閉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 ( -8 px );
/* 閉じる時: コンテンツが先にフェードアウト → 高さ収縮 */
transition:
height var ( --durations-slow ) var ( --easings-ease-out-expo ) 0.08 s ,
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.2 s ;
}
}
} セレクタに現れる ::details-content は、summary を除いた details の展開部分を指す擬似要素である。
変数には次の値を置いた前提で読む。
:root {
--durations-slower : 500 ms ; /* 開く時の高さ展開にかける時間 */
--durations-slow : 350 ms ; /* 閉じる時の高さ収縮にかける時間 */
--durations-fast : 100 ms ; /* 開く時に中身を遅らせる時間 */
--durations-normal : 200 ms ; /* 閉じる時に中身を消す時間 */
--easings-ease-out-expo : cubic-bezier ( 0.16 , 1 , 0.3 , 1 );
} まず、このコードが作る動きをデモで確かめる。
気持ちよさを決めているのはタイミングの設計である。先にその設計とイージングを読み解き、それを支える個々のCSS機能は後半で解説する。content-visibility や calc-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.08 s ,
opacity var ( --durations-normal ),
transform var ( --durations-normal );
} 開く時と閉じる時で別の transition を書き分けられるのは、プロパティが変化した瞬間に、変化後の状態に適用されている transition の値 が使われるためである。open 属性が付くと details[open]::details-content 側の指定で開き、外れると基底の details::details-content 側の指定で閉じる。閉じる時の動きを基底セレクタに書いているのは、この仕組みによる。
重ねて動かし、閉じる側を短くする 変数の値を当てはめて時系列に並べると以下の通りである。
閉じる時の遅延 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-height を 0 から実際の中身より大きな推測値(例えば 1000px)へ遷移させる方法である。トランジションは推測値までの距離を基準に進むのに、見かけの動きは中身の高さで止まる。見える時間が中身の量で変わり、設計した時間どおりに動かない。
もうひとつは、grid-template-rows を 0fr から 1fr へ遷移させる方法である。中身の高さに追従するが、どちらの方法もアニメーション用のラッパー要素を前提とする。この方法は次の記事で扱った。
https://b.0218.jp/202301311714.html
前述のとおり、::details-content は summary を除いた展開部分を表す擬似要素である。対象のHTMLは通常の details のままでよく、ラッパー要素を追加する必要がない。
< details >
< summary >見出し</ summary >
< p >開閉する中身。</ p >
</ details > /* 閉じた状態 */
details :: details-content {
opacity: 0 ;
transition:
opacity 0.4 s ,
content-visibility 0.4 s 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: auto と height: calc-size(auto, size) を続けて書いているのはフォールバックを兼ねる。calc-size() に対応しないブラウザは2行目を無視して height: auto を採用し、アニメーションせずに開く。
単純な高さの補間だけなら、Chromeのドキュメント は :root に interpolate-size: allow-keywords を一度書き、文書全体へ継承させる方法を推奨する。calc-size() は、auto や max-content のような内在サイズ(intrinsic size)へ計算を加えたい場合に使う。
高さだけを縮めても中身はその場に残り、箱からはみ出して見えてしまう。サンプルコードが閉じた状態に overflow-y: clip を指定しているのはこのためである。clip は hidden と違いスクロールコンテナを作らないため、はみ出しを切り取るだけで意図しないスクロールも発生しない。
離散プロパティを allow-discrete でつなぐ CSSのプロパティには、中間値を計算できるものとできないものがある。opacity は 0 と 1 の間で 0.5 のような中間値を取れるため、なめらかに補間できる。一方、display の none と block、content-visibility の hidden と visible には中間の状態が存在しない。このように値を補間できず、ある瞬間に切り替わるしかないプロパティを離散プロパティ (discrete)と呼ぶ。離散プロパティは通常トランジションの対象にならず、値の変化が即座に反映される。プロパティが離散かどうかは、MDNの各プロパティのページにある「Animation type」の欄で確認できる。
これが問題になるのは閉じる時である。details を閉じると、::details-content にはUAスタイルシートによって content-visibility: hidden が設定される。content-visibility も離散プロパティであるため、何もしなければ中身は閉じた瞬間に消え、フェードアウトが見えない。
transition-behavior: allow-discrete は、離散プロパティをトランジションに参加させる指定である。切り替え自体は一瞬で起きるが、そのタイミングは原則としてトランジションの50%地点になる。ただし display: none と content-visibility: hidden は例外で、表示する方向では開始時に、非表示にする方向では終了時に切り替わる 。この例外により、閉じる時も中身はトランジションが終わるまで表示され続け、opacity や transform のアニメーションが最後まで見える。
allow-discrete は transition 全体ではなく、プロパティごとの指定の末尾に置く。サンプルコードの content-visibility var(--durations-slower) ease allow-discrete がこれにあたる。
対応ブラウザとフォールバック 前述のとおり、高さの伸縮に必要な calc-size() と interpolate-size は、2026年6月時点でChromium系だけが対応する。サンプルコードが全体を @supports (interpolate-size: allow-keywords) で囲んでいるのは、この動きを対応ブラウザ向けの漸進的強化として扱うためである。未対応ブラウザでも details の開閉機能は保たれる。
実装の詳細は details 要素に畳んでおく。
対応状況と実装の詳細 未対応ブラウザには @supports ブロック内のスタイルが適用されない。height: auto の二重宣言と合わせて二段構えの保険になっている。
フェードだけをより広いブラウザに届ける構成もできるが、この記事では扱わない。
アクセシビリティへの配慮 サンプルコードは動きを減らす設定にも対応している。prefers-reduced-motion: reduce は、ユーザーがOSで動きの抑制を選んだ状態を表す。前庭障害やそれに伴うめまい、片頭痛への配慮として、移動を伴う動きは省く。
@media (prefers-reduced-motion: reduce) {
details :: details-content ,
details [ open ]:: details-content {
/* 移動系は即時化し、状態変化を伝えるフェードだけ残す */
transition: opacity 0.2 s ;
}
} transition を opacity だけに上書きすると、高さの伸縮や transform の移動は即時に切り替わり、状態の変化を伝えるフェードだけが残る。reduce は動きの全廃ではなく、前庭刺激になりやすい移動の削減を目的とするためである。開く時はフェードインが見える。閉じる時は高さが即時に畳まれるため、見た目は即時に閉じる。抑制の指定も @supports ブロックの内側に置けば、動きを定義した範囲と過不足なく一致する。
すべての変化を即時化したい場合は、transition: none に置き換える。どちらの方式でも開閉の機能は保たれる。
まとめ 印象を左右するのは、個々の機能よりもタイミングの設計である。開く時は構造から中身へ、閉じる時は中身から構造へと順序を反転させ、閉じる側を短くする。この非対称が、開閉を操作への自然な反応に見せる。
その設計は3つの機能で実装できる。::details-content で展開部分を直接指定し、calc-size() で高さの補間を有効にする。離散プロパティは transition-behavior: allow-discrete でつなぐ。ラッパー要素は不要である。
高さの伸縮はChromium系限定の漸進的強化として扱い、フォールバックと prefers-reduced-motion を用意すれば、対応ブラウザではなめらかに、それ以外でも壊れない開閉になる。
なお、この演出は開閉がときどき起きる程度の操作だから成り立つ。コマンドパレットの開閉のように毎日100回以上繰り返す操作では、アニメーションそのものが遅延になり、付けない判断が正しくなる。
参考