このポートフォリオは、巨大な「FUJI」のタイポグラフィを中心に据えた1ページ構成です。GSAP(ScrollTrigger)と Lenis を組み合わせて、スクロールに連動する没入感を作っています。実装で意識したポイントを整理します。

全体構成

  • Lenis でスムーススクロールを敷き、gsap.ticker に Lenis の raf を接続して1つのループに統一
  • ScrollTrigger でセクションごとの出現・パララックスを制御
  • ヒーローの文字は、入場アニメ(GSAP)と、マウス追従+スクロール飛散(自前の RAF)を分離
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on('scroll', ScrollTrigger.update);
const ticker = gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);

GSAP と Lenis をそれぞれ独立して回すと ticking が二重になりがちなので、Lenis を GSAP の ticker に乗せて1本化するのが安定します。

ヒーローのマグネット+飛散

「FUJI」の各文字は、

  1. ロード時に clip-path / opacity で入場(GSAP)
  2. マウス距離に応じて吸い寄せ(magnet)
  3. スクロール量に応じて飛散・フェードアウト

の3層。2と3は毎フレーム transform を書き換えるので、GSAP には入場だけ任せ、transform の常時制御は1つの requestAnimationFrame ループに集約しました。こうすると競合せず、パフォーマンスも読みやすくなります。

画面外では止める

常時 RAF を回すのは無駄なので、IntersectionObserver でヒーローが画面内にある時だけループを回し、画面外で停止します。

const io = new IntersectionObserver(([e]) => {
  e.isIntersecting ? startLoop() : stopLoop();
});
io.observe(section);

後始末を厳密に

ScrollTrigger は便利な反面、getAll().kill() で全消ししてしまうと他セクションのトリガーまで巻き込みます。そのコンポーネントが生成したトリガーだけを収集して kill するようにしました。非同期 import(GSAP を動的読み込み)と effect の破棄が競合してリークしないよう、active フラグで解決後の登録を防ぐのも重要です。

演出とパフォーマンス・アクセシビリティの両立

派手な演出は、放っておくと CWV やアクセシビリティを犠牲にします。prefers-reduced-motion への対応や、画面外でのループ停止、トリガーの厳密な後始末は、この手のサイトでは「演出の一部」として最初から設計に入れるべきだと考えています。詳しくは別記事「アクセシビリティと演出を両立する」でも触れています。

制作のご相談はこちらからどうぞ。