CSSの@starting-styleで追加した項目をふわっと出す方法

​​CSS​HTML,JavaScript,アクセシビリティ

JavaScript で <li> を追加すると、項目は一瞬でパッと現れます。CSS の @starting-style に「表示する前の値」(たとえば opacity: 0)を書いておくと、追加した項目はその値から transition で現れます。ToDoリストに予定を足したとき、チャットに新しいメッセージが届いたとき、通知の一覧に1件増えたときなどに使える書き方です。JavaScript の側は、要素を追加するだけです。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、ヘッドレスの Chromium 153 です。項目を追加した直後の transition の有無と opacity の値を、getAnimations() と getComputedStyle() で読みました。Firefox と Safari、スマートフォンの実機、React など UI ライブラリーの中で使う場合は試していません。

「項目を追加」を3回押してから「最後の項目を消す」を1回押す様子の動画。押すたびに上の帯に「操作 1回目/4回」のように回数が出て、新しい項目は薄い状態から少し下からせり上がって濃くなり、下の欄の opacity の数字がピンクで 0 から 1 へ増える。4回目は最後の項目が薄くなって消え、「DOM から消えた」と出る
項目を追加したときと消したときの動き。等間隔のコマにするため transition を止めて時間を進めながら撮影し、動きは半分の速さにしている。上の帯、カーソルの丸、下の opacity の表示は録画のために足したもの

li に @starting-style で表示前の値を書く

検証に使ったのは、ボタンを押すと <ul> の最後に <li> を1つ足すだけのページです。

html
<button id="add" type="button">項目を追加</button>
<ul id="list"><li>最初からある項目</li></ul>
<script>
  let n = 0;
  const list = document.getElementById('list');
  function addItem() {
    n += 1;
    const li = document.createElement('li');
    li.textContent = '追加した項目 ' + n;
    list.append(li);
    return li;
  }
  document.getElementById('add').addEventListener('click', addItem);
</script>

CSS では、li に transition を付け、その中に @starting-style を入れ子にして、表示する前の値を書きます。

css
li {
  transition: opacity 0.4s ease-out, translate 0.4s ease-out;

  @starting-style {
    opacity: 0;
    translate: 0 8px;
  }
}

項目を追加した直後と、その後の値は次のとおりでした。@starting-style の部分だけを消すと、transition は1つも始まらず、項目は最初から opacity: 1 で表示されました。

CSS 追加した直後の transition 0ms 100ms 200ms 300ms 500ms
@starting-style あり opacity、translate 0.00 0.43 0.73 0.93 1.00
@starting-style なし なし 1.00 1.00 1.00 1.00 1.00

(数字は opacity の値です。@starting-style がある場合は、translate も 8px から 0 に向かって減っていきました)

MDN の @starting-style の説明には、予期しない動作を避けるため、CSS の transition は要素の初期スタイル更新時には起きないと書かれています。DOM に追加したばかりの要素には「変わる前の値」がないので、transition が始まりません。@starting-style は、その「変わる前の値」を代わりに決めるものです。

同じ処理の中で3件をまとめて追加した場合も、3件とも transition が始まり、100ms 後の opacity はどれも 0.38 でした。

独立した @starting-style は、元のルールより後に書く

@starting-style は、入れ子にせず、独立したブロックとして書くこともできます。その場合は書く順番に気をつけます。

css
li {
  opacity: 1;
  transition: opacity 0.4s ease-out, translate 0.4s ease-out;
}

/* li のルールより後に書く */
@starting-style {
  li {
    opacity: 0;
    translate: 0 8px;
  }
}

この順番では、入れ子の場合と同じように transition が始まりました。@starting-style のブロックを li のルール(opacity: 1; translate: 0 0; を書いたもの)の前に移すと、transition は始まらず、項目は追加した瞬間から opacity: 1 でした。

MDN には、@starting-style と元のルールの詳細度は同じなので、@starting-style を元のルールの後に書くように、と書かれています。CSS Transitions Level 2 の仕様にも、@starting-style の中のルールは外のルールに勝つとは限らない、とあります。入れ子で書けば、この順番を気にせずに済みます。

ページを開いたときの項目も動くので、追加した項目だけに付ける

li 全体に @starting-style を付けると、HTML に最初から書いてある項目も、ページを開いたときにふわっと表示されます。検証でも、ページを開いて load が終わった直後の「最初からある項目」には opacity と translate の transition が動いていて、opacity は 0 でした。仕様にも、@starting-style は直前のスタイルの計算のときに表示されていなかった要素や、DOM になかった要素に使われる、とあります。ページを開いた直後の要素も、これに当てはまります。

追加した項目だけを動かしたいときは、JavaScript で追加するときにクラスを付け、そのクラスにだけ @starting-style を書きます。

