skills/awwwards-animations/references/gsap-react.md

13 KiB

GSAP + React Patterns

Complete GSAP patterns for React with useGSAP hook. Based on official GSAP documentation.

Table of Contents

  1. useGSAP Fundamentals
  2. ScrollTrigger in React
  3. Context Safe Functions
  4. Timeline Orchestration
  5. Text Animations
  6. Pin Sections
  7. Batch Animations
  8. SVG Animations

useGSAP Fundamentals

Basic Setup

'use client'
import { useRef } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

// Register once (usually in a shared lib file)
gsap.registerPlugin(ScrollTrigger, useGSAP)

Basic Animation

function AnimatedBox() {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    gsap.to('.box', { x: 360, rotation: 360, duration: 1 })
  }, { scope: containerRef }) // scope limits selectors to container

  return (
    <div ref={containerRef}>
      <div className="box">Animated</div>
    </div>
  )
}

With Dependencies

function AnimatedCounter({ count }: { count: number }) {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    gsap.from('.counter', {
      textContent: 0,
      duration: 1,
      snap: { textContent: 1 }
    })
  }, {
    scope: containerRef,
    dependencies: [count],     // Re-run when count changes
    revertOnUpdate: true       // Cleanup before re-running
  })

  return (
    <div ref={containerRef}>
      <span className="counter">{count}</span>
    </div>
  )
}

Cleanup Function

useGSAP(() => {
  const animation = gsap.to('.box', { x: 100 })

  return () => {
    // Custom cleanup (useGSAP auto-reverts GSAP objects)
    console.log('Component unmounting')
  }
}, { scope: containerRef })

ScrollTrigger in React

Basic ScrollTrigger

function ScrollSection() {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    gsap.from('.content', {
      opacity: 0,
      y: 50,
      duration: 1,
      scrollTrigger: {
        trigger: '.content',
        start: 'top 80%',
        end: 'top 30%',
        toggleActions: 'play none none reverse',
        // markers: true, // Debug only
      }
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef}>
      <div className="content">Scroll to reveal</div>
    </div>
  )
}

Scrub Animation

function ScrubSection() {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    gsap.to('.progress', {
      scaleX: 1,
      ease: 'none',
      scrollTrigger: {
        trigger: containerRef.current,
        start: 'top top',
        end: 'bottom bottom',
        scrub: 0.3, // Smooth catch-up (seconds)
      }
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef} className="h-[300vh]">
      <div className="progress fixed top-0 left-0 h-1 w-full bg-blue-500 origin-left scale-x-0" />
    </div>
  )
}

Pin Section

function PinnedSection() {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    gsap.to('.pinned-content', {
      y: 200,
      opacity: 0.5,
      scrollTrigger: {
        trigger: containerRef.current,
        start: 'top top',
        end: '+=1000', // Pin for 1000px of scroll
        pin: true,
        scrub: true,
        anticipatePin: 1, // Prevents jump
      }
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef} className="h-screen">
      <div className="pinned-content">Pinned content</div>
    </div>
  )
}

Multiple ScrollTriggers

function MultipleAnimations() {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    const sections = gsap.utils.toArray<HTMLElement>('.section')

    sections.forEach((section, i) => {
      gsap.from(section, {
        opacity: 0,
        y: 50,
        duration: 0.8,
        scrollTrigger: {
          trigger: section,
          start: 'top 80%',
          toggleActions: 'play none none reverse',
        }
      })
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef}>
      {[1, 2, 3, 4].map(i => (
        <div key={i} className="section h-screen">Section {i}</div>
      ))}
    </div>
  )
}

Context Safe Functions

Use contextSafe for animations triggered by events (click, hover, etc.).

Click Handler

function ClickAnimation() {
  const containerRef = useRef<HTMLDivElement>(null)

  const { contextSafe } = useGSAP({ scope: containerRef })

  const handleClick = contextSafe(() => {
    gsap.to('.box', {
      rotation: '+=360',
      duration: 0.5,
      ease: 'power2.out'
    })
  })

  return (
    <div ref={containerRef}>
      <button onClick={handleClick}>Rotate</button>
      <div className="box">Click to spin</div>
    </div>
  )
}

