このポートフォリオは、巨大な「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」の各文字は、
- ロード時に clip-path / opacity で入場(GSAP)
- マウス距離に応じて吸い寄せ(magnet)
- スクロール量に応じて飛散・フェードアウト
の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 への対応や、画面外でのループ停止、トリガーの厳密な後始末は、この手のサイトでは「演出の一部」として最初から設計に入れるべきだと考えています。詳しくは別記事「アクセシビリティと演出を両立する」でも触れています。
制作のご相談はこちらからどうぞ。