Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

CSS: 確認ダイアログをCSSだけでアニメーションするときの落とし穴(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

CSS: 確認ダイアログをCSSだけでアニメーションするときの落とし穴(翻訳)

前回の確認ダイアログ記事のアニメーションを完璧に仕上げるのに随分長い間手こずってしまいました。

Chromeのアニメーション開始・終了に関する以下のドキュメントはいたって簡単に思えました。「オープン」「表示開始」「表示終了」の3つのステートを定義するだけで済み、CSSのブロックもたった3つです。

参考: スムーズな開始と終了のアニメーションを実現する 4 つの新しい CSS 機能  |  Blog  |  Chrome for Developers

ダイアログを開くときのアニメーションはさくっと動いたのですが、ダイアログを閉じるときのアニメーションは散々でした。

アニメーションの途中でダイアログの幅がめいいっぱい広がってしまい、閉じるまでにダイアログがあちこち飛び回る有り様でした。
ダイアログが閉じた後の背景も元に戻らなかったり、かと思えばダイアログがフェードアウトしている途中で背景が一瞬消えてしまったりと、期待を裏切られる一方でした。

私が踏んださまざまな問題とそれぞれの解決方法について詳しく解説したいと思います。これは未来の自分を助けるためでもあるとともに、ネイティブのダイアログをアニメーションしようとした人はことごとく同じ問題を踏むのではないかと思ったからです。

🔗 問題1

最初にしくじったのは、@starting-styleの置き場所を間違えたことでした。
Chromeのドキュメントでは別のブロックに書かれていたのですが、ベースとなるdialogセレクタの内側にネストしてみたところ、アニメーションはピクリとも動きませんでした。

dialog {
  opacity: 1;
  scale: 1;
  transition: /* ... */

  @starting-style {
    opacity: 0;
    scale: 0.95;
  }
}

問題は、この@starting-styleは「要素が特定のステートになったときにアニメーションを"どの位置から"開始するか」を定義するということです。つまり、@starting-styleを単にベースセレクタの内側に配置してしまうと、今どんなステートなのかを認識できなくなってしまいます。
ここでは以下のようにdialog[open]の内側に配置しなければなりません

dialog[open] {
  opacity: 1;
  scale: 1;

  @starting-style {
    opacity: 0;
    scale: 0.95;
  }
}

dialog {
  transition: /* ... */
  opacity: 0;
  scale: 0.95;
}

@starting-styleという名前もわかりにくいですね。"Starting style"という英語は「ここで指定したものは常に初期スタイルとなる」かのように読めます。
しかし実際には「この要素は"指定したステートになったときに"初期スタイルとなる」という条件付きの意味なのです!

これでダイアログを表示するときのアニメーションは動くようになりましたが、まだダイアログを閉じるときのアニメーションが壊れたままです...

🔗 問題2

ダイアログを閉じると、アニメーションが終了するまでダイアログがビューポートの幅いっぱいに広がってしまいます。
しばらく呆然としていましたが、気を取り直してトランジションをスローダウンしてみることを思いつきました。タイミングの値を10倍にすれば、何が起きているかをフレーム単位で目視確認できます。

スローモーションにしたことで問題が判明しました。以下は、当初dialog[open]に設定していたスタイルです。

dialog[open] {
  @apply flex w-full flex-col;
  @apply max-w-[calc(100vw-32px)];

  @variant sm {
    @apply max-w-md;
  }
}

しかしこの書き方だと、閉じるアニメーションの途中でステートが変わって[open]が削除された途端に、これらの制約が消えてしまいます。フェードアウトが完了していない状態でダイアログのmax-w-mdが消えてしまい、ダイアログの幅がめいいっぱい広がってしまいます。

これを修正するには、レイアウト用の共通プロパティを以下のようにベースセレクタの内側に配置します。つまり、ステートが変わったときにアニメーション用のプロパティだけが変更されるようにします。

dialog {
  @apply flex w-full flex-col;
  @apply max-w-[calc(100vw-32px)];

  @variant sm {
    @apply max-w-md;
  }

  /* アニメーションプロパティをここに書く */
}

わかってしまえば簡単ですが、openとclosedの表示をまったく異なるステートと考えていると見落としがちです。ダイアログは、アニメーションの実行中も含めたライフサイクル全体で構造を維持する必要があります。

🔗 問題3

次の問題はダイアログの背景です。
ダイアログを表示するときの背景スタイルは期待通りにフェードインするのですが、ダイアログを閉じても背景の色が元に戻らなかったり、ダイアログを閉じるときのアニメーションが完了する前にいきなり背景の色が消えてしまったりします。

問題は、背景のoverlaydisplayに設定する持続期間(duration)を、ダイアログ自体の持続期間に合わせる必要があることです。背景色がフェードする速さはダイアログと同じでなくてもよいのですが、表示や非表示のタイミングはダイアログと同期させなければなりません。

dialog::backdrop {
  background-color: rgb(0 0 0 / 0);
  transition:
    background-color 0.05s ease-in-out,
    overlay 0.1s ease-in-out allow-discrete,
    display 0.1s ease-in-out allow-discrete;
}

dialog[open]::backdrop {
  background-color: rgb(0 0 0 / 0.2);
  transition:
    background-color 0.15s ease-in-out,
    overlay 0.2s ease-in-out allow-discrete,
    display 0.2s ease-in-out allow-discrete;

  @starting-style {
    background-color: rgb(0 0 0 / 0);
  }
}

ここでは背景が素早く(0.05秒)フェードアウトしますが、ダイアログの終了まで0.1秒間は表示されます。色と表示のスタイルが分離されているため、同期を崩さずにアニメーションの印象を柔軟に調整できます。

🔗 問題4

この時点でアニメーションはどうにか動くようになりましたが、ダイアログを開くときと閉じるときのアニメーションがそれぞれ「下から上にずり上がる」「上から下にずり下がる」というように対称的になっていました。
しかし私は「ダイアログを開くときは下から上にずり上がる」「ダイアログを閉じるときは縮小して消える」ように、開始と終了のアニメーションを非対称にしたかったのです。

ダイアログを閉じるときに「上から下にずり下がる」アニメーションにすると、ダイアログがどこかに飛び去ってしまうように見えて、違和感があるからです(実際には飛んでいってしまうのではなく、単に消えるだけなのですが)。
「ダイアログを閉じるときは縮小して消える」ようにすれば、紛らわしくならない形でダイアログが閉じた感じを演出できます。

transformの値をステートごとにあれこれ変えて試してみました。

dialog {
  transform: translateY(0) scale(0.95); /* 終了時はサイズだけを縮小する */
}

dialog[open] {
  transform: translateY(0) scale(1);

  @starting-style {
    transform: translateY(20px) scale(0.98); /* 開始時は下から上にずり上がる */
  }
}

やっているうちにダイアログの拡大・縮小アニメーションだけになってしまいました。CSSトランジションのアニメーションは最短のパスをたどるので、ダイアログを閉じるときのアニメーションは常にdialog[open]のスタイルを逆にたどる形でdialogのスタイルに戻ります。ダイアログを閉じるときに違うパスをたどることは、トランジションだけではできません。

これを解決するには、@keyframesというアットルールを使って、完全に独立した2つのアニメーションを定義します。

@keyframes dialog-slide-up-scale-fade {
  from {
    opacity: 0;
    transform: translateY(20px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

@keyframes dialog-scale-down-fade {
  from {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
  to {
    opacity: 0;
    transform: translateY(0) scale(0.95);
  }
}

dialog {
  animation: dialog-scale-down-fade 0.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
  transition:
    overlay 0.1s cubic-bezier(0.16, 1, 0.3, 1) allow-discrete,
    display 0.1s cubic-bezier(0.16, 1, 0.3, 1) allow-discrete;
}

dialog[open] {
  animation: dialog-slide-up-scale-fade 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
  transition:
    overlay 0.2s cubic-bezier(0.16, 1, 0.3, 1) allow-discrete,
    display 0.2s cubic-bezier(0.16, 1, 0.3, 1) allow-discrete;

  @starting-style {
    animation: none;
  }
}

これで、ダイアログを表示する方法と閉じる方法が完全に独立しました。トランジションはoverlaydisplayプロパティをこれまで通り処理できています(これらのプロパティは離散的なので、スムーズにアニメーションするにはallow-discreteを追加する必要があることにご注意ください)。

@starting-style { animation: none; }は、ページ読み込み時にダイアログを閉じるアニメーションが発火しないためのものです。これがないと、ページ読み込みが完了した途端にダイアログを閉じるアニメーションが動き出してしまいます。

🔗 問題5

まだ問題がありました。ベースとなるdialog要素にdisplay: flexスタイルを適用すると、ページ読み込み時に一瞬だけダイアログが表示されてしまうのです。スタイルが適用されたダイアログがほんの一瞬現れて、すぐさま非表示に戻ります。

pointer-events: noneを設定したことでダイアログを誤って操作される心配はなくなりましたが、ダイアログが一瞬表示される問題はまだ解決しませんでした。
これを解決するには、以下のようにダイアログを適切な形で非表示にします。

dialog {
  visibility: hidden;
  pointer-events: none;
}

dialog[open] {
  visibility: visible;
  pointer-events: auto;
}

🔗 最終的なCSS

最終的に以下のCSSにたどり着きました。タイミングの数値をCSS変数に保存したので、表示開始の持続時間の変更があらゆる場所で反映されるようになりました。

dialog {
  --dialog-entry-duration: 0.2s;
  --dialog-exit-duration: calc(var(--dialog-entry-duration) / 2);
  --backdrop-entry-duration: calc(var(--dialog-entry-duration) * 0.75);
  --backdrop-exit-duration: calc(var(--dialog-exit-duration) / 2);
  --dialog-easing: cubic-bezier(0.16, 1, 0.3, 1);
  --backdrop-easing: ease-in-out;

  /* レイアウトはライフサイクルを通じて一定 */
  @apply flex w-full flex-col max-w-[calc(100vw-32px)];
  @variant sm { @apply max-w-md; }

  visibility: hidden;
  pointer-events: none;

  animation: dialog-scale-down-fade var(--dialog-exit-duration) var(--dialog-easing) forwards;
  transition:
    overlay var(--dialog-exit-duration) var(--dialog-easing) allow-discrete,
    display var(--dialog-exit-duration) var(--dialog-easing) allow-discrete;
}

dialog[open] {
  visibility: visible;
  pointer-events: auto;

  animation: dialog-slide-up-scale-fade var(--dialog-entry-duration) var(--dialog-easing) forwards;
  transition:
    overlay var(--dialog-entry-duration) var(--dialog-easing) allow-discrete,
    display var(--dialog-entry-duration) var(--dialog-easing) allow-discrete;

  @starting-style {
    animation: none;
  }
}

dialog::backdrop {
  background-color: rgb(0 0 0 / 0);
  transition:
    background-color var(--backdrop-exit-duration) var(--backdrop-easing),
    overlay var(--dialog-exit-duration) var(--backdrop-easing) allow-discrete,
    display var(--dialog-exit-duration) var(--backdrop-easing) allow-discrete;
}

dialog[open]::backdrop {
  background-color: rgb(0 0 0 / 0.2);
  transition:
    background-color var(--backdrop-entry-duration) var(--backdrop-easing),
    overlay var(--dialog-entry-duration) var(--backdrop-easing) allow-discrete,
    display var(--dialog-entry-duration) var(--backdrop-easing) allow-discrete;

  @starting-style {
    background-color: rgb(0 0 0 / 0);
  }
}

ダイアログを閉じるアニメーションは、ダイアログを表示する時間の半分になっています(ダイアログの表示時間は長めに取って表示感を演出し、閉じるときは邪魔にならないようすぐ閉じます)。

背景色のフェード速度は、ダイアログのアニメーションよりも速くしてあり、背景色の表示・非表示はダイアログの表示・非表示とずれないようになっています。

🔗 最後に

一度わかってしまえば、どれも難しい内容ではありません。しかしここにたどり着くまでに何時間もかかってしまいました。主な理由は、ダイアログが動き回ったり、現れたかと思ったら消えたり、原因がなかなかわからないような同期のずれが発生するなど、 わけのわからない問題が次々に発生したからでした。
アニメーション速度を1/10に下げたことで突破口が見え、フレームごとに確認できるようになって修正方法も明確になりました。

本記事で取り上げたCSSの機能についてもっと詳しく知りたい方は、以下の充実したMDNドキュメントをどうぞ。しかし率直に申し上げれば、これらの機能を学ぶベストな方法は、実際に機能を分解してスピードを落とし、そこで起きていることを観察することです。

関連記事

HTML: 「JavaScriptなし」で動く最新の多機能確認ダイアログを構築する(翻訳)

CSSだけで星を"半押し"可能な10段階レーティング機能を実装する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。