CSS @starting-style — display: noneからのアニメーションがついにJSなしで可能に

CSS@starting-styletransition-behaviorアニメーションdisplaydialogpopover

モーダルを開く時、トーストを表示する時、ドロップダウンを展開する時。display: noneから要素を表示する場面は日常的にあります。しかしCSSのtransitiondisplay: none → blockの変化に対して効きません。そのため多くの開発者がJavaScriptでタイミングを制御したり、max-heightハックに頼ったりしてきました。

CSS @starting-style はこの制約を根本から解消します。全主要ブラウザで対応済み(Baseline 2024)。

Before / After の比較デモを用意しました。 実際に触って違いを体感してみてください。

デモページを開く →

何が問題だったのか

CSSのdisplayプロパティは**離散値(discrete value)**です。opacityのように0から1へ補間できるプロパティとは異なり、noneblockの間に中間状態がありません。

/* これは動かない */
.modal {
  display: none;
  opacity: 0;
  transition: opacity 0.3s, display 0.3s;
}

.modal.visible {
  display: block;
  opacity: 1;
}

display: noneの要素はレンダリングツリーから完全に除外されるため、display: blockに変わった瞬間、ブラウザはトランジションの「開始値」を知りません。結果として、要素はアニメーションなしにパッと表示されます。

これまでの回避策

max-heightハック:

.accordion-content {
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.3s;
}

.accordion-content.open {
  max-height: 500px; /* 十分大きい値 */
}

実際の高さが80pxなのに500pxまでアニメーションするため、見た目上のアニメーションが最初の16%で完了し、残りの時間は何も変化しない「空振り」区間になります。テキスト量によってはmax-heightが足りずにコンテンツが途切れることもあります。

JSタイミングハック:

modal.style.display = 'block';
// 1フレーム待ってからクラスを追加
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    modal.classList.add('visible');
  });
});

display: blockの適用後にブラウザがレイアウトを計算するのを待ってから、opacityやtransformを変更する。2重のrequestAnimationFramesetTimeout(0)が必要で、タイミングが不安定になることがあります。

@starting-styleの仕組み

@starting-styleは、要素がDOMに追加された時やdisplay: noneから表示状態に変わった時に、トランジションの開始状態を定義するat-ruleです。

.modal {
  /* 表示状態(= トランジションのゴール) */
  opacity: 1;
  transform: translateY(0) scale(1);

  transition:
    opacity 0.35s ease,
    transform 0.35s ease,
    display 0.35s allow-discrete;

  /* 入場アニメーションの開始状態 */
  @starting-style {
    opacity: 0;
    transform: translateY(24px) scale(0.94);
  }
}

/* 退場アニメーションの終了状態 */
.modal.hidden {
  display: none;
  opacity: 0;
  transform: translateY(24px) scale(0.94);
}

ポイントは3つです。

1. @starting-styleで「どこから始めるか」を定義

@starting-styleブロック内のスタイルは、要素が初めて表示される時のみ適用されます。以降のトランジションではメインルールのスタイルが開始値になるため、入場と退場で異なるアニメーションを定義できます。

2. transition-behavior: allow-discreteが必須

displayのような離散値プロパティをトランジションに参加させるには、transition-behavior: allow-discreteが必要です。ショートハンドでも書けます。

/* ショートハンド */
transition: display 0.35s allow-discrete;

/* ロングハンド */
transition-property: display;
transition-duration: 0.35s;
transition-behavior: allow-discrete;

3. overlayプロパティでトップレイヤーを制御

<dialog>popover要素はブラウザのトップレイヤーに配置されます。閉じる時にアニメーションが完了する前にトップレイヤーから削除されてしまうと、アニメーションが中断されます。overlayプロパティをトランジションに含めることで、アニメーション完了まで要素をトップレイヤーに維持できます。

dialog {
  transition:
    opacity 0.35s,
    transform 0.35s,
    overlay 0.35s allow-discrete,
    display 0.35s allow-discrete;
}

実装パターン

モーダル(dialog要素)

<dialog>要素のshowModal()は自動的にバックドロップを生成し、中央配置してくれます。@starting-styleと組み合わせれば、アニメーションのJSコードはゼロです。

dialog {
  opacity: 1;
  transform: translateY(0) scale(1);
  margin: auto;

  transition:
    opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1),
    transform 0.35s cubic-bezier(0.16, 1, 0.3, 1),
    overlay 0.35s allow-discrete,
    display 0.35s allow-discrete;

  @starting-style {
    opacity: 0;
    transform: translateY(24px) scale(0.94);
  }
}

/* 閉じる時の状態 */
dialog:not([open]) {
  opacity: 0;
  transform: translateY(24px) scale(0.94);
}

/* バックドロップもアニメーション */
dialog::backdrop {
  background: rgba(0, 0, 0, 0);
  transition:
    background 0.35s,
    overlay 0.35s allow-discrete,
    display 0.35s allow-discrete;
}

dialog[open]::backdrop {
  background: rgba(0, 0, 0, 0.6);
}

@starting-style {
  dialog[open]::backdrop {
    background: rgba(0, 0, 0, 0);
  }
}
// JSはopen/closeの制御のみ
dialog.showModal();
dialog.close();

トースト通知

右からスライドインして、消える時もスライドアウトします。

.toast {
  display: none;
  opacity: 1;
  transform: translateX(0);

  transition:
    opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
    transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
    display 0.4s allow-discrete;

  @starting-style {
    opacity: 0;
    transform: translateX(110%);
  }
}

.toast.visible {
  display: block;
}

.toast.removing {
  opacity: 0;
  transform: translateX(110%);
}

退場アニメーションでは.removingクラスを追加してopacity: 0にした後、トランジション完了後に.visibleを外します。

ドロップダウンメニュー

上から降りてくる自然なアニメーションです。

.dropdown {
  display: none;
  opacity: 1;
  transform: translateY(0);

  transition:
    opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1),
    transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
    display 0.2s allow-discrete;

  @starting-style {
    opacity: 0;
    transform: translateY(-8px);
  }
}

.dropdown.visible {
  display: block;
}

ブラウザサポート

ブラウザ対応バージョン
Chrome117+(2023年9月〜)
Edge117+
Safari17.5+(2024年5月〜)
Firefox129+(2024年8月〜)

2024年8月のFirefox対応をもって、Baseline Newly Availableに到達しています。主要ブラウザ全てで利用可能です。

@starting-styleを使うべき場面・使わなくていい場面

使うべき場面:

  • display: noneから表示する要素(モーダル、ドロップダウン、トースト、ツールチップ)
  • <dialog>要素の開閉
  • popover属性を使ったポップオーバー
  • DOMに動的に追加される要素のエントリーアニメーション

使わなくていい場面:

  • 最初から表示されている要素のアニメーション(@starting-styleなしでtransitionだけで十分)
  • visibility: hiddenからのアニメーション(visibilityは元々トランジション可能)
  • @keyframesアニメーション(@starting-styleはトランジション専用)

まとめ

@starting-styleは「モーダルの開閉アニメーション」という日常的な実装を、JSのタイミングハックから解放するCSSの新機能です。transition-behavior: allow-discreteと組み合わせることで、display: noneからの滑らかなトランジションが数行のCSSで実現できます。

デモページで実際の動きを確認する →