CSSで、スクロールに合わせて要素をふわっと表示する方法

​​CSS​HTML,Playwright

スクロールして要素が画面の下から入ってくるときに、透明な状態から少しずつ見せるには、CSS の animation-timeline: view() を使います。アニメーションの進み具合が、時間ではなく要素の見え具合で決まるので、JavaScript でスクロールを見張る必要がありません。animation-range: entry 0% entry 100% を付けると、要素が下から入りきった時点で、ふわっと出す動きが終わり、完全に見える状態になります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS だけのページを Playwright で開き、window.scrollTo() でスクロール量を変えては、要素の opacity と translate の計算値を読みました。動きを減らす設定は、Playwright の emulateMedia() で再現しています。Firefox と Safari、スマートフォンの実機、指やマウスホイールでの実際のスクロールは試していません。

CSS の全体

先に、記事で使う CSS をまとめて載せます。

css
@keyframes fade-in {
  from {
    opacity: 0;
    translate: 0 40px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

.fade-in {
  animation: fade-in 1ms linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}
html
<div class="card fade-in">カード 1</div>
<div class="card fade-in">カード 2</div>

@keyframes は、透明で 40px 下にずれた状態から、不透明で元の位置に戻るまでの動きです。

animation の一括指定にある 1ms は、再生時間ではありません。MDN のスクロール駆動アニメーションのタイムラインの解説によると、スクロールに合わせたアニメーションでは animation-duration は再生時間に影響しません。ただし Firefox は 0 でない値がないとアニメーションを適用しないため、1ms を書くのが一般的とされています。

animation-timeline に view() を指定すると、要素がいちばん近いスクロールコンテナーの中でどれだけ見えているかが、アニメーションの進み具合になります。ページ全体をスクロールする場合は、ブラウザの表示領域(ビューポート)を基準に見え具合が決まります。

縦長のスマートフォン幅の画面。上の濃い色の帯に「下へスクロール 1回目/2回」とスクロール量、カード1〜4の不透明度が出ている。下へスクロールすると、カードが画面の下から入ってくる間に透明から見えてきて、変化している不透明度はピンクで表示される。最後に上へ戻すと、カードがまた透明になっていく
下へ2回スクロールしてから上へ戻したところ。カードが下から入ってくる間だけ、不透明度が0から1へ変わる

検証環境の Chromium で、上の CSS のページを下へスクロールしたところ、カードは画面の下から入ってくる間に透明から見えてきました。スクロールを上へ戻すと、カードはまた透明に戻っていきます。見え具合と進み具合が結びついているので、逆向きにスクロールすれば、アニメーションも逆向きに進みます。

animation-range で、入ってくる間だけに動きを収める

animation-range を書かないと、動きは要素が画面に入り始めてから、画面の上から出きるまでの長い範囲に引き延ばされます。MDN の <timeline-range-name> のページによると、この範囲は cover と呼ばれ、view() の既定の範囲です。

同じページによると、entry は、要素が画面に入り始めてから入りきるまでの範囲です。animation-range に entry 0% entry 100% を指定すると、この範囲だけで動きが終わります。

折れ線グラフ。横軸はスクロール量、縦軸はカードの不透明度。水色の線(animation-range: entry 0% entry 100%)は、スクロール量300pxで0から上がり始め、500pxで1に届く。灰色の線(animation-range なし)は300pxから上がり始め、1200pxでやっと1に届く
高さ 200px のカードの不透明度。animation-range がないと、カードが画面の上から出きるまで不透明度が 1 にならない

高さ 700px の画面で、高さ 200px のカードを置いて測りました。カードの上端が画面の下端に届くスクロール量 300px で、どちらも不透明度が上がり始めます。entry 0% entry 100% のほうは、カードが全部見えた 500px で不透明度が 1 になりました。animation-range を書かないほうは、500px で 0.22、カードが画面の上端に届いた 1000px でも 0.78 で、1 になったのはカードが画面の上から出きる 1200px でした。

animation-timeline は animation より後に書く

animation-timeline を animation の一括指定より前に書くと、スクロールに合わせた動きになりません。

css
/* うまくいかない書き方 */
.timeline-first {
  animation-timeline: view();
  animation: fade-in 1ms linear both;
  animation-range: entry 0% entry 100%;
}

この書き方では、animation-timeline の計算値が auto になりました。カードは画面に入ってくる前から不透明度 1 のままで、ふわっと出る動きはありませんでした。

MDN の animation-timeline のページによると、animation の一括指定は、前に宣言した animation-timeline を auto に戻します。そのため、animation-timeline は animation の後に書くか、animation より高い詳細度で指定する必要があります。

親に overflow: hidden があると動かない

カードを overflow: hidden の要素で囲むと、カードはスクロール量にかかわらず、ずっと不透明度 1 のままでした。親の scrollHeight と clientHeight はどちらも 200 で、中身ははみ出していません。

html
<div class="clip-parent">
  <div class="card fade-in">カード</div>
</div>
css
.clip-parent {
  overflow: hidden; /* これがあると、カードは最初から見えたまま */
}

view() は、いちばん近い祖先のスクロールコンテナーの中での見え具合を使います(前に挙げた MDN のタイムラインの解説より)。CSS Overflow Module Level 3 の仕様では、overflow が hidden の要素は、scroll や auto と同じくスクロールコンテナーになります。はみ出しているかどうかは条件に入っていません。MDN の overflow のページには「オーバーフローした場合」とありますが、検証環境の Chromium では仕様のとおり、はみ出していない hidden の親の中でもカードは動きませんでした。

はみ出しを隠すことだけが目的なら、overflow: clip に変えます。MDN の同じページによると、clip の要素はスクロールコンテナーになりません。

css
.clip-parent {
  overflow: clip;
}

overflow: clip に変えると、カードは何も囲まないときと同じく、スクロール量 300px から 500px の間で不透明度が 0 から 1 へ変わりました。

最初から画面に入っている要素と、translate のずれ

ページを開いた時点で画面に入っている要素は、スクロールする前から不透明度 1 でした。上から 100px の位置に置いたカードは、ページを開いた直後から最後まで、ずっと表示されたままです。入りきった後の状態になっているので、最初の画面の内容が透明で隠れることはありませんでした。

translate: 0 40px で下にずらしていても、動きが始まる位置は、ずらす前のカードの位置で決まりました。カードの上端(ずらす前)が画面の下端に届いたスクロール量 300px で不透明度が上がり始め、translate も 0px 40px から少しずつ減って、500px で 0px になっています。動き始めのカードは 40px 下にずれていて、まだ画面の外にあります。最初の GIF で、帯の数字が変わり始めてから少し遅れてカードが見えてくるのはこのためです。

対応していないブラウザと、動きを減らす設定の人には最初から見せる

MDN の互換性データ(@mdn/browser-compat-data 8.1.8)では、animation-timeline、view()、animation-range は、Chrome と Edge が 115 から、Firefox が 159 から、Safari が 26 から使えます。これより古いブラウザでも内容が読めるように、@supports で対応しているときだけ動きを付けます。

あわせて、OS の設定で動きを減らしている人には動きを付けないようにします。prefers-reduced-motion は、動きを減らす設定が有効かどうかを調べるメディア特性です。

css
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .safe-fade-in {
      animation: fade-in 1ms linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
  }
}

この書き方では、動きを減らす設定がないとき、基本の .fade-in とまったく同じ不透明度の変化になりました。Playwright で prefers-reduced-motion: reduce を再現すると、animation-timeline の計算値は auto で、カードはスクロール量にかかわらず不透明度 1、translate は none でした。

対応していない古いブラウザは検証環境に用意していないので、@supports の条件をわざと成り立たないもの(animation-timeline: no-such-value)に変えて確かめました。このときも、カードは最初から不透明度 1 で、ずれもなく表示されました。

​Posted by 86