🐥

テキストを1文字ずつ抜き出して、アニメーションさせる

2022/06/17に公開

WebGL と Apex の勉強をしています。
スピッカートの金山(@spicato_kana)です。

今回は、表題の通りテキストを 1 文字ずつ抜き出して、アニメーションさせる方法を紹介します。
最近、特に 1 文字ずつアニメーションさせることが多くなってきたので、JavaScript をテンプレートで作って共通化させました。

本当は npm で配信してみたかったのですが、時間がなく断念しました。。。

参考


使用例

SplitText.js
// @ts-check
export class SplitText {
  /**
   * @class SplitText
   * @constructor
   * @param {String} els - 対象要素(メイン)
   * @param {Object} [Options] - オプション
   * @param {any} [Options.target] - 対象要素(複数行の場合は行ごとに指定)
   * @param {Array} [Options.transition] - トランジションディレイがある場合は指定 (ms)
   * @param {Array} [Options.animation] - アニメーションディレイがある場合は指定 (ms)
   *
   * @example
    new SplitText(".js-s-block", {
      target: ".js-s-text",
      transition: [800],
    });
   */

  // transition, animationはどちらか一方だけ指定する
  constructor(els, { target = false, transition, animation } = {}) {
    this.els = document.querySelectorAll(els);

    if (!this.els) {
      console.error(`SplitText: ${els} が見つかりませんでした。`);
      return;
    }

    this.transition = transition;
    this.animation = animation;

    // 対象要素が複数行の場合は、それぞれに対応する
    // テキストを分割する
    this.els.forEach((element) => {
      this.target = target ? element.querySelectorAll(target) : this.els;

      this.target.forEach((target) => {
        const span = document.createElement("span");
        span.innerHTML = target.innerHTML;
        span.classList.add("visuallyHidden");
        target.insertAdjacentElement("afterend", span);

        this.chars = target.innerHTML.trim().split("");
        target.innerHTML = this._splitText();
      });
    });

    // ディレイを付与する
    if (this.transition || this.animation) {
      this._charsAddDelay();
    }
  }

  _splitText() {
    return this.chars.reduce((acc, curr) => {
      if (curr === "&") {
        curr = curr.replace("&", "&");
      } else if (curr === /\s+/) {
        curr = curr.replace(/\s+/, " ");
      }

      return `${acc}<span class="char" aria-hidden="true">${curr}</span>`;
    }, "");
  }

  _charsAddDelay() {
    this.els.forEach((element) => {
      const chars = element.querySelectorAll(".char");
      const transition = this.transition;
      const animation = this.animation;

      chars.forEach((char, index) => {
        let transitionDelayTime;
        let animationDelayTime;

        if (transition) {
          if (transition.length === 1) {
            transitionDelayTime = Math.round(transition[0] * index) / 1000 + "s";
          } else {
            transitionDelayTime = (() => {
              return transition.reduce((acc, curr) => {
                const diff = curr - acc;
                return `${Math.round(acc * index) / 1000}s, ${Math.round(acc * index + diff) / 1000}s`;
              });
            })();
          }
        }
        if (animation) {
          if (animation.length === 1) {
            animationDelayTime = Math.round(animation[0] * index) / 1000 + "s";
          } else {
            animationDelayTime = (() => {
              return animation.reduce((acc, curr) => {
                const diff = curr - acc;
                return `${Math.round(acc * index) / 1000}s, ${Math.round(acc * index + diff) / 1000}s`;
              });
            })();
          }
        }

        if (this.animation && this.transition) {
          char.setAttribute("style", `animation-delay: ${animationDelayTime}; transition-delay: ${transitionDelayTime};`);
        } else if (this.animation) {
          char.setAttribute("style", `animation-delay: ${animationDelayTime};`);
        } else if (this.transition) {
          char.setAttribute("style", `transition-delay: ${transitionDelayTime};`);
        }
      });
    });
  }
}
index.js
// @ts-check
import { SplitText } from "./SplitText";

document.addEventListener("DOMContentLoaded", () => {
  new SplitText(".js-s-block", {
    target: ".js-s-text",
    transition: [400],
  });
});
index.html
<h1 class="js-s-block">
  <span class="js-s-text">見出し</span>
  <span class="js-s-text">タイトル</span>
  <span class="js-s-text">見出し</span>
</h1>

<h2>
  <span class="js-text">見出し02</span>
