JavaScriptで1文字ずつ表示するアニメーションを実装する方法



Webサイトのデザインにおいては、アニメーション効果でユーザーの視線や印象としてインパクトを与えることは、情報を伝えるのにとても効果的です。
キャッチなフレーズなどのテキストにもアニメーション効果を加えることで、より強く記憶に残りやすくなり、情報の理解も深まります。

様々なアニメーション効果がある中、
ここでは、JavaScriptを使ってフェード効果のアニメーションで、テキストを1文字ずつ表示する方法についてご紹介します。

ご紹介する内容は、ファーストビューやスクロール時でのアニメーション表示など、Webページを閲覧していく中で、効果的に活用していくことができます。

ただ、重要なキーワードを含むテキストは、初期状態で非表示ですと、LLMO(Large Language Model Optimization:大規模言語モデル最適化)の観点から、生成AIに認識されない可能性もあるので、アニメーション表示させるテキストは、コンテンツ情報を把握したうえで選定しましょう。
特に、何かの説明の見出しとなる文言は避けましょう。

1文字ずつ表示するアニメーションの実装


ここでは、Webページのファーストビューでの表示や、スクロールで可視範囲に入った際のアニメーション表示など、実用的なサンプルで説明していきます。

まずは、Webページのファーストビューでの例を見ていきます。

HTMLは以下のような構造で構築します。

HTML

<div id="text-container">
  <h2>Headline</h2>
</div>


「id=”text-container”」というIDを持つ div は、CSSやJavaScriptで操作する際の目印とし、アニメーションで表示したいテキストを h2 タグに入れておきます。

続いてCSS。
CSSでは、レイアウトの調整のほか、フェード効果のアニメーションを設定します。

CSS

#text-container {
  text-align: center;
}

#text-container h2 {
  display: inline-block;
  text-align: left;
  opacity: 0; /* 初期状態では非表示 (JavaScriptで表示を制御) */
}

#text-container span {
  opacity: 0; /* 初期状態では非表示 */
  transition: opacity 0.2s; /* 0.2秒かけてフェードインするアニメーション */
}


text-container と #text-container h2 で見た目の初期設定と配置を設定しています。
アニメーションで表示したいテキストを中央に配置し、h2要素は初期状態で非表示しておきます。
h2要素の横幅は、JavaScriptで動的に設定していきます。

text-container span のspanタグは、JavaScriptで動的に1文字ずつ囲う際に生成するものです。
こちらも初期状態は非表示にしておき、表示する際にフェード効果のアニメーションを実装するようにします。

続いてJavaScript。

JavaScriptのプログラムは、アニメーション表示させるh2要素を把握や、初期状態のスタイルが適用されている必要があるため、window.onload でHTMLやCSS、画像など、ページのすべての要素が読み込み終わってから処理を開始するようにします。

JavaScript

