スクロールアニメーション実装ガイド|Intersection Observer+CSSでプラグイン不要

Webサイトをスクロールしていると、画像やテキストがふわっと現れたり、下からスライドしてきたりする演出をよく見かけます。こうした「スクロールアニメーション」は、ユーザーの視線を自然に誘導できるため、実務の現場でも多用される表現です。
アニメーションをうまく使うことで、
- コンテンツの読みやすさを高める
- 情報のメリハリをつける
- サイト全体の印象をリッチにする
といった効果が期待できます。
スクロールアニメーションを実現するための外部ライブラリ(例:jQueryプラグインなど)は数多くありますが、近年のブラウザで標準的に使える Intersection Observer を活用すれば、プラグインに頼らずスムーズなアニメーションを実装できます。
そこで今回は、HTML・CSS・JavaScriptのみでシンプルに作れるスクロールアニメーション を解説します。実務ですぐ使える完成デモとともに、実装の流れとポイントを紹介します。
Intersection Observer の基本から学びたい方は、こちらの記事もあわせてご覧ください。

完成デモの紹介
ここで、今回作成するスクロールアニメーションの完成デモをお見せします。まずは実際に動くイメージを確認してみましょう。

スクロールに合わせて要素がふわっと表示されたり、スライドして現れる様子を体感できると思います。このあと、デモを実現しているコード一式を紹介し、さらに重要なポイントを解説していきます。
コード全体(HTML / CSS / JavaScript)
ここからは、実際に動作するコード全体を紹介します。HTML・CSS・JavaScriptを順に掲載しているので、コピーして試すこともできますし、ざっと眺めるだけでも全体の流れが分かります。あとで各コードの重要なポイントを解説していきます。
HTMLコード
ページ全体の骨組みを作る部分です。ヘッダー、キービジュアル、複数のセクション要素を配置し、スクロールに応じてアニメーションさせる対象を用意しています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no">
<title>スクロールアニメーションのデモ</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrapper">
<header class="header">
<div class="header-logo"><a href="#">SAMPLE</a></div>
<nav class="header-nav">
<ul>
<li><a href="#">会社情報</a></li>
<li><a href="#">事業内容</a></li>
<li><a href="#">採用情報</a></li>
<li><a href="#">ニュース</a></li>
</ul>
</nav>
<div class="header-contact"><a href="#">お問い合わせ</a></div>
</header>
<div class="keyvisual">
<p class="keyvisual-sub">SCROLL ANIMATION DEMO</p>
<h1>スクロールに応じて<br>要素が表示されるデモ</h1>
<p class="keyvisual-text">このページでは、スクロールに応じて<br class="only-pc">要素がフェードインやスライドインするアニメーションを確認できます。<br class="only-pc">実務でよく使われる「スクロールアニメーション」の効果を体験してみてください。</p>
</div>
<div class="contents">
<div class="notice">
<p class="notice-text">このページは以下の記事のデモです。<br class="only-pc">デモの使い方や詳細な解説は、記事内でご覧いただけます。</p>
<div class="notice-link">
<a href="#">
<div class="notice-link-image"><span><img src="eyecatch.webp" alt=""></span></div>
<p class="notice-link-text">スクロールアニメーション<br class="only-sp">実装ガイド<br>Intersection Observer+<br class="only-sp">CSSでプラグイン不要</p>
</a>
</div>
</div>
<div class="section-wrap">
<section class="section">
<div class="section-image"><span><img src="image01.webp" alt="サンプル画像1"></span></div>
<div class="section-text">
<h2>サンプルタイトル1</h2>
<p>スクロールアニメーションを加えることで、静的なページに動きが生まれ、視線の誘導がしやすくなります。特に最初の表示時に注目を集めたい要素に使うと、自然とユーザーの目線を導けます。</p>
<p>文章や画像が徐々に現れることで、読み進めやすく、滞在時間の向上につながります。</p>
</div>
</section>
<section class="section">
<div class="section-image"><span><img src="image02.webp" alt="サンプル画像2"></span></div>
<div class="section-text">
<h2>サンプルタイトル2</h2>
<p>スクロールアニメーションを加えることで、静的なページに動きが生まれ、視線の誘導がしやすくなります。特に最初の表示時に注目を集めたい要素に使うと、自然とユーザーの目線を導けます。</p>
<p>文章や画像が徐々に現れることで、読み進めやすく、滞在時間の向上につながります。</p>
</div>
</section>
<section class="section">
<div class="section-image"><span><img src="image03.webp" alt="サンプル画像3"></span></div>
<div class="section-text">
<h2>サンプルタイトル3</h2>
<p>スクロールアニメーションを加えることで、静的なページに動きが生まれ、視線の誘導がしやすくなります。特に最初の表示時に注目を集めたい要素に使うと、自然とユーザーの目線を導けます。</p>
<p>文章や画像が徐々に現れることで、読み進めやすく、滞在時間の向上につながります。</p>
</div>
</section>
<section class="section">
<div class="section-image"><span><img src="image04.webp" alt="サンプル画像4"></span></div>
<div class="section-text">
<h2>サンプルタイトル4</h2>
<p>スクロールアニメーションを加えることで、静的なページに動きが生まれ、視線の誘導がしやすくなります。特に最初の表示時に注目を集めたい要素に使うと、自然とユーザーの目線を導けます。</p>
<p>文章や画像が徐々に現れることで、読み進めやすく、滞在時間の向上につながります。</p>
</div>
</section>
<section class="section">
<div class="section-image"><span><img src="image05.webp" alt="サンプル画像5"></span></div>
<div class="section-text">
<h2>サンプルタイトル5</h2>
<p>スクロールアニメーションを加えることで、静的なページに動きが生まれ、視線の誘導がしやすくなります。特に最初の表示時に注目を集めたい要素に使うと、自然とユーザーの目線を導けます。</p>
<p>文章や画像が徐々に現れることで、読み進めやすく、滞在時間の向上につながります。</p>
</div>
</section>
<section class="section">
<div class="section-image"><span><img src="image06.webp" alt="サンプル画像6"></span></div>
<div class="section-text">
<h2>サンプルタイトル6</h2>
<p>スクロールアニメーションを加えることで、静的なページに動きが生まれ、視線の誘導がしやすくなります。特に最初の表示時に注目を集めたい要素に使うと、自然とユーザーの目線を導けます。</p>
<p>文章や画像が徐々に現れることで、読み進めやすく、滞在時間の向上につながります。</p>
</div>
</section>
</div>
</div>
<footer class="footer">
<p class="footer-copy"><small>©フロントエンドクリップ</small></p>
</footer>
</div>
<script src="script.js"></script>
</body>
</html>
CSSコード
初期状態では非表示や変形を与え、.is-visible
クラスが付与されたときにアニメーションが走るように設定しています。レスポンシブ対応や細かなデザイン調整も含めた、やや長めのコードです。
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&display=swap');
/* ------------------------------
全体設定(リセット & ベース)
------------------------------ */
* {
margin: 0;
padding: 0;
}
@media (min-width: 768px) {
.only-sp {
display: none;
}
}
@media (max-width: 767px) {
.only-pc {
display: none;
}
}
html {
overflow-y: scroll;
}
body {
background: #e9edf2;
color: #333;
font-family: "Noto Sans JP", sans-serif;
font-optical-sizing: auto;
font-size: 18px;
font-weight: 400;
font-style: normal;
line-height: 1.8;
text-align: left;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: none;
}
.wrapper {
min-width: 1200px; /* PCでは固定幅(SPでは後段メディアクエリで可変に上書き) */
}
/* ------------------------------
ヘッダー
------------------------------ */
.header {
position: relative;
height: 120px;
background: #fff;
}
/* ロゴの配置(transformで縦方向の中央揃え) */
.header-logo {
position: absolute;
left: 50px;
top: 50%;
font-size: 40px;
font-weight: 900;
line-height: 1;
letter-spacing: 2px;
transform: translateY(-50%);
}
.header-logo a {
display: inline-block;
color: #192753;
text-decoration: none;
vertical-align: top;
}
.header-nav {
position: absolute;
right: 240px;
top: 50%;
transform: translateY(-50%);
}
/* ナビゲーション:フレックスで水平方向に整列し、gapで間隔を管理 */
.header-nav ul {
list-style: none;
display: flex;
gap: 0 30px;
}
.header-nav ul li a {
display: block;
padding: 10px;
color: inherit;
font-weight: 700;
line-height: 1.4;
text-decoration: none;
transition: color 0.3s; /* ホバー時の色変化 */
}
.header-nav ul li a:hover {
color: #4466ce;
}
.header-contact {
position: absolute;
right: 50px;
top: 50%;
transform: translateY(-50%);
}
.header-contact a {
display: block;
width: 150px;
padding: 10px 0;
border-radius: 5px;
background: #4466ce;
color: #fff;
font-weight: 700;
line-height: 1.4;
text-align: center;
text-decoration: none;
transition: background-color 0.3s;
}
.header-contact a:hover {
background: #192753;
}
/* ------------------------------
キービジュアル
------------------------------ */
.keyvisual {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
height: 700px;
background: url('keyvisual.webp') no-repeat center/cover;
color: #fff;
text-align: center;
}
/* 背景を暗くして文字のコントラストを確保(可読性向上) */
.keyvisual::before {
content: "";
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
}
.keyvisual-sub {
position: relative;
margin-bottom: 20px;
font-size: 20px;
font-weight: 700;
line-height: 1.5;
}
.keyvisual h1 {
position: relative;
font-size: 65px;
font-weight: 700;
line-height: 1.25;
}
.keyvisual-text {
position: relative;
margin-top: 80px;
line-height: 1.8;
}
/* ------------------------------
コンテンツ
------------------------------ */
.contents {
width: 1100px;
margin: 0 auto;
padding: 80px 0 150px;
}
/* ------------------------------
元の記事へのリンク
------------------------------ */
.notice {
width: 700px;
margin: 0 auto;
}
.notice-text {
margin-bottom: 35px;
text-align: center;
}
.notice-link-image {
width: 20%;
}
.notice-link-image span {
display: block;
position: relative;
aspect-ratio: 320 / 180;
overflow: hidden;
}
.notice-link-image img {
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 5px;
object-fit: cover;
}
.notice-link-text {
width: 80%;
box-sizing: border-box;
padding-left: 30px;
font-weight: 500;
}
.notice-link a {
display: flex;
align-items: center;
position: relative;
padding: 30px;
border-radius: 5px;
background: #fff;
color: #192753;
text-decoration: none;
transition: transform 0.3s;
}
.notice-link a::before {
content: "";
display: block;
position: absolute;
right: 30px;
top: 50%;
width: 10px;
height: 10px;
margin-top: -5px;
box-sizing: border-box;
border-top: 3px solid #4466ce;
border-right: 3px solid #4466ce;
transform: rotate(45deg);
}
@media (min-width: 768px) {
.notice-link a:hover {
transform: translateY(-6px);
}
}
/* ------------------------------
セクション
------------------------------ */
.section-wrap {
margin-top: 120px;
}
.section {
display: flex;
align-items: center;
}
.section-image {
width: 50%;
}
/* アニメーション前の状態(非表示 & 小さい円) */
.section-image span {
display: block;
position: relative;
aspect-ratio: 1;
overflow: hidden;
opacity: 0;
clip-path: circle(10%);
transition: opacity 1500ms cubic-bezier(0.645, 0.045, 0.355, 1), clip-path 1500ms cubic-bezier(0.645, 0.045, 0.355, 1);
}
.section-image img {
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
/* テキストは下からスライドアップするように準備 */
.section-text {
width: 50%;
box-sizing: border-box;
opacity: 0;
transform: translateY(200px);
transition: opacity 1500ms 500ms cubic-bezier(0.645, 0.045, 0.355, 1), transform 1500ms 500ms cubic-bezier(0.645, 0.045, 0.355, 1);
}
.section-text h2 {
margin-bottom: 45px;
color: #192753;
font-size: 34px;
font-weight: 700;
line-height: 1.4;
}
.section-text p + p {
margin-top: 35px;
}
.section:nth-child(n+2) {
margin-top: 150px;
}
/* Intersection Observer により is-visible が付与されたとき */
.section.is-visible .section-image span {
opacity: 1;
clip-path: circle(50%); /* 円形クリップが広がる */
}
.section.is-visible .section-text {
opacity: 1;
transform: translateY(0px); /* 下から自然に表示 */
}
@media (min-width: 768px) {
.section:nth-child(2n+1) {
flex-direction: row;
}
.section:nth-child(2n+1) .section-text {
padding-left: 60px;
}
.section:nth-child(2n+2) {
flex-direction: row-reverse;
}
.section:nth-child(2n+2) .section-text {
padding-right: 60px;
}
}
/* ------------------------------
フッター
------------------------------ */
.footer {
padding: 30px 0;
background: #192753;
color: #fff;
}
.footer-copy {
font-size: 16px;
text-align: center;
}
/* ------------------------------
レスポンシブ対応(SPでは縦積みレイアウト+余白をコンパクト化)
------------------------------ */
@media (max-width: 767px) {
/* 全体設定 */
body {
font-size: 16px;
}
.wrapper {
min-width: auto;
}
/* ヘッダー */
.header {
height: 80px; /* ヘッダーをコンパクトに */
}
.header-logo {
left: 20px;
font-size: 25px;
letter-spacing: 1px;
}
.header-nav {
display: none;
}
.header-contact {
display: none;
}
/* キービジュアル */
.keyvisual {
height: auto;
padding: 160px 0 100px; /* モバイル用余白調整 */
}
.keyvisual-sub {
margin-bottom: 15px;
font-size: 17px;
}
.keyvisual h1 {
font-size: 32px;
line-height: 1.4;
}
.keyvisual-text {
margin: 50px 20px 0;
text-align: left;
}
/* コンテンツ */
.contents {
width: auto;
margin: 0 20px;
padding: 50px 0 100px;
}
/* 元の記事へのリンク */
.notice {
width: auto;
}
.notice-text {
margin-bottom: 25px;
text-align: left;
}
.notice-link-image {
width: 30%;
}
.notice-link-text {
width: 70%;
padding-left: 20px;
font-size: 14px;
line-height: 1.6;
}
.notice-link a {
padding: 15px 20px;
}
.notice-link a::before {
content: normal;
}
/* セクション */
.section-wrap {
margin-top: 70px;
}
.section {
display: block; /* 縦積みレイアウト */
}
.section-image {
width: auto;
}
.section-text {
width: auto;
margin-top: 35px;
transform: translateY(150px);
}
.section-text h2 {
margin-bottom: 25px;
font-size: 24px;
}
.section-text p + p {
margin-top: 25px;
}
.section:nth-child(n+2) {
margin-top: 80px;
}
/* フッター */
.footer {
padding: 25px 0;
}
.footer-copy {
font-size: 14px;
}
}
JavaScriptコード
Intersection Observer を使って、スクロールで要素が画面内に入ったタイミングを検出します。対象の要素が見えたときに .is-visible
を付与し、CSSアニメーションが発火する仕組みになっています。
// ページ読み込み完了後に実行
document.addEventListener('DOMContentLoaded', () => {
// Intersection Observer の設定
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
// 要素がビューポート内に入ったかどうか(trueなら見えている)
if (entry.isIntersecting) {
// CSSアニメーションを発火させるクラスを付与
entry.target.classList.add('is-visible');
// アニメーションが終わったら監視を外す(繰り返し発火しないように)
obs.unobserve(entry.target);
}
});
}, {
root: null, // ビューポートを基準に監視
rootMargin: '0px 0px -35%', // 画面下の35%分を発火対象から除外して、少しスクロールしてから反応する
threshold: 0 // 要素が1pxでも見えたら判定
});
// すべての .section 要素を監視対象に追加
document.querySelectorAll('.section').forEach((el) => {
observer.observe(el);
});
});
コードのポイント解説
ここからは、コード全体の中でもスクロールアニメーションに関係する重要な部分を取り上げて解説します。
アニメーションの初期状態をCSSで定義
まず、要素が画面に現れる前の状態を CSS で設定しています。
.section-image span {
opacity: 0;
clip-path: circle(10%);
transition: opacity 1500ms cubic-bezier(0.645, 0.045, 0.355, 1),
clip-path 1500ms cubic-bezier(0.645, 0.045, 0.355, 1);
}
.section-text {
opacity: 0;
transform: translateY(200px);
transition: opacity 1500ms 500ms cubic-bezier(0.645, 0.045, 0.355, 1),
transform 1500ms 500ms cubic-bezier(0.645, 0.045, 0.355, 1);
}
- 画像部分は
opacity: 0
で透明、clip-path
で小さな円に切り取って隠しています。 - テキスト部分は
opacity: 0
+translateY(200px)
で下に隠しておきます。 transition
を設定しているので、後からクラスを付与したときに自然にアニメーションします。
今回のコードでは、アニメーションのイージングに cubic-bezier(0.645, 0.045, 0.355, 1)
を指定しています。これは 自然な加速と減速が得られるベジェ曲線で、デフォルトの ease-in-out
よりもキビキビした動きになるのが特徴です。
「なぜこの値なのか?」と思う方もいるかもしれませんが、数値自体を暗記する必要はありません。ベジェ曲線はオンラインツールで直感的に調整できるので、好みに合わせて試すのがおすすめです。
clip-path の基本的な使い方や他の形状について詳しく知りたい方は、こちらの記事をご覧ください。