</h2>
index.html(結果)
<h1 class="js-s-block">
  <span class="js-s-text">
    <span class="char" aria-hidden="true" style="animation-delay: 0s"></span>
    <span class="char" aria-hidden="true" style="animation-delay: 0.4s"></span>
    <span class="char" aria-hidden="true" style="animation-delay: 0.8s"></span>
  </span>
  <span class="visuallyHidden">見出し</span>

  <span class="js-s-text">
    <span class="char" aria-hidden="true" style="animation-delay: 1.2s"></span>
    <span class="char" aria-hidden="true" style="animation-delay: 1.6s"></span>
    <span class="char" aria-hidden="true" style="animation-delay: 2s"></span>
    <span class="char" aria-hidden="true" style="animation-delay: 2.4s"></span>
  </span>
  <span class="visuallyHidden">タイトル</span>

  <span class="js-s-text">
    <span class="char" aria-hidden="true" style="animation-delay: 2.8s"></span>
    <span class="char" aria-hidden="true" style="animation-delay: 3.2s"></span>
    <span class="char" aria-hidden="true" style="animation-delay: 3.6s"></span>
  </span>
  <span class="visuallyHidden">見出し</span>
</h1>

使用方法解説

拙いコードで恐縮ですが、使用方法を解説していきます。。。!

テキスト分割

<div class="js-split-text">テキストテキスト</div>

このように、1 列のテキストの場合は、

new SplitText(".js-split-text");

で 1 文字ずつに分割することができます。

複数行の場合は、使用例のように記述します。

<div class="js-split-text">
  <div class="js-split-text-target">テキストテキスト</div>
  <div class="js-split-text-target">テキストテキスト</div>
</div>
new SplitText(".js-split-text", {
  target: ".js-split-text-target",
});

アニメーション

アニメーションさせる時は、基本ディレイをかけることが多いと思います。
ただ、SCSS の for 文でディレイをかけることができますが、テキストの文字数が毎回同じだと限らないので、この関数でディレイを付与することができます。

あとは、CSS でアニメーションを付けるもよし、 JavaScript でアニメーションを付けるもよし、どちらもいいでしょう。

new SplitText(".js-split-text", {
  target: ".js-split-text-target",
  animation: [400], // msで指定
});

そして、複数行の場合は、1 行目のアニメーションが終わってから、2 行目のアニメーションが始まるようになっています。

また、

.hoge {
  animation: test ease-in-out 0.5s, test2 ease-in-out 0.5s;
  animation-delay: 0.2s, 0.3s;

  transition: transform ease-in-out 0.5s, opacity ease-in-out 0.5s;
  transition-delay: 0.2s, 0.3s;
}

上記のように、ディレイを分けてかけたい場合の時は、2 つ以上の数字を指定します。

new SplitText(".js-split-text", {
  target: ".js-split-text-target",
  animation: [400, 600], // msで指定
});

これで、

<span class="js-s-text">
  <span class="char" aria-hidden="true" style="animation-delay: 1.2s, 1.4s"></span>
  <span class="char" aria-hidden="true" style="animation-delay: 1.6s, 1.8s"></span>
  <span class="char" aria-hidden="true" style="animation-delay: 2s, 2.2s"></span>
  <span class="char" aria-hidden="true" style="animation-delay: 2.4s, 2.6s"></span>
</span>

のように、分けてディレイをかけることができます!
あとは、ディレイ合わせてアニメーション等を設定しましょう!


まとめ

今回は、1 文字ずつに分割する方法を紹介しました。
最近案件で使用することが多かったので、流用できるように作成しました。

ファーストビューのアニメーションの時は CSS Animation を使用し、スクロールアニメーションのときは、Intersection Observer を使用してクラスを付与してアニメーションさせています。

次は、よく流用する Intersection Observer を紹介したいと思います。

また、今回アクセシビリティを考慮して 1 文字ずつ分割したものにはaria-hidden="true"を付与して読み上げをしないようにしています。
もとのテキストを複製して、visuallyHiddenというクラスに付与しています。
ちなみに、visuallyHiddenの CSS は下記のようにしていしています。

.visuallyHidden {
  position: absolute !important;
  width: 1px !important;
  height: 1px !important;
  margin: -1px !important;
  padding: 0 !important;
  overflow: hidden !important;
  clip: rect(0 0 0 0) !important;
  white-space: nowrap !important;
  border: 0 !important;
}

しかし、読み上げ機能を使用して確認したら分割したままでも普通に読み上げてくれたので、必要ないかも知れません。。。

ただ、Chrome のデベロッパーツールのアクセシビリティツリーを確認すると、表示が気持ち悪かったので今回はテキストを複製させています。

テキスト複製なし
テキスト複製なし

テキスト複製あり
テキスト複製あり

spicato Inc.

Discussion