Hover Animation

function HoverCard() {
  const containerRef = useRef<HTMLDivElement>(null)

  const { contextSafe } = useGSAP({ scope: containerRef })

  const handleMouseEnter = contextSafe(() => {
    gsap.to('.card-content', { y: -10, duration: 0.3 })
  })

  const handleMouseLeave = contextSafe(() => {
    gsap.to('.card-content', { y: 0, duration: 0.3 })
  })

  return (
    <div
      ref={containerRef}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    >
      <div className="card-content">Hover me</div>
    </div>
  )
}

Timeline Orchestration

Basic Timeline

function TimelineAnimation() {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    const tl = gsap.timeline({
      scrollTrigger: {
        trigger: containerRef.current,
        start: 'top center',
      }
    })

    tl.from('.title', { opacity: 0, y: 50, duration: 0.6 })
      .from('.subtitle', { opacity: 0, y: 30, duration: 0.5 }, '-=0.3')
      .from('.cta', { opacity: 0, scale: 0.9, duration: 0.4 }, '-=0.2')
      .from('.decoration', { opacity: 0, x: -20, stagger: 0.1 }, '-=0.3')

  }, { scope: containerRef })

  return (
    <div ref={containerRef}>
      <h1 className="title">Title</h1>
      <p className="subtitle">Subtitle</p>
      <button className="cta">CTA</button>
      <div className="decoration" />
      <div className="decoration" />
    </div>
  )
}

Timeline with Controls

function ControlledTimeline() {
  const containerRef = useRef<HTMLDivElement>(null)
  const tl = useRef<gsap.core.Timeline>(null)

  useGSAP(() => {
    tl.current = gsap.timeline({ paused: true })
      .to('.box', { x: 200, duration: 0.5 })
      .to('.box', { y: 100, duration: 0.5 })
      .to('.box', { rotation: 360, duration: 0.5 })
  }, { scope: containerRef })

  const play = () => tl.current?.play()
  const reverse = () => tl.current?.reverse()
  const restart = () => tl.current?.restart()

  return (
    <div ref={containerRef}>
      <div className="box" />
      <button onClick={play}>Play</button>
      <button onClick={reverse}>Reverse</button>
      <button onClick={restart}>Restart</button>
    </div>
  )
}

Text Animations

SplitText (Club GSAP)

import { SplitText } from 'gsap/SplitText'
gsap.registerPlugin(SplitText)

function TextReveal() {
  const textRef = useRef<HTMLHeadingElement>(null)
  const splitRef = useRef<SplitText | null>(null)

  useGSAP(() => {
    splitRef.current = new SplitText(textRef.current, {
      type: 'chars, words, lines',
      linesClass: 'overflow-hidden'
    })

    gsap.from(splitRef.current.chars, {
      opacity: 0,
      y: 100,
      rotateX: -90,
      stagger: 0.02,
      duration: 0.8,
      ease: 'back.out(1.7)',
      scrollTrigger: {
        trigger: textRef.current,
        start: 'top 80%',
      }
    })

    return () => splitRef.current?.revert()
  })

  return <h1 ref={textRef}>Animated Headline</h1>
}

Text Scramble

import { TextPlugin } from 'gsap/TextPlugin'
gsap.registerPlugin(TextPlugin)

function ScrambleText() {
  const textRef = useRef<HTMLSpanElement>(null)

  useGSAP(() => {
    gsap.to(textRef.current, {
      duration: 2,
      text: {
        value: 'Final Text',
        delimiter: '',
      },
      ease: 'none',
      scrollTrigger: {
        trigger: textRef.current,
        start: 'top 80%',
      }
    })
  })

  return <span ref={textRef}>Initial Text</span>
}

Line-by-Line Mask Reveal

function LineMaskReveal({ text }: { text: string }) {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    gsap.from('.line-inner', {
      yPercent: 100,
      duration: 0.8,
      ease: 'power4.out',
      stagger: 0.1,
      scrollTrigger: {
        trigger: containerRef.current,
        start: 'top 80%',
      }
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef}>
      {text.split('\n').map((line, i) => (
        <div key={i} className="overflow-hidden">
          <div className="line-inner">{line}</div>
        </div>
      ))}
    </div>
  )
}

