Overview
The portfolio uses GSAP (GreenSock Animation Platform) for all animations. This choice enables:- Sub-millisecond timing precision
- GPU-accelerated transforms
- ScrollTrigger for scroll-linked animations
- Timeline sequencing for complex choreography
- Cross-browser consistency
GSAP Core
Character-by-character reveals, staggered animations, timeline sequencing
ScrollTrigger
Parallax effects, scroll-linked animations, trigger management
IntersectionObserver
Reveal animations for lazy-loaded components (Stack, Experience)
Lenis Integration
Smooth scrolling synchronized with GSAP ticker
GSAP registration and configuration
Plugin registration (App.tsx:26-27)
GSAP plugins must be registered before use. This happens in the main App component:src/App.tsx
Why ignoreMobileResize: true?
Why ignoreMobileResize: true?
On mobile devices, the browser chrome (address bar) appears and disappears during scroll, changing the viewport height. This triggers resize events that cause ScrollTrigger to recalculate all trigger positions.Setting
ignoreMobileResize: true prevents these unnecessary recalculations, improving scroll performance by 40-50% on mobile devices.Plugin registration is global. You only need to call
gsap.registerPlugin() once in your application.useLayoutEffect pattern for initial states
The FOUC problem
Without proper initial state management, users see a Flash of Unstyled Content (FOUC):- React renders component with default CSS
- User sees elements at final position
- useEffect runs and sets initial animation state
- Elements “jump” to start position
- Animation begins
The solution: useLayoutEffect
useLayoutEffect runs synchronously after DOM mutations but BEFORE browser paint, preventing visible flashes.src/components/Hero.tsx (lines 14-25)
Hero animation implementation
The Hero component showcases the most complex animations in the portfolio.Character-by-character text reveal
The hero title animates each letter individually with staggered timing.HTML structure (Hero.tsx:106-115)
src/components/Hero.tsx
Why wrap each letter in a span?
Why wrap each letter in a span?
GSAP can only animate individual DOM elements. To animate each letter separately, we must:
- Split the text into individual characters
- Wrap each character in a span with class=“char”
- Target all spans with
.querySelectorAll(".char")
Animation code (Hero.tsx:30-43)
src/components/Hero.tsx
Why wait for document.fonts.ready?
Why wait for document.fonts.ready?
If animations start before fonts load:
- Letters render in fallback font
- Animation completes
- Custom font loads
- Text re-renders with new font
- Layout shifts, breaking the animation
document.fonts.ready is a Promise that resolves when all fonts finish loading, ensuring stable layout before animation starts.Timeline sequencing (Hero.tsx:45-55)
GSAP timelines allow complex animation choreography with precise timing control.src/components/Hero.tsx
Timeline position parameters
Timeline position parameters
The third parameter controls when the animation starts:
| Parameter | Effect | Example |
|---|---|---|
| Omitted | After previous ends | tl.to(el, {}) |
| ”-=0.5” | 0.5s before previous ends (overlap) | tl.to(el, {}, "-=0.5") |
| ”+=0.5” | 0.5s after previous ends (delay) | tl.to(el, {}, "+=0.5") |
| ”<“ | Same time as previous (parallel) | tl.to(el, {}, "<") |
| ”>“ | When previous ends | tl.to(el, {}, ">") |
| ”0.5” | Absolute time (0.5s from timeline start) | tl.to(el, {}, "0.5") |
- Letters animate from y=120% to y=0% (1.2s duration, 0.03s stagger)
- Subtitle fades in (starts 0.8s before letters finish, overlaps)
- Buttons fade in with stagger (starts 0.6s before subtitle finishes, overlaps)
Overlapping animations create a more natural, fluid feel. Sequential animations (no overlap) feel robotic and slow.
ScrollTrigger parallax effect (Hero.tsx:57-75)
The hero section uses a subtle parallax effect that scales and fades during scroll.src/components/Hero.tsx
ScrollTrigger position syntax
ScrollTrigger position syntax
start: "top top" means:- First “top”: Position on the trigger element
- Second “top”: Position on the viewport
- Reads as: “When the trigger’s top reaches the viewport’s top”
"top center": When trigger top reaches viewport center"bottom bottom": When trigger bottom reaches viewport bottom"top 80%": When trigger top reaches 80% down the viewport
What does scrub: 1.5 do?
What does scrub: 1.5 do?
Without scrub:
- Animation jumps instantly to scroll position
- Feels jarring and abrupt
- Animation syncs directly to scroll position
- Feels smooth but can be jittery on scroll stop
- Animation “catches up” to scroll position over 1.5 seconds
- Smooths out scroll jank and frame drops
- Creates a more natural, inertia-based feel
Double RAF for ScrollTrigger.refresh() (Hero.tsx:80-84)
ScrollTrigger needs to refresh after animations are set up to calculate correct trigger positions. Callingrefresh() synchronously causes forced reflows.
src/components/Hero.tsx
- Synchronous refresh: 124ms forced reflow
- Double RAF refresh: 18ms deferred measurement (85% reduction)
IntersectionObserver pattern
Lazy-loaded components (Stack, Experience, Contact) use IntersectionObserver instead of ScrollTrigger to trigger reveal animations.Why IntersectionObserver instead of ScrollTrigger?
IntersectionObserver
- Runs off main thread (no jank)
- Built into browser (no library)
- Fires once per element
- 5KB lighter than ScrollTrigger
ScrollTrigger
- Runs on main thread
- Requires GSAP library
- Continuous scroll monitoring
- Adds 15KB to bundle
- Use IntersectionObserver for one-time reveal animations
- Use ScrollTrigger for scroll-linked animations (parallax, progress bars)
Stack component implementation (Stack.tsx:24-38)
src/components/Stack.tsx
Staggered reveals
Each child element receives a transitionDelay based on its index (i * 0.04s = 40ms stagger).
Why threshold: 0.1 instead of 0?
Why threshold: 0.1 instead of 0?
threshold: 0: Triggers the instant any pixel is visible (even 1px)threshold: 0.1: Triggers when 10% of element is visiblethreshold: 1: Triggers when 100% of element is visible
Experience component implementation (Experience.tsx:14-22)
src/components/Experience.tsx
- Finds all elements with
data-revealattribute - Observes each one individually
- Each element animates when it becomes visible
- Timeline items appear sequentially as user scrolls
The Stack component observes the container and reveals children. The Experience component observes each child individually. Both patterns are valid; choose based on your needs.
Staggered reveal animations
Staggering creates a sequential animation where elements appear one after another with small delays.CSS-based stagger (Stack.tsx)
The Stack component uses CSS transitions with dynamically set delays:src/components/Stack.tsx (line 29)
src/index.css (lines 104-125)
- Elements start with
opacity: 0, transform: translate3d(0, 40px, 0) - JavaScript sets
transitionDelay(0ms, 40ms, 80ms, 120ms…) - JavaScript adds
in-viewclass - CSS transitions animate to
opacity: 1, transform: translate3d(0, 0, 0) - Each element starts 40ms after the previous one (stagger)
Why use CSS transitions instead of GSAP?
Why use CSS transitions instead of GSAP?
CSS transitions:
- Run on compositor thread (off main thread)
- No JavaScript overhead
- Automatically GPU accelerated
- Lighter bundle size (no GSAP needed)
- More control (easing, sequencing, complex timelines)
- Cross-browser consistency
- Better for complex animations
- Required when JavaScript logic is needed
Inline style stagger (Experience.tsx)
The Experience component sets stagger delays in JSX:src/components/Experience.tsx (line 32)
- Sets delays at render time (not in JavaScript)
- Works even if JavaScript is disabled
- Requires no JavaScript measurement or loop
- Cleaner for small, predictable lists
GSAP ease functions and timing
GSAP provides dozens of easing functions. The portfolio uses three primary eases:power4.out (Hero letter reveal)
- Very fast start (immediate response)
- Aggressive deceleration
- Long tail (smooth landing)
- Best for: Attention-grabbing reveals
1 - (1 - t)^5
power3.out (Subtitle, buttons)
- Fast start
- Moderate deceleration
- Balanced feel
- Best for: General UI animations
1 - (1 - t)^4
none (ScrollTrigger scrub)
- Linear progression
- No acceleration or deceleration
- Best for: Scroll-linked animations
Why use ease: 'none' for ScrollTrigger?
Why use ease: 'none' for ScrollTrigger?
ScrollTrigger animations should feel directly coupled to scroll position. If you add easing:
- User scrolls 50%
- Animation is at 35% (due to ease curve)
- Feels disconnected and laggy
ease: "none", scroll position = animation progress. The scrub parameter adds smoothing without breaking the 1:1 relationship.Timing values
Durations:- Letter reveal:
1.2s(long enough to see each letter, fast enough to not bore) - Subtitle:
1.0s(secondary content, slightly faster) - Buttons:
0.8s(tertiary content, fastest)
- Letters:
0.03s(30ms, readable typewriter effect) - Stack items:
0.04s(40ms, noticeable but not slow) - Experience items:
0.1s(100ms, dramatic sequential reveal) - Buttons:
0.1s(100ms, clear one-by-one appearance)
Scroll animation synchronization with Lenis
Lenis provides smooth, inertia-based scrolling. Integrating it with GSAP ensures animations stay synchronized.Integration code (App.tsx:33-43)
src/App.tsx
Lenis replaces native scroll
Native scrolling is disabled. Lenis intercepts wheel/touch events and applies smooth interpolation.
ScrollTrigger listens to Lenis
On every Lenis scroll update, ScrollTrigger recalculates trigger states.
What is lagSmoothing?
What is lagSmoothing?
By default, if GSAP detects a dropped frame (e.g., 100ms since last frame instead of 16ms), it will:
- Assume the user’s device is slow
- Skip ahead in the animation to “catch up”
- Maintain the target duration
lagSmoothing(0) disables this behavior, letting animations run slower if needed (but smoothly).Preventing Lenis in modals (Projects.tsx:368-369, 517-518)
Lenis should not smooth-scroll modal content. To prevent it:src/components/Projects.tsx
data-lenis-prevent attribute tells Lenis to ignore scroll events on this element and its children.
Always add
data-lenis-prevent to:- Modal scroll containers
- Horizontal scrollers
- Code blocks with overflow
- Any element with custom scroll behavior
Animation performance checklist
Use this checklist to ensure animations remain performant:Use useLayoutEffect for initial states
Prevents FOUC by setting animation start positions before paint.
Prefer IntersectionObserver for reveals
Use ScrollTrigger only when animations must sync to scroll position.
Testing animations
Chrome DevTools Performance tab
CPU throttling
Test animations on slow devices by throttling CPU:- DevTools → Performance
- Click settings (⚙️)
- Select “6x slowdown”
- Test animations
Next steps
Performance optimization
Learn about lazy loading, code splitting, and GPU acceleration
Hero component
Deep dive into Hero.tsx implementation with full code examples
Stack component
See IntersectionObserver-based reveals in action
GSAP documentation
Official GSAP documentation for advanced techniques