window.onload = () => {
  const container = document.getElementById('text-container');
  const paragraph = container.querySelector('h2'); // h2要素を取得
  const text = paragraph.textContent.trim();       // 元のテキストを取得

  setTimeout(() => {
    // 元のスタイルを一時的に保持
    const originalVisibility = paragraph.style.visibility;
    const originalPosition = paragraph.style.position;
    const originalWhiteSpace = paragraph.style.whiteSpace;

    // 正確な幅を計算するために一時的にスタイルを変更
    paragraph.style.visibility = 'hidden';   // 計算中は見えないように
    paragraph.style.position = 'absolute';   // 親のレイアウトに影響を与えないように
    paragraph.style.whiteSpace = 'nowrap';   // テキストが折り返されないように
    paragraph.textContent = text;            // 計算のために一時的に全テキストを戻す

    const textWidth = paragraph.offsetWidth; // この時点でのh2の幅を取得

    // スタイルとテキストをアニメーション用に元に戻す
    paragraph.textContent = '';
    paragraph.style.whiteSpace = originalWhiteSpace;
    paragraph.style.position = originalPosition;
    paragraph.style.visibility = originalVisibility;

    // 計算した幅をh2要素に設定
    if (textWidth > 0) { // 幅が0より大きい場合のみ設定
      paragraph.style.width = `${textWidth}px`;
    }

    // h2要素自体を表示状態にする
    paragraph.style.opacity = 1; // CSSの opacity:0 を上書きして表示開始

    // 1文字ずつ表示するアニメーション処理
    let index = 0;
    const displayInterval = 50; // 次の文字を表示するまでの時間 (ミリ秒)
    const fadeInDelay = 50;     // 文字をDOMに追加してからフェードインを開始するまでの遅延 (ミリ秒)

    const displayNextCharacter = () => {
      if (index < text.length) {
        const span = document.createElement('span'); // 新しいspan要素を作成
        span.textContent = text[index] === ' ' ? '\u00A0' : text[index]; // 1文字を設定
        paragraph.appendChild(span); // h2要素に追加

        // 非同期でフェードイン
        setTimeout(() => {
          span.style.opacity = 1; // フェードイン(CSSのtransitionが発動)
        }, fadeInDelay);

        index++;
        setTimeout(displayNextCharacter, displayInterval); // 次の文字を表示するまで待機
      }
    };

    displayNextCharacter(); // アニメーション実行
  }, 1000); // 1秒後に実行
};


text-container のdivや、アニメーション表示させるh2要素を取得したあと、setTimeout でwindow.onload が発生してから、さらに1秒待ってからアニメーション関連の処理を開始します。
h2要素は一旦空にしておきます。

中央配置でさらにコンテンツ幅の左から順番に表示する場合は、文字列全体の描画幅を計算する必要があります。
const textWidth = paragraph.offsetWidth; までのプログラムが、描画幅の計算と幅を取得になります。そのあと一旦、アニメーションのために paragraph.textContent を空にし、スタイルを戻しておきます。
取得したh2要素の幅は paragraph.style.width = `${textWidth}px`; でh2要素に設定します。
ここまでレイアウト調整は完了です。

アニメーション表示の実装では、
まず、文字数カウンタ(index)や文字を表示するまでの時間(displayInterval)、文字をDOMに追加してからフェードインを開始するまでの遅延(fadeInDelay)などの値を変数で管理します。
displayNextCharacter という関数を作成し、if文の処理内で文字数分だけspan要素の追加、1文字の設定、h2要素に追加、フェード処理を行います。
そしてsetTimeoutを使い、次の文字を表示するまで待機をして1文字ずつのフェード効果のアニメーションを実装しています。

上記のプログラムは、主にWebページのファーストビューで活用できるでしょう。

スクロールで可視範囲に入ったら表示する場合


ページをスクロールする中で、可視範囲に入ったらアニメーションで表示する場合は、JavaScriptでスクロール位置を把握して処理を実行していくことになります。
HTMとCSSの調整も含めて見ていきます。

まずはHTML。

HTML

<section>
  <div class="text-container">
    <h2>Headline</h2>
  </div>
</section>

<section>
  <div class="text-container ef">
    <h2>Headline</h2>
  </div>
</section>

<section>
  <div class="text-container ef">
    <h2>Headline</h2>
  </div>
</section>


section のコンテンツにh2要素がある中、スクロールして見ていくコンテンツのdivにはクラス名「ef」を付与しておき、JavaScriptでの操作の目印とします。

続いてCSS。

CSS

section {
  min-height: 100vh;
  background-color: #eee;
  padding: 1rem;
  &:nth-child(even) {
    background-color: #fff;
  }
}

.text-container {
  text-align: center;
}

.text-container h2 {
  display: inline-block;
  text-align: left;
}

.text-container.ef h2 {
  visibility: hidden; /* 初期状態では非表示 */;
}

.text-container.ef h2.visible {
  visibility: visible; /* スクロールで可視範囲に入ったら表示 */
}

