JavaScript

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

みなと

Webサイトをスクロールしていると、画像やテキストがふわっと現れたり、下からスライドしてきたりする演出をよく見かけます。こうした「スクロールアニメーション」は、ユーザーの視線を自然に誘導できるため、実務の現場でも多用される表現です。

アニメーションをうまく使うことで、

  • コンテンツの読みやすさを高める
  • 情報のメリハリをつける
  • サイト全体の印象をリッチにする

といった効果が期待できます。

スクロールアニメーションを実現するための外部ライブラリ(例:jQueryプラグインなど)は数多くありますが、近年のブラウザで標準的に使える Intersection Observer を活用すれば、プラグインに頼らずスムーズなアニメーションを実装できます。

そこで今回は、HTML・CSS・JavaScriptのみでシンプルに作れるスクロールアニメーション を解説します。実務ですぐ使える完成デモとともに、実装の流れとポイントを紹介します。

Intersection Observer の基本から学びたい方は、こちらの記事もあわせてご覧ください。

あわせて読みたい
Intersection Observer 入門|スクロールに応じた処理をデモで直感的に理解しよう!
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: 0translateY(200px) で下に隠しておきます。
  • transition を設定しているので、後からクラスを付与したときに自然にアニメーションします。

今回のコードでは、アニメーションのイージングに cubic-bezier(0.645, 0.045, 0.355, 1) を指定しています。これは 自然な加速と減速が得られるベジェ曲線で、デフォルトの ease-in-out よりもキビキビした動きになるのが特徴です。

「なぜこの値なのか?」と思う方もいるかもしれませんが、数値自体を暗記する必要はありません。ベジェ曲線はオンラインツールで直感的に調整できるので、好みに合わせて試すのがおすすめです。

cubic-bezier.com では、実際にハンドルを動かしてカーブを確認しながら、自分に合ったイージングを作成できます。

clip-path の基本的な使い方や他の形状について詳しく知りたい方は、こちらの記事をご覧ください。

あわせて読みたい
CSSで複雑な形も簡単設定!実務でも使えるclip-pathの基本形状と応用例を紹介
CSSで複雑な形も簡単設定!実務でも使える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 入門|スクロールに応じた処理をデモで直感的に理解しよう!
Intersection Observer 入門|スクロールに応じた処理をデモで直感的に理解しよう!

まとめ

今回は、Intersection Observer を使って プラグイン不要・シンプルなスクロールアニメーション を実装する方法を紹介しました。

この仕組みを理解しておけば、フェードイン以外にもスライドインや拡大表示など、さまざまなバリエーションに応用できます。

まずは今回のコードをベースに、実際の制作案件やポートフォリオで試してみてください。

押していただけると励みになります!

ABOUT ME
みなと
みなと
フロントエンドエンジニア
東京のWeb制作会社で15年以上働いている現役フロントエンドエンジニアです。これまで、いろんなプロジェクトに関わりながら、フロントエンド開発やWebデザインに取り組んできました。このブログでは、今までの経験を活かして、Web制作に役立つ情報やノウハウをシェアしていきたいと思います。初心者の方から、現場で働く方まで、誰でも参考にできる内容をお届けしますので、ぜひ覗いてみてください。
記事URLをコピーしました