【コピペOK】スクロール アニメーション CSS のみ 9選
スクロール アニメーション css は、要素が画面に入った量やページ全体の読了率を animation-timeline に渡し、JavaScript を1行も書かずに動かす仕組みです。この記事では9種類を選び、view() と scroll(root) という2つのタイムラインだけで実装しました。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
並び順は効果の種類ではなく、実際にページを開いてから閉じるまでの順番です。最初に見出しやカードが現れ(01〜04)、読んでいる間はページ全体の進み具合を伝え(05〜07)、最後は数字と年表で結果を残します(08〜09)。HTML と CSS だけでスクロール アニメーションを完結させるため、JavaScript のファイルは zip に1つも入っていません。項目ごとに要点となるコードだけを載せ、完全なファイルは zip の中です。
01ふわっと表示
スクロールで要素が画面に入ると、下から24pxふわっと持ち上がりながらフェードインします。IntersectionObserver ではなく animation-timeline: view() だけで動くので、JavaScript は0行です。見出しやカード、段落などほぼすべての登場に使えます。
.card {
@supports (animation-timeline: view()) {
animation: fade-up-reveal $ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
@supports not (animation-timeline: view()) {
opacity: 1;
transform: none;
}
}
@keyframes fade-up-reveal {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
02順番に表示
リストの各項目に同じアニメーションを指定するだけで、スクロールに合わせて上から順番に現れます。JavaScript の setTimeout で遅延を作る必要はありません。機能一覧や料金表、メンバー紹介に向いています。
.list__item {
@supports (animation-timeline: view()) {
animation: stagger-in $ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@supports not (animation-timeline: view()) {
opacity: 1;
transform: none;
}
}
@keyframes stagger-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
03文字ワイプ
見出しが clip-path ひとつで、左から右へワイプするように現れます。width や left ではなく clip-path だけを動かすので、レイアウトの再計算が起きません。ヒーローの見出しや大きな中見出しに向いています。
.headline {
@supports (animation-timeline: view()) {
animation: wipe-in $ease-spring both;
animation-timeline: view();
animation-range: entry 20% cover 60%;
}
@supports not (animation-timeline: view()) {
clip-path: inset(0 0 0 0);
}
}
@keyframes wipe-in {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
04画像カーテン
画像を覆う色板が scaleX で右へたたまれるように消え、下の画像が現れます。ポートフォリオや商品写真の一覧で、読み込みの一瞬を隠す効果です。
.frame__curtain {
transform: scaleX(1);
transform-origin: right;
@supports (animation-timeline: view()) {
animation: curtain-open $ease-spring both;
animation-timeline: view();
animation-range: entry 10% cover 50%;
}
@supports not (animation-timeline: view()) {
transform: scaleX(0);
}
}
@keyframes curtain-open {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
05読了プログレスバー
ページ上部の3pxバーが、読んだ分だけ左から伸びていきます。view() ではなく scroll(root) を使うのが要点で、「画面に入ったか」ではなく「文書全体のどこまで読んだか」を表します。長い記事やドキュメントに向いています。
.bar {
position: fixed;
top: 0; left: 0;
width: 100%;
height: 3px;
transform: scaleX(0);
transform-origin: left;
@supports (animation-timeline: scroll()) {
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) {
transform: scaleX(0);
}
}
@keyframes fill-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
06縮むヘッダー
position: sticky のヘッダーが、スクロールが進むほど scaleY で薄くなります。height ではなく transform を動かすのは、height のアニメーションがブラウザに毎フレームのレイアウト計算をさせるためです。LP やブログのヘッダーに向いています。
.head {
position: sticky;
top: 0;
transform-origin: top;
@supports (animation-timeline: scroll()) {
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
@supports not (animation-timeline: scroll()) {
transform: none;
}
}
@keyframes shrink-header {
from { transform: scaleY(1); }
to { transform: scaleY(.75); }
}
07パララックス
背景の円が0.3倍、前景のタイトルが0.5倍の速さで動き、奥行きが生まれます。同じ scroll(root) を使いながら移動量だけを変えるので、レイヤーが増えても仕組みは1つのままです。
.parallax__back {
@supports (animation-timeline: scroll()) {
animation: drift-back linear both;
animation-timeline: scroll(root);
}
}
.parallax__front {
@supports (animation-timeline: scroll()) {
animation: drift-front linear both;
animation-timeline: scroll(root);
}
}
@keyframes drift-back { from { transform: translateY(0); } to { transform: translateY(-20px); } }
@keyframes drift-front { from { transform: translateY(0); } to { transform: translateY(-40px); } }
08カウントアップ
数字が見えた瞬間に0から目標値まで上がります。@property で --num を整数として登録するので、JavaScript なしで小数点のない数字がひとつずつ増えます。実績や統計セクションに向いています。
@property --num {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
.stat__num {
--num: 0;
counter-reset: num var(--num);
&::after { content: counter(num); }
animation: count-up 1.2s linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
@keyframes count-up {
from { --num: 0; }
to { --num: 1280; }
}
09線が伸びるタイムライン
縦線は scroll(root) で文書の進み具合に合わせて伸び、通過した点は view() で点灯します。1つの効果の中で scroll() と view() を組み合わせているのがここだけの特徴です。沿革や手順、ロードマップに向いています。
.timeline__line {
transform: scaleY(0);
transform-origin: top;
@supports (animation-timeline: scroll()) {
animation: line-draw linear both;
animation-timeline: scroll(root);
animation-range: 0 200px;
}
}
.timeline__dot {
@supports (animation-timeline: view()) {
animation: dot-light 320ms ease-out both;
animation-timeline: view();
animation-range: entry 30% cover 60%;
}
}
@keyframes line-draw { from { transform: scaleY(0); } to { transform: scaleY(1); } }
@keyframes dot-light { from { opacity: .28; transform: scale(1); } to { opacity: 1; transform: scale(1.3); } }
どこで壊れるか — 落とし穴
9つとも animation-timeline だけで動く理由は、IntersectionObserver を使うと JavaScript がスクロールのたびに要素の位置を再計算し、メインスレッドが混雑するからです。MDN の CSS scroll-driven animations にあるとおり、animation-timeline を使えばこの計算はブラウザのコンポジター側で行われ、メインスレッドをふさぎません。1つ目は05と06でやりがちなことで、animation-range を entry 0% cover 100% のように広く取ると、要素が画面の外にいる間もアニメーションが進んでしまい、スクロールを戻すと変な位置で止まって見えます。
2つ目は、最初に09を作ったときのことです。線と点を同じ animation-range に合わせたら、線が伸び切る前に最後の点だけ先に光ってしまいました。線は scroll(root) で文書全体の進み具合に、点は view() で自分の位置に合わせるよう分けたところ、この崩れは直りました。
3つ目はブラウザです。animation-timeline: view() と scroll() は Chrome・Edge 115 以降と Safari 26 以降が対応済みで、Firefox は 2026 年 9 月時点の安定版ではフラグの裏にあり、@supports で分岐していない実装だと非対応ブラウザで要素が透明なまま止まってしまいます。zip のファイルはすべて @supports not (animation-timeline: view()) のような分岐で最終状態をそのまま表示するようにしてあり、対応状況は MDN の animation-timeline 対応表 で確認できます。修正済みの9ファイルを開くには x5xz5uh4 という文字列が必要です。下のカードから zip を取ってください。
アクセシビリティ
9つとも prefers-reduced-motion: reduce で animation: none になり、スクロールしても動きません。ただし05のプログレスバーと09の線は「動きを止める」と「情報を消す」が別物なので、transform: scaleX(1) や scaleY(1) を残して、進捗や到達済みの状態だけは見えるようにしてあります。08のカウントアップだけは例外的に、動きを止めると同時に目標の数字をそのまま表示します。
@media (prefers-reduced-motion: reduce) {
.card, .headline, .stat__num { animation: none !important; }
.bar, .timeline__line { animation: none !important; transform: scaleX(1); }
}
スクロール以外の CSS アニメーションは CSS アニメーションのカテゴリ に、このサイトについては 紹介ページ にまとめています。
FAQ
9つすべてを1つのページに入れても大丈夫ですか?
入れられますが、animation-timeline: scroll(root) を使う05と07は1つの文書につき1つが基本です。scroll(root) は文書全体の進み具合を見ているので、同じ考え方の効果を何個も置くと、どれがどこまで進んだか分かりにくくなります。view() を使う01〜04・08・09は要素ごとに独立しているので、いくつ置いても構いません。
React ではクラスの付け外しの代わりに何を使いますか?
zip の react/ フォルダのコンポーネントは IntersectionObserver ではなく、CSS の animation-timeline をそのまま使っています。08のカウントアップだけは目標値を --num という CSS カスタムプロパティに props で渡し、JavaScript は数値を計算しません。それ以外の8つはクラス名を1つ付けるだけで、状態管理は不要です。
スマホでも同じように動きますか?
はい、動きます。iOS の Safari と Android の Chrome はどちらも animation-timeline: view() と scroll() に対応しているので、タッチでスクロールしても同じアニメーションが再生されます。ただし06の縮むヘッダーはアドレスバーの表示・非表示でビューポートの高さが変わる端末があるため、animation-range の数値をそのままにすると縮むタイミングが数十ピクセルずれることがあります。