CSS の @starting-style で要素の出現アニメーションを定義する

display: none から display: block に切り替えたとき、要素がパッと表示されるだけでアニメーションが効かない。CSS でトランジションを書いても display の変更は補間できないため、これまでは JavaScript でクラスの付け替えを 2 段階に分けたり、requestAnimationFrame を挟んだりする必要がありました。@starting-style はこの問題を解決するルールで、要素が最初に表示される瞬間の「出発点」のスタイルを定義できます。

なぜ display: none からのトランジションは効かなかったのか

CSS のトランジションは、プロパティの値が A から B に変わるときにその間を補間する仕組みです。しかし display: none の要素はレンダリングツリーに存在しないため、「変更前の値」がそもそも存在しません。ブラウザからすると、要素が突然出現して最初から最終状態を持っている状態であり、補間する起点がないわけです。

display: none(レンダリングツリーに不在)

display: block に変更

要素が生成されるが、起点の値がない

トランジションが発火しない

@starting-style はこの「起点がない」問題を解決します。要素がレンダリングツリーに追加された最初のフレームで適用されるスタイルを明示的に宣言でき、ブラウザはそこから通常のスタイルへトランジションを走らせます。

基本的な構文

@starting-style はネスト記法とスタンドアロン記法の 2 通りで書けます。

/* ネスト記法 */
.modal {
    opacity: 1;
    transform: translateY(0);
    transition: opacity 0.3s, transform 0.3s;

    @starting-style {
        opacity: 0;
        transform: translateY(20px);
    }
}
/* スタンドアロン記法 */
.modal {
    opacity: 1;
    transform: translateY(0);
    transition: opacity 0.3s, transform 0.3s;
}

@starting-style {
    .modal {
        opacity: 0;
        transform: translateY(20px);
    }
}

どちらも動作は同じです。ネスト記法のほうが対象要素との対応が明確で読みやすいため、こちらを使うケースが多くなっています。

フェードインするモーダルの実装

@starting-style の典型的なユースケースはモーダルダイアログです。<dialog> 要素と組み合わせた例を見てみます。

