Dialog と Popover #6
Intro
ついに <toast>
-> <popup>
-> popup
-> popover
として、要素から属性になり名前も決まった。
とはいえ実装は popup
とそんなに変わらないので、 popup
でやっていた Origin Trials を継続しながら、徐々に実装を進めていくフェーズが 2022/12 くらいの話だ。
- Intent to extend Origin Trial: Popover API
しかし、 popover
を実用するには足りていない議論があった、それが Animation だ。
Display and content-visibility animations
<dialog>
や popover
された要素は、基本的には現れたり消えたりするので、それをアニメーションするには、 display
/ content-visibility
の属性をアニメーションする必要がある。
まずは消す方を以下のように transition
でやってみよう。 opacity
を徐々に薄くし、最後に display: none
する実装は以下のようになる。
div {
opacity: 1;
display: block;
}
/* transition で実装 */
.fade-out {
opacity: 0;
display: none;
transition: opacity 2s, display 2s;
}
/*アニメーションで実装 */
.fade-out {
animation: fade-out 0.5s forwards;
}
@keyframes fade-out {
100% {
opacity: 0;
display: none;
}
}
この .fade-out
class を追加したら、要素がゆっくり消える想定だが、実際には追加した瞬間に消えてしまう。これは display
がもともと not animatable だったからだ。
CSS のアニメーションでは、属性に対して 4 つの Animation Type が決まっている。
- not animatable
- discrete
- by computed value
- repeatable list
not animatable は「アニメーションすると複雑すぎるため transition
や keyframe
に指定されても無視する」というものだ。
つまり、要素をふわっと消したい場合は、代わりに visibility
を用いる、 height: 0
にして潰す、 left: -1000px
などで画面外に飛ばす、アニメーションが終わったら JS で消す、といった実装が必要だった。
ところが、 <dialog>
や popover
は、その表示非表示を display
の変更で行っているため、アニメーションが複雑になる。そこで、 display
も animatable にしようという議論が始まったのだ。
- Intent to Ship: Display and content-visibility animations
- [css-display] Why is display listed as not animatable instead of animation type: discrete? · Issue #6429 · w3c/csswg-drafts
- rendering/entry-exit-animations.md at master · chrishtr/rendering
これによって、CSS Display Module Level 4 から display
の Animation Type は not animatable ではなく discrete に近いが、少し特別扱いされる値となった。
- rendering/entry-exit-animations.md at master · chrishtr/rendering
例えば opacity
の Animation Type は by computed value で、 0 から 1 の間をスライダーを動かすように連続した変化をさせる。一方、 true/false, enable/disable といった値は discrete (離散値) というカテゴリになる。
discrete な値を transition
する場合は、真ん中(50%)で値が切り替わる挙動になるため、 opacity
が 0.5 まで減ったところで、 display: none
になって要素がぱっと消えてしまうことになる。
しかし、実際に欲しいのは opacity: 0
になった後に、 display: none
/ content-visibility: hidden
に変化し消える挙動だ。
この変更を利用するためには、 keyframe
をそのままに、transition
の対象プロパティに allow-discrete
をつける。
div {
opacity: 1;
display: block;
}
/* transition で実装 */
.fade-out {
opacity: 0;
display: none;
transition: opacity 2s, display 2s allow-discrete;
}
/* animation で実装 */
.fade-out {
animation: fade-out 0.5s forwards;
}
@keyframes fade-out {
100% {
opacity: 0;
display: none;
}
}
fade-in させる時は少し考えることが増える。
単純に、 display: none
を block
にしてから, opacity: 0
を 1
に遷移させればよさそうだ。
div {
opacity: 0;
display: none;
}
/* transition で実装 */
.fade-in {
opacity: 1;
display: block;
transition: opacity 2s, display 2s allow-discrete;
}
/* animation で実装 */
.fade-out {
animation: fade-out 0.5s forwards;
}
ところが、これでは display
が変わった瞬間に要素が opacity
を持つため、それが「0 である」という初期値が計算される前に、 opacity
が 1 になってしまうため、想定した挙動にならない。
ここでは、 display
が切り替わる時は、アニメーションする値の初期値を要素に反映し、その上で変化をさせる必要がある。以前はこれを「一旦 display: block
に変えたら、 requestAnimationFrame()
で opacity: 0
が計算されるのを待ってから、 opacity: 1
に遷移させる」や「getBoundingClientRect()
で値の計算を確定させる処理を一旦呼んでから遷移させる」といった実装が必要だった。
ところが <dialog>
や popover
も、出現時に display
が変わっているため、同じ問題が起こってしまう。せっかく Declarative に定義したのに、そのアニメーションに JS が必須となるのは不便だ。
@starting-style
この問題を解決するため、「初期値を定義するための仕様」が提案された。
- [selectors-4] Add syntax to establish before-change style for css-transitions on new elements. · Issue #8174 · w3c/csswg-drafts
名前は紆余曲折を経て @starting-style
というプロパティで書くことになった。
div {
display: none;
opacity: 0;
}
/* transition で実装 */
.fade-in {
opacity: 1;
display: block;
transition: opacity 2s, display 2s allow-discrete;
@starting-style {
/* display が切り替わった時点での初期値を指定 */
opacity: 0;
}
}
/* animation で実装 */
.fade-in {
animation: fade-in 0.5s forwards;
display: block;
@starting-style {
/* display が切り替わった時点での初期値を指定 */
opacity: 0;
}
}
@keyframes fade-in {
100% {
opacity: 1;
display: block;
}
}
これで、要素のスタイルが計算される前の値を、明示的に示すことができるようになった。
Overlay
ここまでで、 <dialog>
を表示する際の opacity
と display
はアニメーションすることができた。しかし、これでは「要素がいつ Top Layer から消えるのか」は特に制御していない。
では、前述のアニメーションのなかで Top Layer にいつ表示されているのかを見てみると、以下のようになる。
- Show: Top Layer に追加され、同時にトランジションが行われ徐々に表示される。
- Close: Top Layer から除去され、同時にトランジションが行われ徐々に非表示される。
これは内部的に「Top Layer に表示されている」という状態があり、それが先ほどの display のように、離散値としてアニメーションしているからだ。ところが、特に Close する際のアニメーションは、 opacity
のトランジションが終わってから Top Layer から消えて欲しい場合が多い。
そこで、同じように allow-discrete
を設定したいわけだが、そもそも「Top Layer に表示されるかどうか」を制御する CSS のプロパティ自体は存在しない。そこで策定されたのが overlay
だ。
overlay
は Top Layer に表示されている場合を示す auto
と、そうではない none
がある。しかし、この値は CSS で明示的に指定できるわけでは無く、ブラウザが内部的に設定しており、 show()
などの裏で自動的に設定されるのだ。
しかし、このプロパティを transition
に指定することは可能だ。 allow-discrete
を合わせて指定すれば、ブラウザが内部でこの離散値のトグルをするタイミングを制御できる。
.fade-out {
opacity: 0;
display: none;
transition: opacity 1s,
overlay 1s allow-discrete,
display 1s allow-discrete;
}
これにより「トランジションが終わってから Top Layer から消える」というアニメーションを書くことができるようになるのだ。
overlay
はあくまで、ブラウザが管理し内部に隠していたプロパティを、 transition
に指定できるように条件付きでエクスポートしたような格好になっている。
Prefer Reduced Motion
<dialog>
/ popover
とは直接関係ないが、 prefer-reduced-motion
が指定されていたら、アニメーションは無効にしたい。
@media(prefers-reduced-motion: no-preference) {
.fade-in, .fade-out {
transition: none;
}
}
Outro
これで、 Dialog / Popover をアニメーションさせるためのプリミティブが揃った。
以降はより実践的な実装方法について解説していく。
DEMO
動作する DEMO を以下に用意した。
- Dialog Animation DEMO | labs.jxck.io