display: none で隠しておいた要素をボタンで表示したとき、transition を書いているのに一瞬でパッと現れてしまった経験はないでしょうか。これは書き方の間違いではなく、CSS が長いあいだ苦手としてきた部分です。この記事では、その原因を整理したうえで、表示が始まる瞬間のスタイルを指定する @starting-style と、display のようなプロパティをトランジションの対象にする transition-behavior: allow-discrete の使い方を、動かせるデモ付きで解説します。popover や dialog をふわっと開くときに必要になる overlay にも触れます。
目次
display: none からの表示がアニメーションしない理由
まずは素直に書いたコードを見てみます。閉じているときは display: none、開いているときは display: block にして、opacity をトランジションさせる、という書き方です。
.panel {
display: none;
opacity: 0;
transition: opacity 0.4s ease;
}
/* クラスを付けると表示される……が、フェードインしない */
.panel.is-open {
display: block;
opacity: 1;
}
これを実行すると、パネルは opacity: 1 の状態でいきなり表示されます。理由は2つあります。
1つ目は、display には中間の値が存在しないことです。opacity なら 0 と 1 の間に 0.5 という値がありますが、none と block の間に「半分だけ none」はありません。このような、値と値のあいだを計算で埋められないプロパティを離散プロパティと呼びます。離散プロパティは既定ではトランジションの対象にならず、値が変わった瞬間に切り替わります。つまり display: none が外れた時点で要素はもう完全に表示された状態になっています。
2つ目は、表示され始めた瞬間の要素には「変更前の状態」が無いことです。トランジションは「直前の値」と「今の値」を比べ、その差を時間をかけて埋める仕組みです。ところが display: none だった要素や、たった今 JavaScript で DOM に追加された要素は、ブラウザーがスタイルを計算するのがそのときが初めてなので、比べる相手がありません。比べる相手がない以上、差を埋めるアニメーションも発生しません。
この2つを解決するのが、これから説明する @starting-style と transition-behavior: allow-discrete です。前者が「変更前の状態」を与え、後者が display の切り替えを待たせます。
@starting-style で表示が始まる瞬間のスタイルを指定する
@starting-style は、要素が表示され始めるときに使われる「出発点」のスタイルを書くためのアットルールです。ブラウザーは比べる相手を持っていませんが、こちらから「この状態から始めてください」と渡してやれば、そこから通常のスタイルへ向かってトランジションできるようになります。
出発点として使われるのは、次の場面です。要素が DOM に最初に現れたとき、display: none から表示される値に変わったとき、そして popover や dialog がトップレイヤーに追加されたときです。逆に、すでに表示されている要素の状態が変わるだけのとき(ホバーやクラスの付け替えなど)は使われません。その場合は普通に直前の値が残っているので、そもそも必要がないからです。
書き方は2通りある
1つは、@starting-style のブロックの中にセレクターごと書く形です。どのルールに対する出発点なのかが分かるように、同じセレクターを繰り返します。
/* 表示されている状態(=到着点) */
.panel.is-open {
opacity: 1;
}
/* 表示が始まる瞬間の状態(=出発点) */
@starting-style {
.panel.is-open {
opacity: 0;
}
}
もう1つは、ルールの中に直接ネストして書く形です。セレクターを書かずに宣言だけを並べます。対応関係が目で追いやすいので、こちらのほうが読みやすいと感じる場面も多いはずです。
.panel.is-open {
opacity: 1;
/* このルールに対する出発点をネストで書く */
@starting-style {
opacity: 0;
}
}
どちらの書き方でも動作は同じです。ただし、セレクターを含む形を使うときは元のルールより後に書く必要があります。@starting-style の中のルールは元のルールと詳細度が同じになるため、先に書いてしまうと後から来た元のルールに上書きされ、出発点が到着点と同じ値になってしまうからです。同じ値どうしではトランジションは起こらないので、「書いたのに何も起きない」という結果になります。
transition-behavior: allow-discrete で display を切り替える
@starting-style だけでは、まだ display: none から始まるフェードインは完成しません。display が離散プロパティで、値が変わった瞬間に切り替わってしまうからです。ここで使うのが transition-behavior プロパティです。
| transition-behavior の値 | 効果 |
|---|---|
normal | 初期値。離散プロパティのトランジションは開始されず、値は即座に切り替わる |
allow-discrete | 離散プロパティのトランジションを開始する。display や overlay も対象にできる |
指定の仕方も2通りあります。1つは transition-behavior を単独で書く方法で、transition-property に並べたすべてのプロパティに適用されます。もう1つは transition ショートハンドの中に allow-discrete というキーワードを混ぜる方法で、こちらはそれを書いたプロパティにだけ効きます。
/* 書き方1: ショートハンドの中に allow-discrete を混ぜる(display だけに効く) */
.panel {
transition: opacity 0.4s ease, display 0.4s allow-discrete;
}
/* 書き方2: 単独のプロパティで指定する(並べた全部に効く) */
.panel {
transition-property: opacity, display;
transition-duration: 0.4s;
transition-behavior: allow-discrete;
}
気になるのは「補間できないプロパティをトランジションさせると、途中はどうなるのか」という点です。原則として、離散プロパティはトランジションの 50% の時点で新しい値に切り替わります。ただし display と content-visibility は特別扱いで、表示される値へ向かうときは 0% の時点で、none へ向かうときは 100% の時点で切り替わります。つまりどちら向きのときも、アニメーションしているあいだは要素が見えている状態が保たれる、というわけです。これがあるおかげで、フェードインもフェードアウトも最後まで見えます。
ボタンでフェードインするパネルを作る
ここまでの3つ(通常のスタイル、@starting-style、allow-discrete)を組み合わせると、display: none からのフェードインが書けます。下のデモのボタンを押して、表示と非表示の両方が滑らかに動くことを確かめてみてください。JavaScript がやっているのはクラスの付け外しだけです。
<button id="toggle" class="toggle" type="button">パネルを表示する</button>
<div id="panel" class="panel">
<h3>フェードインするパネル</h3>
<p>display: none の状態からでも、@starting-style と allow-discrete でふわっと表示できます。</p>
</div>
.panel {
/* 閉じているときは display: none で完全に消す */
display: none;
opacity: 0;
translate: 0 -10px;
/* display も一緒にトランジションの対象にする(allow-discrete が必要) */
transition: opacity 0.4s ease, translate 0.4s ease, display 0.4s allow-discrete;
}
/* 表示中の状態(=トランジションの終着点) */
.panel.is-open {
display: block;
opacity: 1;
translate: 0 0;
}
/* 表示され始める瞬間の状態(=トランジションの出発点) */
@starting-style {
.panel.is-open {
opacity: 0;
translate: 0 -10px;
}
}
/* 以下は見た目を整えるためのスタイル */
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 20px;
}
.toggle {
background-color: #1b1f24;
color: #ffffff;
border: 0;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.panel {
margin-top: 16px;
padding: 16px 20px;
border: 1px solid #dde1e6;
border-radius: 10px;
background-color: #f7f9fb;
}
.panel h3 { margin: 0 0 8px; font-size: 16px; }
.panel p { margin: 0; font-size: 14px; line-height: 1.7; }
const button = document.getElementById('toggle');
const panel = document.getElementById('panel');
button.addEventListener('click', () => {
// クラスを付け外しするだけ。あとは CSS がフェードを担当する
const opened = panel.classList.toggle('is-open');
button.textContent = opened ? 'パネルを隠す' : 'パネルを表示する';
});
閉じるときについて補足しておきます。@starting-style が使われるのは表示が始まるときだけですが、閉じるときは .is-open が外れて .panel の宣言(opacity: 0 と display: none)に戻るだけなので、通常のトランジションとして処理されます。display: none が適用されるのは 100% の時点なので、フェードアウトは最後まで見えます。つまり出発点を補ってやる必要があるのは表示側だけ、と考えると理解しやすくなります。
popover や dialog を開くときは overlay も指定する
popover 属性を付けた要素や、showModal() で開いた <dialog> は、開いているあいだトップレイヤーという特別な層に移動します。ページのどの要素よりも手前に描かれる層で、z-index の指定なしに最前面へ出られるのはこの仕組みのおかげです。
ここで問題になるのが、閉じたときにトップレイヤーからすぐに取り除かれてしまうことです。フェードアウトの途中で層から外れると、他の要素の下に潜り込んで見た目が乱れたり、アニメーションが最後まで見えなかったりします。これを防ぐのが overlay プロパティです。
overlay は「その要素が実際にトップレイヤーに描画されているかどうか」を表すプロパティで、値を CSS から設定することはできません。作者ができるのはトランジションの対象に指定することだけです。allow-discrete と組み合わせて指定しておくと、トップレイヤーからの除去がトランジションの終わりまで先送りされます。
次のデモは popover の例です。開いている状態は :popover-open 疑似クラスで表し、display と overlay の両方をトランジションの対象にしています。背景(::backdrop)にも同じ考え方をそのまま適用できます。
<button popovertarget="menu" class="toggle" type="button">ポップオーバーを開く</button>
<div id="menu" popover class="menu">
<h3>お知らせ</h3>
<p>popover は開いた瞬間にトップレイヤーへ移動します。overlay も allow-discrete にすると、閉じるときのフェードも最後まで見えます。</p>
<button popovertarget="menu" popovertargetaction="hide" class="close" type="button">閉じる</button>
</div>
.menu {
opacity: 0;
scale: 0.94;
/* display と overlay も含めてトランジションの対象にする */
transition: opacity 0.3s ease, scale 0.3s ease, display 0.3s, overlay 0.3s;
/* 離散プロパティ(display / overlay)の切り替えを最後まで待たせる */
transition-behavior: allow-discrete;
}
/* 開いている状態 */
.menu:popover-open {
opacity: 1;
scale: 1;
}
/* 開き始める瞬間の状態 */
@starting-style {
.menu:popover-open {
opacity: 0;
scale: 0.94;
}
}
/* 背景も同じ考え方でフェードさせられる */
.menu::backdrop {
background-color: rgb(0 0 0 / 0%);
transition: background-color 0.3s ease, display 0.3s allow-discrete, overlay 0.3s allow-discrete;
}
.menu:popover-open::backdrop {
background-color: rgb(0 0 0 / 40%);
}
@starting-style {
.menu:popover-open::backdrop {
background-color: rgb(0 0 0 / 0%);
}
}
/* 以下は見た目を整えるためのスタイル */
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 20px;
}
.menu {
width: min(320px, 80vw);
padding: 20px;
border: 1px solid #dde1e6;
border-radius: 10px;
background-color: #ffffff;
}
.menu h3 { margin: 0 0 8px; font-size: 16px; }
.menu p { margin: 0 0 14px; font-size: 14px; line-height: 1.7; }
.toggle, .close {
background-color: #1b1f24;
color: #ffffff;
border: 0;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
<dialog> でも書き方はほとんど変わりません。開いている状態を表すのが [open] 属性セレクターになる点だけが違います。閉じているあいだはブラウザーが display: none を当てているので、やはり display をトランジションの対象に含める必要があります。
dialog {
opacity: 0;
transition: opacity 0.3s ease, display 0.3s allow-discrete,
overlay 0.3s allow-discrete;
}
/* 開いている状態 */
dialog[open] {
opacity: 1;
}
/* 開き始める瞬間の状態(元のルールより後に書く) */
@starting-style {
dialog[open] {
opacity: 0;
}
}
思ったようにアニメーションしないときに見直すところ
この3つの機能は互いに噛み合って動くので、どれか1つ欠けると「何も起きない」「片方向だけ動く」といった結果になります。症状ごとに見ていきます。
@starting-style を元のルールより前に書いている
表示側だけまったくアニメーションしない場合、まず疑うのが記述順です。前述のとおり @starting-style の中のルールは元のルールと詳細度が同じなので、ファイルの上のほうに書いてしまうと後から来た元のルールが勝ち、出発点と到着点が同じ値になります。CSS を整理するときに @starting-style だけまとめて上に移動してしまう、というのがよくある崩れ方です。必ず対応する元のルールの後ろに置いてください。
transition に display を含めていない
@starting-style を正しく書いても、transition の対象が opacity だけだと display は即座に切り替わります。表示側は一応フェードインして見えますが、閉じるときにアニメーションが一切見えず消えるのが特徴的な症状です。display: none が即座に適用されて要素が消えてしまうためです。transition のリストに display を足し、allow-discrete を付けたか確認してください。
allow-discrete を書き忘れている
transition: opacity 0.4s, display 0.4s; のように display を並べただけでは効果がありません。離散プロパティは既定でトランジションの対象外なので、allow-discrete を書かないかぎり display の指定は無視されるのと同じ結果になります。ショートハンドに混ぜる書き方をしている場合は、対象にしたいプロパティごとに allow-discrete を添える必要がある点にも注意してください。
popover や dialog で閉じるときだけ乱れる
開くときは滑らかなのに、閉じるときだけ一瞬で消えたり、他の要素の後ろに回り込んで見えたりする場合は、overlay をトランジションの対象に入れ忘れています。トップレイヤーからの除去が先に起きてしまうためで、overlay 0.3s allow-discrete を transition に追加すると解決します。display と overlay はセットで書く、と覚えておくと安全です。
要素を JavaScript で追加した直後にクラスを付けている
@starting-style が無かった頃は、要素を追加してから requestAnimationFrame や setTimeout を挟んでクラスを付ける、という回避策がよく使われていました。@starting-style を導入したあともこの遅延処理が残っていると、意図しないタイミングでスタイルが当たって動きがちらつくことがあります。出発点を CSS 側で持てるようになったので、JavaScript は要素の追加やクラスの付け外しだけに専念させるほうが素直です。
対応していない環境での見え方を整える
@starting-style と transition-behavior は、主要なブラウザーの最新版であれば利用できます。ただし少し前のバージョンが残っている環境では、@starting-style のブロックごと読み飛ばされ、display の切り替えも即座に行われます。このときアニメーションは付きませんが、表示と非表示の切り替えそのものは従来どおり動くので、機能が壊れるわけではありません。演出として割り切るなら、そのままでも実害は出にくい部類です。
古い環境でもフェードは見せたい、という場合は display ではなく visibility を使う昔ながらの方法を土台に置きます。visibility はトランジションの途中では表示側の値が使われる扱いになっているため、opacity と一緒に指定するとフェードアウトの終わりまで要素を見せておけます。
/* 1. 土台: visibility と opacity だけで書く */
.panel {
visibility: hidden;
opacity: 0;
transition: opacity 0.4s ease, visibility 0.4s;
}
.panel.is-open {
visibility: visible;
opacity: 1;
}
/* 2. 対応ブラウザーでは display に切り替える */
@supports (transition-behavior: allow-discrete) {
.panel {
display: none;
visibility: visible;
transition: opacity 0.4s ease, display 0.4s allow-discrete;
}
.panel.is-open {
display: block;
}
@starting-style {
.panel.is-open {
opacity: 0;
}
}
}
visibility: hidden は要素の場所を空けたまま見えなくするだけなので、display: none のようにレイアウトから取り除かれるわけではありません。土台側でも余白が空いたままになる点は、position: absolute を併用するなどして調整してください。@supports の条件は「そのプロパティにその値を書けるか」を試すものなので、(transition-behavior: allow-discrete) のように実際に使える形で書きます。
まとめ
display: none からの表示がアニメーションしなかったのは、display に中間の値が無いことと、表示され始めた瞬間には比べる相手となる「変更前の状態」が無いことが原因でした。@starting-style は後者を解決し、要素が最初に現れるときやトップレイヤーに追加されるときの出発点を CSS から与えられるようにします。書き方はブロックの中にセレクターごと書く形とルールにネストする形の2通りで、前者を使うときは元のルールより後に置くことが条件です。
前者を解決するのが transition-behavior: allow-discrete です。離散プロパティをトランジションの対象に加えるもので、display は表示側へ向かうとき 0% の時点、none へ向かうとき 100% の時点で切り替わるため、どちらの向きでもアニメーションが最後まで見えます。popover や <dialog> では、これに overlay を加えてトップレイヤーからの除去を遅らせると、閉じるときの動きまで滑らかになります。
まずは手元のモーダルやドロップダウンで、transition に display と allow-discrete を足し、@starting-style で出発点を1行書くところから試してみてください。JavaScript のタイマーで無理やり組んでいた開閉アニメーションが、CSS だけで素直に書けるようになります。