css
li {
  transition: opacity 0.4s ease-out, translate 0.4s ease-out;
}

li.added {
  @starting-style {
    opacity: 0;
    translate: 0 8px;
  }
}
js
const li = document.createElement('li');
li.textContent = '追加した項目';
li.classList.add('added');
list.append(li);

この書き方では、ページを開いた直後の「最初からある項目」に transition はなく、opacity は 1 でした。追加して added を付けた項目は、これまでと同じく opacity: 0 から transition が始まりました。

動きを減らす設定の人には transition を付けない

OS の設定で画面の動きを減らしている人には、項目をそのまま表示したほうが親切です。transition だけを prefers-reduced-motion: no-preference の中に入れます。

css
li {
  @starting-style {
    opacity: 0;
    translate: 0 8px;
  }
}

@media (prefers-reduced-motion: no-preference) {
  li {
    transition: opacity 0.4s ease-out, translate 0.4s ease-out;
  }
}

Playwright で動きを減らす設定(reducedMotion: 'reduce')にしたブラウザーで開くと、追加した項目に transition はなく、最初から opacity: 1 でした。@starting-style の値は、transition の始まりの値として使われるだけなので、transition がなければ項目は透明になりません。設定をしていない場合は、@starting-style を li に入れ子にしたときと同じように transition が始まりました。

消すときは transition が終わってから remove() する

@starting-style が働くのは、要素が表示されるときだけです。li.remove() を呼ぶと、項目はその場で DOM から消え、transition は始まりませんでした。消える動きを付けるには、先に「消えたあとの見た目」のクラスを付け、transition が終わってから remove() を呼びます。

css
li.leaving {
  opacity: 0;
  translate: 0 8px;
}

transition の終わりを知る方法の1つに、transitionend イベントがあります。ただ、前の節の動きを減らす設定と組み合わせると、問題が起きました。

js
function removeItem(li) {
  li.classList.add('leaving');
  li.addEventListener('transitionend', () => li.remove(), { once: true });
}

この書き方では、ふだんは 401ms 後に項目が DOM から消えました。ところが、動きを減らす設定にしたブラウザーでは transition が始まらないので transitionend も起きず、2000ms たっても項目は DOM に残っていました。opacity は 0 なので、目には見えないのに、リストの中には場所を取ったまま残っています。

getAnimations() で、その要素で動いている transition を取り出し、それぞれの finished を待つと、どちらの場合にも対応できます。

js
function removeItem(li) {
  li.classList.add('leaving');
  Promise.all(li.getAnimations().map((a) => a.finished)).then(() => li.remove());
}
書き方 動きを減らす設定なし 動きを減らす設定あり
transitionend を待つ 401ms 後に消えた 2000ms たっても残った
getAnimations() の finished を待つ 406ms 後に消えた 1ms 後に消えた

transition がないときは getAnimations() が空の配列を返すので、Promise.all() はすぐに終わり、項目はそのまま消えます。

表示の途中で消した場合も確かめました。追加してから 100ms 後(opacity が 0.32 のとき)に removeItem() を呼ぶと、表示の transition は止まり、消える transition に切り替わって、117ms 後に DOM から消えました。finished の Promise がエラーになることはありませんでした。MDN の例では、クラスを付けてから setTimeout() で 1000ms 待って消していますが、finished を待つ書き方なら、表示の途中で消したときも、消える transition が終わった時点で項目が消えます。

ポイント

表示の動きは @starting-style、消える動きはクラスと transition で付けます。消すときは、getAnimations() の finished を待ってから remove() を呼ぶと、動きを減らす設定の人の画面に透明な項目が残りません。

並べ替えで動かすと、もう一度ふわっと表示される

表示し終わった項目を list.prepend(li) で先頭へ移すと、その項目にもう一度 transition が始まり、opacity は 0 に戻りました。MDN の moveBefore() の説明には、prepend() や insertBefore() で移すと、移すたびに要素の状態がリセットされると書かれています。

並べ替えのたびに項目がちらつくのを避けたいときは、moveBefore() で移します。

js
list.moveBefore(li, list.firstElementChild);

moveBefore() で先頭へ移した項目には transition が始まらず、opacity は 1 のままでした。同じ MDN のページには、moveBefore() は要素を外さずに移すので、アニメーションやトランジションの状態などが移動後も保たれると書かれています。ただし、moveBefore() は Safari が対応していません(次の節の表)。

対応しているブラウザー

@mdn/browser-compat-data(8.1.8)でのデスクトップ版の対応バージョンは次のとおりです。

機能 Chrome・Edge Firefox Safari
@starting-style 117 129 17.5
translate プロパティ 104 72 14.1
moveBefore() 133 144 対応なし

​Posted by 86