logo
コーディング

更新日

JavaScriptのIntersection Observerでスクロールに合わせてグラデーションの色を変更する

新しいMacbook Proが発売され、特設ページが公開されています。そのページの中で私の目に止まったのは、Macbook Proのスペックではなく、スクロールに合わせて動くグラデーションカラーのテキストでした。今回はこれをJavaScriptの Intersection Observer を使って実装した例を紹介します。

この記事は動画でも紹介しています。動画派の方はこちらを御覧ください!

このテキストを実装したい!

Macbook Pro の紹介ページにあるグラデーションカラーのテキストです。スクロールするとグラデーションの位置も変わるのがわかりますね!

Intersection Observer とは?

従来、スクロールに合わせて要素を操るには scroll というイベントを利用していました。ただ、それだと画面サイズが変わったら再計算しないといけなかったり、スクロールするたびに関数を呼び出すので、パフォーマンスへの悪影響が懸念されていました。

今回の例では特定の要素が可視領域に入ったらなんかしらの動作をすればいいので、そんな場合は Intersection Observer が使えます。 Intersection Observer特定の要素が指定領域内に入ったかどうかを検知するAPI です。前述の scroll を使った手法と違ってスクロールするたびに反応するわけではないので、パフォーマンス面を見ても推奨されます。

Intersection Observer の使い方

基本的には IntersectionObserver を呼び出して、第 1 引数に実行したい関数、第 2 引数にオプション設定を記述します。オプションは初期値でよければなくても OK。

const options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "0px",
  threshold: 1.0,
};

const observer = new IntersectionObserver(callback, options);

options  オブジェクトは、以下のフィールドがあります:

root

ターゲットとなる要素が見えるかどうかを判定するためのベース部分を指定します。デフォルトはブラウザーのビューポートです。

rootMargin

交差を検知する、上記 root からの距離です。これを利用してイベントを発生させる位置を調整できます。例えば 10px を指定した場合、交差の判定を root 要素の周りから 10px 分拡大します。見える少し前に実行させたい時に使えますね。要素が見えてきてから実行させたい時は負の値を指定するといいでしょう。記述方法は CSS の margin とほぼ同じですが、単位は px か % に限られます。デフォルトは 0px です。

threshold

関数を実行するタイミングを 0〜1 の間で記述します。ターゲットとなる要素が見え始めた瞬間と見え終わりの瞬間が 0、半分通過したときは 0.5、すべて見えている状態が 1 です。[0, 0.5, 1] のように配列形式でも記述できます。その場合は交差量が 0、50%、100%の時にコールバック関数が呼ばれます。デフォルトでは 0 です。

簡単な実装例

スクロールをして、見出しテキストの位置にくるとふわっと表示させました。最初に CSS で opacity: 0 を指定して透明にしておき、threshold が 1 のとき、つまり見出しがすべて表示された時に heading.style.opacity = 1; とすることで、不透明にして現れます。 ※ うまく見えない時は上のパネルの右下にある「Rerun」ボタンをクリック!

CSS のクラスを足すことでより細かい動きも実装できますね!

それでは実際に、この Intersection Observer を使ってグラデーションカラーを動かしてみましょう!

1. グラデーションカラーのテキストを CSS で作成

まずはベースとなる装飾を CSS で加えておきます。見出しの背景に linear-gradient でグラデーションカラーを用意し、background-clip: text; でくり抜いてグラデーションのテキストを用意します。

HTML

<h1 id="heading">恐るべきスピード。</h1>
<p id="paragraph">
  M1アーキテクチャの卓越したパフォーマンスを、プロユーザーのためにまったく新しいレベルに進化させるチップ。…
</p>

CSS

#heading {
  background-image: linear-gradient(
    45deg,
    rgb(37, 47, 255) 0%,
    rgb(124, 192, 226) 100%,
    rgb(37, 47, 255) 200%
  );
  font-size: 4rem;
  font-weight: bold;
  -webkit-text-fill-color: transparent;
  text-fill-color: transparent;
  -webkit-background-clip: text;
  background-clip: text;
}
#paragraph {
  font-size: 21px;
  color: #a1a1a6;
  line-height: 1.4;
}

2. Intersection Observer の設定

今回は見出しの色を変更したいのですが、着火点はその下にある p の要素とします。そうすることで見出しが見えなくなるときにグラデーションが固定されてチラチラ動いたりしないからです。

// 着火点となる要素
const p = document.getElementById("paragraph");

const options = {
  threshold: 1,
};

// 実行するよ
const observer = new IntersectionObserver(showElements, options);

// p に到達したら発動
observer.observe(p);

// 要素が表示されたら実行する動作
function showElements() {
  console.log("hey!");
}

この段階では「p 要素がすべて表示されたら、コンソールに hey! と表示」されるようになります。

3. オプションを加える

要素が見えた位置=交差の量に合わせてグラデーションカラーの位置を動かしたいので、threshold を配列で細かく指定する必要があります(細かくないとカクカク動いちゃいます)。上記で 1 と指定していた thresholdbuildThresholdList() という関数とし、比率を 20 段階で設定。

// 着火点となる要素
const p = document.getElementById("paragraph");

const options = {
  threshold: buildThresholdList(),
};

// 実行するよ
const observer = new IntersectionObserver(showElements, options);

// p に到達したら発動
observer.observe(p);

// threshold の設定
function buildThresholdList() {
  let thresholds = [];
  let numSteps = 20;

  for (let i = 1; i <= numSteps; i++) {
    let ratio = i / numSteps;
    thresholds.push(ratio);
  }
  return thresholds;
}

// 要素が表示されたら実行する動作
function showElements() {
  console.log("hey!");
}

4. 要素が見えた割合にあわせてグラデーションカラーの位置を動かす

関数の第 1 引数には、IntersectionObserverEntry オブジェクトを記述します。isIntersecting はターゲットの要素が root と交差しているかどうかを真偽値で返します。intersectionRatio は交差している割合を 0〜1 の範囲で返します。この intersectionRatio をグラデーションカラーの位置に使用します。

まずはこんな感じで console.logintersectionRatio がちゃんと反応するか確認。

// 要素が表示されたら実行する動作
function showElements(entries) {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log(entry.intersectionRatio);
    }
  });
}

検知されていますね!

あとはスタイルを適用させるだけ! intersectionRatio の値は 0〜1 の数値なので、パーセンテージにするために 100 をかけます。さらに Math.round で小数点以下の端数を四捨五入。この数値を元の CSS で指定していたパーセンテージから引いて色を動かしていきます。

// 要素が表示されたら実行する動作
function showElements(entries) {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      let ratio = Math.round(entry.intersectionRatio * 100);

      const heading = document.getElementById("heading");
      heading.style.backgroundImage = `
        linear-gradient(
          45deg,
          rgb(37, 47, 255) ${0 - ratio}%,
          rgb(124, 192, 226) ${100 - ratio}%,
          rgb(37, 47, 255) ${200 - ratio}%
      )`;
    }
  });
}

完成!

こんな感じで仕上がりました!実際の Apple のページではもう少し多くの色を指定していますが、これだけでも十分きれいですね!色を変更してカスタマイズしてみるといいでしょう!参考になれば幸いです。