可視化されたときのスタイル
画面に入ったタイミングで .is-visible
クラスを付与し、アニメーション後の状態に切り替えます。
.section.is-visible .section-image span {
opacity: 1;
clip-path: circle(50%); /* 円形クリップが広がる */
}
.section.is-visible .section-text {
opacity: 1;
transform: translateY(0px); /* 下から自然に表示 */
}
- 画像は円形クリップを拡大しながらフェードイン。
- テキストは下からスライドアップして自然に表示されます。
Intersection Observer で発火を制御
要素が画面内に入ったかどうかを検出するのが JavaScript の役割です。
// Intersection Observer の設定
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
// 要素がビューポート内に入ったかどうか(trueなら見えている)
if (entry.isIntersecting) {
// CSSアニメーションを発火させるクラスを付与
entry.target.classList.add('is-visible');
// アニメーションが終わったら監視を外す(繰り返し発火しないように)
obs.unobserve(entry.target);
}
});
}, {
root: null, // ビューポートを基準に監視
rootMargin: '0px 0px -35%', // 画面下の35%分を発火対象から除外して、少しスクロールしてから反応する
threshold: 0 // 要素が1pxでも見えたら判定
});
// すべての .section 要素を監視対象に追加
document.querySelectorAll('.section').forEach((el) => {
observer.observe(el);
});
entry.isIntersecting
… 要素がビューポート(ユーザーが見ている画面領域)内に入ったらtrue
になります。classList.add('is-visible')
… CSSアニメーションを発火させるきっかけ。unobserve(entry.target)
… 何度も繰り返し動かず、一度だけ実行。rootMargin
… 発火位置の調整。ここでは下端を 35% 縮め、要素がある程度スクロールされた時点で発火します。
今回の記事ではアニメーションの実装にフォーカスしましたが、Intersection Observer は他にも幅広く活用できます。基本的な使い方はこちらの記事で紹介しています。

まとめ
今回は、Intersection Observer を使って プラグイン不要・シンプルなスクロールアニメーション を実装する方法を紹介しました。
この仕組みを理解しておけば、フェードイン以外にもスライドインや拡大表示など、さまざまなバリエーションに応用できます。
まずは今回のコードをベースに、実際の制作案件やポートフォリオで試してみてください。
押していただけると励みになります!