このサイトのセクションやカードは、スクロールして画面に入ると、ふわっとフェードインしながら少し下から上に上がってきます。演出自体はよくあるものですが、「全セクション共通のクラス一つで実現する」ことにこだわりました。

クラスを貼るだけにする

各コンポーネントの見出しやカードには、機能ごとに違うクラス名を付けるのではなく、すべて .reveal の一つだけを付けています。

<div class="section-head reveal">...</div>
<div class="card service-card reveal">...</div>
<div class="tl-item reveal">...</div>

見た目(フェード+移動)の定義はCSS側に一箇所だけ書き、JS側は「画面に入ったら .in を付ける」という一つの仕事しかしません。新しいセクションを追加するときも reveal を貼るだけで演出が揃うので、実装のたびに毎回アニメーションを設計し直す必要がありません。

IntersectionObserverは一つを使い回す

要素の数だけ IntersectionObserver を作るのではなく、ページ全体で一つのオブザーバーに全 .reveal 要素をまとめて監視させています。

const io = new IntersectionObserver(
  (entries) => {
    entries.forEach((en) => {
      if (en.isIntersecting) {
        en.target.classList.add("in");
        io.unobserve(en.target);
      }
    });
  },
  { threshold: 0.12, rootMargin: "0px 0px -8% 0px" }
);
els.forEach((e) => io.observe(e));

unobserve を呼んで監視対象から外しているのも重要な点です。一度現れた要素を再度観測し続ける理由はないので、表示済みの要素はすぐに監視リストから外し、コールバックの呼び出しコストを増やさないようにしています。

rootMargin: "0px 0px -8% 0px" は、画面の下端ぎりぎりで反応させないための調整です。ビューポート下部8%を「まだ見えていない」扱いにすることで、要素がしっかり見える位置まで来てから演出が始まるようにしています。

スタガー(ずらし)は index を使ったCSS変数で

複数のカードが同時にフェードインすると、一斉に動いて単調に見えます。そこで兄弟要素ごとに少しずつ開始を遅らせる「スタガー」を、JS側の一行だけで実現しています。

els.forEach((e, i) => {
  e.style.transitionDelay = (i % 4) * 70 + "ms";
  io.observe(e);
});

インデックスを4の剰余で丸めているのがポイントです。単純に i * 70ms にすると、要素数が多いセクション(スキルカードの一覧など)で最後の要素が現れるまで数秒待たされてしまいます。4件ごとに遅延をリセットすることで、「近い要素同士は少しずつずれるが、全体としては待ち時間が発散しない」バランスを取っています。

IntersectionObserverが無い環境へのフォールバック

対応していない古いブラウザ向けに、IntersectionObserver が存在しない場合は最初から全要素に .in を付けてしまう分岐を用意しています。

if (!("IntersectionObserver" in window)) {
  els.forEach((e) => e.classList.add("in"));
  return;
}

演出が動かないより、演出なしで普通にコンテンツが見える方が圧倒的にマシです。装飾的なアニメーションほど、「動かなかったときに何が起きるか」を先に決めておく価値があると感じています。