.text-container h2 span {
  opacity: 0; /* 初期状態では非表示 */
  transition: opacity 0.2s; /* フェードインアニメーション */
}


section のスタイルは、ページ全体のレイアウトの調整になります。
h2のスタイルは、通常の見出しとは別にして、アニメーション表示させるクラス名「ef」のコンテンツに対して、初期状態の非表示のスタイルを適用させておきます。

続いてJavaScript。
以下、サンプルコードになります。

JavaScript

window.onload = () => {
  const fadeElems = document.querySelectorAll(".ef");

  const checkVisibility = () => {
    const windowHeight = window.innerHeight;
    const visibleThreshold = windowHeight / 5; // 画面の5分の1が見えたら実行

    fadeElems.forEach((fadeElem) => {
      if (fadeElem.dataset.animated === "true") {
        return; // 既にアニメーション済みならスキップ
      }

      const rect = fadeElem.getBoundingClientRect();

      // 要素の上端が画面下部からthreshold分入ったか、または要素の下端が画面上部からthreshold分入ったか
      if (rect.top < windowHeight - visibleThreshold && rect.bottom > visibleThreshold / 2 ) {
        fadeElem.dataset.animated = "true"; // 一度だけ実行するようにフラグを立てる
        animateText(fadeElem);
      }
    });
  };

  const animateText = (fadeElem) => {
    const paragraph = fadeElem.querySelector("h2");
    if (!paragraph) return;

    const originalText = paragraph.textContent.trim();
    paragraph.textContent = ''; // アニメーションのために一旦空にする

    // 全テキスト表示時の幅を計算
    // 一時的に元のテキストをh2に書き戻し、スタイルを調整して幅を取得
    // paragraphの現在のスタイルを保持
    const originalVisibility = paragraph.style.visibility;
    const originalPosition = paragraph.style.position;
    const originalWhiteSpace = paragraph.style.whiteSpace;

    paragraph.style.visibility = 'hidden'; // 計算中は見えないように
    paragraph.style.position = 'absolute'; // 親のレイアウトに影響を与えず、正確な幅を取得するため
    paragraph.style.whiteSpace = 'nowrap'; // テキストが折り返されないようにして正確な幅を計算
    paragraph.textContent = originalText;    // 元のテキストを設定

    const textWidth = paragraph.offsetWidth; // 幅を取得

    // スタイルとテキストを元に戻す
    paragraph.textContent = '';            // アニメーションのために再度空にする
    paragraph.style.whiteSpace = originalWhiteSpace; // white-spaceを元に戻す
    paragraph.style.position = originalPosition;     // positionを元に戻す
    paragraph.style.visibility = originalVisibility; // visibilityを元に戻す (この後.visibleクラスで制御)

    // 計算した幅をh2要素に設定
    paragraph.style.width = `${textWidth}px`;

    // アニメーション処理
    let index = 0;
    const displayInterval = 60; // 1文字あたりの表示遅延 (ミリ秒)
    const fadeInDelay = 50;     // DOM追加からフェードイン開始までの遅延 (ミリ秒)

    const displayNextCharacter = () => {
      if (index < originalText.length) {
        const span = document.createElement("span");
        span.textContent = originalText[index] === ' ' ? '\u00A0' : originalText[index]; // 半角スペースをnbspに変換

        paragraph.appendChild(span);

        setTimeout(() => {
          span.style.opacity = 1;
        }, fadeInDelay);

        index++;
        setTimeout(displayNextCharacter, displayInterval);
      }
    };

    // h2要素を可視化し(CSSで visibility: visible になる)、アニメーション開始
    paragraph.classList.add("visible");
    displayNextCharacter();
  };

  // スクロールイベントの最適化
  let ticking = false;
  window.addEventListener("scroll", () => {
    if (!ticking) {
      window.requestAnimationFrame(() => {
        checkVisibility();
        ticking = false;
      });
      ticking = true;
    }
  });

  setTimeout(checkVisibility, 100);
};