dialog[open] {
    opacity: 1;
    transform: scale(1);
    transition: opacity 0.3s ease, transform 0.3s ease,
                display 0.3s allow-discrete;

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

display 0.3s allow-discrete という指定がポイントです。display は本来トランジションできない離散値ですが、allow-discrete を付けるとトランジション期間中は display: block を維持し、トランジション終了後に display: none へ切り替わるようになります。これにより閉じるときのフェードアウトも CSS だけで実現できます。

HTML
CSS
JavaScript
<button id="openBtn">モーダルを開く</button>
<dialog id="demoDialog">
    <div class="dialog-content">
        <p>これは @starting-style でアニメーションするモーダルです。</p>
        <button id="closeBtn">閉じる</button>
    </div>
</dialog>
dialog {
    border: none;
    border-radius: 12px;
    padding: 0;
    box-shadow: 0 8px 32px rgba(0,0,0,0.15);
    max-width: 360px;
    width: 90%;
}
dialog::backdrop {
    background: rgba(0,0,0,0.4);
    transition: opacity 0.3s ease;
}
dialog[open] {
    opacity: 1;
    transform: scale(1);
    transition: opacity 0.3s ease, transform 0.3s ease;
}
@starting-style {
    dialog[open] {
        opacity: 0;
        transform: scale(0.9);
    }
}
.dialog-content {
    padding: 24px;
    text-align: center;
}
.dialog-content p {
    margin: 0 0 16px 0;
    font-size: 14px;
    color: #333;
    line-height: 1.7;
}
button {
    padding: 8px 20px;
    border: 1px solid #ccc;
    border-radius: 6px;
    background: #fff;
    font-size: 14px;
    cursor: pointer;
}
button:hover {
    background: #f5f5f5;
}
document.getElementById('openBtn').addEventListener('click', () => {
    document.getElementById('demoDialog').showModal();
});
document.getElementById('closeBtn').addEventListener('click', () => {
    document.getElementById('demoDialog').close();
});

「モーダルを開く」ボタンを押すと、ダイアログがスケールアップしながらフェードインします。JavaScript 側は showModal()close() を呼ぶだけで、アニメーションのロジックは一切書いていません。

閉じるアニメーションも CSS だけで実現する

出現時のアニメーションは @starting-style で定義できますが、閉じるときのアニメーションには別のアプローチが必要です。<dialog> の場合、閉じる直前の状態を CSS で捕捉する手段がなかったのですが、transition-behavior: allow-discrete と組み合わせることで対応できます。

dialog[open] {
    opacity: 1;
    transform: translateY(0);
    transition: opacity 0.3s ease,
                transform 0.3s ease,
                overlay 0.3s allow-discrete,
                display 0.3s allow-discrete;

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

/* 閉じるときの終了状態 */
dialog:not([open]) {
    opacity: 0;
    transform: translateY(20px);
}

overlay プロパティのトランジションも allow-discrete で許可しています。overlay は要素がトップレイヤーに留まるかどうかを制御しており、これをトランジションに含めないとダイアログが即座にトップレイヤーから外れてアニメーションが見えなくなります。

出現アニメーション(@starting-style)

要素がレンダリングツリーに追加された最初のフレームの値を定義。そこから通常のスタイルへトランジションする。

退出アニメーション(allow-discrete)

display と overlay を allow-discrete でトランジション対象に含め、アニメーション完了まで要素の表示を維持する。

popover 属性との組み合わせ

@starting-style<dialog> だけでなく、Popover API とも相性がよいです。popover 属性を持つ要素もトップレイヤーに出入りするため、同じ手法でアニメーションできます。

[popover]:popover-open {
    opacity: 1;
    transform: translateY(0);
    transition: opacity 0.25s ease,
                transform 0.25s ease,
                overlay 0.25s allow-discrete,
                display 0.25s allow-discrete;

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

[popover]:not(:popover-open) {
    opacity: 0;
    transform: translateY(8px);
}

ツールチップ、ドロップダウンメニュー、トースト通知など、動的に出現・消滅する UI パーツ全般に応用できるパターンです。

DOM 追加時のアニメーション

@starting-style はトップレイヤーに限った機能ではありません。JavaScript で DOM に要素を追加したときにも発火します。

.notification {
    opacity: 1;
    transform: translateX(0);
    transition: opacity 0.4s ease, transform 0.4s ease;

    @starting-style {
        opacity: 0;
        transform: translateX(100%);
    }
}
const el = document.createElement('div');
el.className = 'notification';
el.textContent = '保存しました';
document.body.appendChild(el);

appendChild で DOM に追加された瞬間、@starting-style の値が初期状態として適用され、そこから通常のスタイルへトランジションが走ります。トースト通知やリストへのアイテム追加など、要素が動的に生成される場面で JavaScript のアニメーションコードを排除できます。

従来の手法との比較

@starting-style が登場する前は、出現アニメーションの実現にいくつかの回避策が使われていました。

2 段階クラス切り替え

要素を表示した直後に requestAnimationFramesetTimeout で別のクラスを付与し、そのクラスにトランジション先の値を書く方法。フレームのタイミングに依存するため不安定になることがあった。

@keyframes による代替

animation プロパティで出現アニメーションを定義する方法。トランジションと違ってイージングの統一が面倒で、hover やフォーカスなど状態変化との整合性を取りにくかった。

@starting-style はこれらの回避策を不要にし、トランジションという既存の仕組みの延長線上で出現アニメーションを宣言的に書けるようにしたものです。

ブラウザ対応状況

ブラウザ対応バージョン
Chrome117+
Edge117+
Safari17.5+
Firefox129+

主要ブラウザすべてで対応が進んでおり、2024 年後半の時点で実用段階に入っています。未対応ブラウザでは @starting-style ブロックが無視されるだけで、アニメーションなしの即時表示にフォールバックします。レイアウトが壊れることはないため、プログレッシブエンハンスメントとして安全に導入できます。