Pin Sections

Horizontal Scroll

function HorizontalScroll() {
  const containerRef = useRef<HTMLDivElement>(null)
  const wrapperRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    const sections = gsap.utils.toArray<HTMLElement>('.panel')

    gsap.to(sections, {
      xPercent: -100 * (sections.length - 1),
      ease: 'none',
      scrollTrigger: {
        trigger: wrapperRef.current,
        pin: true,
        scrub: 1,
        snap: 1 / (sections.length - 1),
        end: () => '+=' + wrapperRef.current!.offsetWidth,
      }
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef}>
      <div ref={wrapperRef} className="flex w-[400vw]">
        {[1, 2, 3, 4].map(i => (
          <div key={i} className="panel w-screen h-screen flex-shrink-0">
            Panel {i}
          </div>
        ))}
      </div>
    </div>
  )
}

Stacked Pin Sections

function StackedSections() {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    const sections = gsap.utils.toArray<HTMLElement>('.stack-section')

    sections.forEach((section, i) => {
      ScrollTrigger.create({
        trigger: section,
        start: 'top top',
        pin: true,
        pinSpacing: false,
        snap: 1,
      })
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef}>
      {[1, 2, 3].map(i => (
        <div key={i} className="stack-section h-screen" style={{ zIndex: i }}>
          Section {i}
        </div>
      ))}
    </div>
  )
}

Batch Animations

Efficient Grid Animation

function BatchGrid() {
  const containerRef = useRef<HTMLDivElement>(null)

  useGSAP(() => {
    ScrollTrigger.batch('.grid-item', {
      onEnter: (elements) => {
        gsap.from(elements, {
          opacity: 0,
          y: 60,
          stagger: 0.1,
          duration: 0.6,
          ease: 'power3.out',
        })
      },
      start: 'top 85%',
    })
  }, { scope: containerRef })

  return (
    <div ref={containerRef} className="grid grid-cols-4 gap-4">
      {Array.from({ length: 16 }).map((_, i) => (
        <div key={i} className="grid-item aspect-square bg-gray-800" />
      ))}
    </div>
  )
}

SVG Animations

DrawSVG

import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin'
gsap.registerPlugin(DrawSVGPlugin)

function DrawSVG() {
  const svgRef = useRef<SVGSVGElement>(null)

  useGSAP(() => {
    gsap.from('.draw-path', {
      drawSVG: '0%',
      duration: 2,
      ease: 'power2.inOut',
      stagger: 0.2,
      scrollTrigger: {
        trigger: svgRef.current,
        start: 'top 70%',
      }
    })
  })

  return (
    <svg ref={svgRef} viewBox="0 0 100 100">
      <path className="draw-path" d="M10,50 Q50,10 90,50" fill="none" stroke="white" />
    </svg>
  )
}

MorphSVG

import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin'
gsap.registerPlugin(MorphSVGPlugin)

function MorphShape() {
  const shapeRef = useRef<SVGPathElement>(null)

  const { contextSafe } = useGSAP()

  const morph = contextSafe(() => {
    gsap.to(shapeRef.current, {
      morphSVG: '#target-shape',
      duration: 1,
      ease: 'power2.inOut',
    })
  })

  return (
    <svg viewBox="0 0 100 100">
      <path ref={shapeRef} id="start-shape" d="M50,10 L90,90 L10,90 Z" />
      <path id="target-shape" d="M50,10 A40,40 0 1,1 50,90 A40,40 0 1,1 50,10" style={{ visibility: 'hidden' }} />
      <button onClick={morph}>Morph</button>
    </svg>
  )
}

Important Notes

React 18 Strict Mode

Strict Mode calls effects twice in development. useGSAP handles this automatically with proper cleanup.

SSR / Next.js

Add 'use client' to components using GSAP. The hook is SSR-safe.

Refresh After Dynamic Content

useEffect(() => {
  ScrollTrigger.refresh()
}, [items]) // After items change

Kill on Route Change

// In a layout or route component
useEffect(() => {
  return () => {
    ScrollTrigger.getAll().forEach(t => t.kill())
  }
}, [])