まず、fadeElems = document.querySelectorAll(“.ef”); で.efというクラス名がついた全ての要素を取得します。
checkVisibility関数では、各.ef要素が画面の可視範囲に入ったかどうかをチェックします。window.innerHeight でブラウザウィンドウの高さを、fadeElem.getBoundingClientRect() で要素の位置情報を取得しています。
こちらは、スクロールイベントの処理でスクロールするたびに呼び出されます。

要素の上端が画面下部からthreshold分入ったか、または要素の下端が画面上部からthreshold分入ったかの条件を満たせば、animateText(fadeElem) を呼び出します。
animateText関数は、実際にテキストアニメーションを行う中心的な関数で、引数で渡された要素(fadeElem)の中から h2タグを探してから、幅を計算したりアニメーションの準備、計算した幅をh2要素に設定、そしてアニメーション処理など、先ほどと同じ流れのプログラムになります。

スクロールイベントの最適化では、window.addEventListener(“scroll”, …) でスクロールイベントを監視します。
スクロールイベントは頻繁に発生するため、パフォーマンス低下を防ぐために window.requestAnimationFrame を利用しています。これにより、ブラウザの描画タイミングに合わせて checkVisibility が効率よく実行されます。ticking 変数は、処理が重複しないように制御するためのフラグです。

最後の setTimeout(checkVisibility, 100); は、ページ読み込み完了から100ミリ秒後に一度 checkVisibility を呼び出し、ページがロードされた時点で既に画面内に表示されている要素があれば、スクロールしなくてもアニメーションが開始します。
これは初回チェックになります。

特定の要素が可視範囲に入ったら表示する場合


先ほどまでの例では、h2要素の見出しをアニメーション表示する話で進んでいましたが、特定の要素が可視範囲に入ったら表示する場合は、ターゲットとなる要素を目印に処理を実行していきます。

例えば、HTMLは以下のような構造とします。

HTML

<section>
  <div class="hl-block">
    <h2>Headline</h2>
  </div>
</section>

<section>
  <div class="hl-block">
    <h2 class="ef">Headline</h2>
  </div>
</section>

<section>
  <div class="hl-block">
    <h2>Headline</h2>
  </div>
  <h3 class="ef">h3 Headline</h3>
</section>


h2見出しの構造だけ分けた、よくあるレイアウトにしております。
クラス名「ef」が付与されている要素が、アニメーション表示の対象の要素となります。

続いてCSS。

CSS

section {
  min-height: 100vh;
  background-color: #eee;
  padding: 1rem;
  &:nth-child(even) {
    background-color: #fff;
  }
}

.hl-block {
  text-align: center;
}

.ef {
  display: inline-block;
  text-align: left;
  visibility: hidden; /* 初期状態では非表示 */
}

.ef.visible {
  visibility: visible;
}

.ef span {
  opacity: 0; /* 初期状態では非表示 */
  display: inline-block; /* transitionを確実に適用するため */
  transition: opacity 0.2s ease-out; /* フェードインアニメーション */
}


クラス名「ef」が付与されている要素を対象として、初期状態の非表示のスタイルや、フィードインアニメーションのスタイルを適用させておきます。

続いてJavaScript。
アニメーション対象要素の「.ef」を取得して、それをもとにプログラムを実行していきます。

JavaScript

window.onload = () => {
  // すべてのフォントが読み込まれるのを待つ
  document.fonts.ready.then(() => {
    const fadeElems = document.querySelectorAll(".ef"); // アニメーション対象要素を取得

    const checkVisibility = () => {
      const windowHeight = window.innerHeight;
      const visibleThreshold = windowHeight / 5;

      fadeElems.forEach((fadeElem) => {
        if (fadeElem.dataset.animated === "true") {
          return;
        }
        const rect = fadeElem.getBoundingClientRect();
        if (rect.top < windowHeight - visibleThreshold && rect.bottom > visibleThreshold / 2) {
          fadeElem.dataset.animated = "true";
          animateText(fadeElem);
        }
      });
    };

    const animateText = (animatedElement) => {
      if (!animatedElement) return;
      const originalText = animatedElement.textContent.trim();

      // 元のスタイルを一時的に保持
      const originalVisibility = animatedElement.style.visibility;
      const originalPosition = animatedElement.style.position;
      const originalWhiteSpace = animatedElement.style.whiteSpace;

      // 正確な幅を計算するために一時的にスタイルを変更
      animatedElement.style.visibility = 'hidden';
      animatedElement.style.position = 'absolute';
      animatedElement.style.whiteSpace = 'nowrap'; // 計算時は折り返しなし
      animatedElement.textContent = originalText;  // 計算のために一時的に全テキストを戻す

      // 幅計算
      const preciseWidth = animatedElement.getBoundingClientRect().width;
      const textWidth = Math.ceil(preciseWidth); // 小数点以下を切り上げて整数にする

      // スタイルとテキストをアニメーション用に元に戻す
      animatedElement.textContent = ''; // アニメーションのために再度空にする
      animatedElement.style.whiteSpace = originalWhiteSpace; // 元のwhite-spaceに戻す
      animatedElement.style.position = originalPosition;
      animatedElement.style.visibility = originalVisibility;

      // 計算した幅を要素に設定
      animatedElement.style.width = `${textWidth}px`;

      // アニメーション処理
      let index = 0;
      const displayInterval = 60;
      const fadeInDelay = 50;

      const displayNextCharacter = () => {
        if (index < originalText.length) {
          const span = document.createElement("span");
          span.textContent = originalText[index] === ' ' ? '\u00A0' : originalText[index];
          animatedElement.appendChild(span);

          setTimeout(() => {
            span.style.opacity = 1;
          }, fadeInDelay);

          index++;
          setTimeout(displayNextCharacter, displayInterval);
        }
      };

      animatedElement.classList.add("visible"); // CSSで visibility: visible にする
      displayNextCharacter();
    };

    // スクロールイベントの最適化
    let ticking = false;
    window.addEventListener("scroll", () => {
      if (!ticking) {
        window.requestAnimationFrame(() => {
          checkVisibility();
          ticking = false;
        });
        ticking = true;
      }
    });

    // 初回のチェック (フォント読み込み後、少し遅延させてレイアウト安定を待つ)
    setTimeout(checkVisibility, 100);

  }).catch(err => {  // フォントの読み込みに失敗した場合のエラーハンドリング
    console.error(err);
  });
};


document.fonts.ready.then(() => {… } では、すべてのフォントが読み込まれるのを待ってから処理を実行するようにします。
これは、フォントが読み込まれる前に処理が実行されると、ページで使用されているウェブフォントなどがまだ完全に読み込まれていない場合、ブラウザは一時的に代替フォント(デフォルトフォント)でテキストの幅を計算するため、少しの幅の違いからテキストが改行される恐れがあるからです。
catch文は例外処理として、エラーメッセージを返して確認したり、またフォントの読み込みに失敗した場合の処理も記述してもOKです。
そのほかのプログラムは、先ほどとほとんど同じ処理となります。

まとめ


1文字ずつ表示するアニメーションの全体の流れとしては、HTML構造に合わせてCSSでスタイルを適用し、初期状態は非表示としておき、また表示した際のアニメーションを設定しておきます。
JavaScriptで実装する内容は、テキストの取得から、必要に応じて要素の幅を取得してレイアウト調整し、その後にテキストを1文字ずつspan要素に分割してから、1文字ずつをアニメーションで表示する流れとなります。

ページアクセス後の実行のタイミングは、主にsetTimeout で遅延を管理します。
またスクロール時の処理が必要な場合は、スクロールイベントの処理で関数を呼び出すなどして対応していきます。