DEV Community

Cover image for GSAP ScrollTrigger pin: true Nearly Broke My Portfolio — Here's What I Learned
Nguyen Xuan Hai
Nguyen Xuan Hai

Posted on

GSAP ScrollTrigger pin: true Nearly Broke My Portfolio — Here's What I Learned

The Bug That Took 3 Days to Find

Last week, I shipped an update to my portfolio site. Everything looked perfect on my dev machine. Then a friend texted me a screenshot from their phone:

"Bro... why is PROJECTS showing while I'm still reading your work history?"

He was right. The PROJECTS section transition was appearing on top of the Experience section — before users had finished scrolling through my work history. On mobile, it was even worse: the text was fully visible over my last two job entries.

I'd been staring at this site for 6 hours and never noticed. Classic developer blindness.

The Architecture

My portfolio uses a scrollytelling layout:

Profile → About → Experience → SectionTransition("PROJECTS") → Portfolio
Enter fullscreen mode Exit fullscreen mode

The SectionTransition component is a full-viewport kinetic text reveal using GSAP ScrollTrigger:

// KineticType.jsx — The problem code
const tl = gsap.timeline({
    scrollTrigger: {
        trigger: container,
        start: "top top",
        end: "+=100%",
        pin: true,     // ← This is the culprit
        scrub: 1,
    }
});

tl.fromTo(textEl,
    { scale: 0.8, opacity: 0, y: 50 },
    { scale: 1, opacity: 1, y: 0, duration: 1 },
);
Enter fullscreen mode Exit fullscreen mode

Looks clean, right? It worked perfectly on my 1440px monitor. But pin: true has hidden costs that only surface on smaller viewports.

What pin: true Actually Does

When GSAP pins an element, it does three things most developers don't realize:

1. Creates a Pin-Spacer Wrapper

<!-- Your original element -->
<div class="kinetic-container">PROJECTS</div>

<!-- What GSAP creates -->
<div class="pin-spacer" style="height: 1400px; padding-bottom: 700px;">
    <div class="kinetic-container" 
         style="position: fixed; width: 400px; max-height: 700px;">
        PROJECTS
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The pin-spacer doubles your element's height in the document flow. On a 700px viewport, a 100vh element becomes 1400px of scroll space.

2. Forces position: fixed

During the pin phase, your element gets position: fixed. This removes it from document flow and places it in its own stacking context. Any content behind or after it can bleed through.

3. Recalculates Dimensions at Init Time

GSAP measures your element's dimensions once during initialization. If your layout changes after init (fonts loading, images rendering, dynamic content), the pin-spacer dimensions are wrong.

My Debug Journey

I initially tried the obvious fixes:

Attempt 1: z-index (Failed ❌)

.experience-section { z-index: 5; }
.kinetic-container { z-index: 2; }
Enter fullscreen mode Exit fullscreen mode

Why it failed: z-index only works between elements in the same stacking context. GSAP's position: fixed creates a new stacking context. The z-index values were comparing apples to oranges.

Attempt 2: Increased padding (Failed ❌)

.experience-section {
    padding: 100px 20px 160px; /* Extra bottom padding */
}
Enter fullscreen mode Exit fullscreen mode

Why it failed: More padding = taller section = worked on desktop. But on mobile (400px wide), text wraps more → section is even taller → the pin triggers at the wrong scroll position anyway.

Attempt 3: Hide content with opacity before pin (Partial ✅)

gsap.set([textEl, ...lines, ...circles], { opacity: 0 });

// Elements start hidden, GSAP reveals them
tl.fromTo(lines,
    { height: "0%", opacity: 0 },
    { height: "100%", opacity: 1, ... }
);
Enter fullscreen mode Exit fullscreen mode

Result: The text was hidden, but the pin-spacer still existed — creating a 1400px empty gap. Users scrolled through work history, hit an empty black void, then saw PROJECTS, then another gap, then Portfolio. The layout was broken in a new way.

The Fix: Remove pin entirely

After 3 days of fighting GSAP's pin behavior, I realized the answer was to stop fighting it:

// ❌ Before — GSAP ScrollTrigger with pin
const tl = gsap.timeline({
    scrollTrigger: {
        trigger: container,
        start: "top top",
        end: "+=100%",
        pin: true,      // Creates pin-spacer, forces position: fixed
        scrub: 1,
    }
});

// ✅ After — GSAP ScrollTrigger without pin
const tl = gsap.timeline({
    scrollTrigger: {
        trigger: container,
        start: "top 85%",   // Animate when 85% into viewport
        end: "top 10%",     // Complete by time it reaches top 10%
        scrub: 0.8,         // Smooth scroll-driven animation
        // NO pin — element scrolls naturally
    }
});
Enter fullscreen mode Exit fullscreen mode

The key insight: you don't need pin for scroll-driven animations. scrub without pin gives you scroll-linked animation while keeping the element in normal document flow. No pin-spacer. No stacking context issues. No height miscalculations.

Premium Animation Without Pin

Removing the pin didn't mean sacrificing visual quality. I upgraded the animation with character-level splitting:

// Split text into individual characters
const characters = text.split('');

return (
    <div className="kinetic-chars-wrapper">
        {characters.map((char, i) => (
            <span key={i} className="kinetic-char">{char}</span>
        ))}
    </div>
);
Enter fullscreen mode Exit fullscreen mode
// Scroll-driven character animation (no pin)
const tl = gsap.timeline({
    scrollTrigger: {
        trigger: container,
        start: "top 85%",
        end: "top 10%",
        scrub: 0.8,
    }
});

// Horizontal reveal line sweeps across
tl.to(hLine, { scaleX: 1, duration: 0.3 });

// Characters fly in one by one with 3D rotation
tl.to(chars, {
    y: 0, opacity: 1, rotateX: 0, scale: 1,
    duration: 0.5, stagger: 0.04, ease: "back.out(1.4)"
}, "<0.15");

// Geometric circles expand with rotation
tl.to(circles, {
    opacity: 0.6, scale: 1, rotation: 90,
    duration: 0.5
}, "<0.1");
Enter fullscreen mode Exit fullscreen mode

The result is actually more impressive than the pinned version — each letter animates individually as you scroll, creating a premium kinetic typography effect.

CSS Architecture for Scroll Animations

The CSS is where the real magic happens. Key techniques:

/* Character split: 3D perspective entrance */
.kinetic-chars-wrapper {
    perspective: 800px;
}

.kinetic-char {
    display: inline-block;
    font-size: clamp(3rem, 14vw, 12rem);
    font-weight: 900;
    color: #ccff00;
    will-change: transform, opacity;
    transform-origin: center bottom;
    text-shadow:
        0 0 40px rgba(204, 255, 0, 0.15),
        0 0 80px rgba(204, 255, 0, 0.05);
}

/* Horizontal reveal line with gradient */
.kinetic-hline {
    width: 100%;
    height: 2px;
    background: linear-gradient(90deg,
        transparent, rgba(204, 255, 0, 0.8), transparent);
    transform: scaleX(0);
    transform-origin: center;
}

/* Responsive: smaller screens need shorter transitions */
@media (max-width: 768px) {
    .kinetic-container { height: 50vh; }
    .kinetic-char { font-size: clamp(2.5rem, 12vw, 5rem); }
}
Enter fullscreen mode Exit fullscreen mode

Important: Every animated element starts with opacity: 0 in CSS as a safety net, with GSAP handling the reveal.

Performance Comparison

Metric Pin Approach Scroll-Driven (No Pin)
Pin-spacer DOM nodes 2 extra 0
Elements with position: fixed 1 (during pin) 0
Scroll height added +100vh per pin 0
Mobile layout issues ⚠️ Common ✅ None
ScrollTrigger.refresh() needed Yes, after async content No
Compositing layers created 3-5 1-2
Cross-browser consistency ⚠️ Safari varies ✅ Consistent
Animation visual quality Good Better (character split)

When You SHOULD Use pin: true

To be fair, pin: true isn't always bad. Use it when:

  1. Full-page takeover — Interactive storytelling where you literally need the viewport to "pause" on a section
  2. Video sync — Scrubbing through a video that should fill the viewport
  3. Step-by-step reveals — Wizards or onboarding flows where each step takes the full screen
  4. Known viewport — Kiosk displays or embedded widgets with fixed dimensions

Don't use it for:

  • Section dividers/transitions
  • Text reveals between content sections
  • Any layout where the preceding section has dynamic height
  • Mobile-first designs (pin-spacer dimensions are unreliable)

5 Key Takeaways

  1. pin: true creates invisible DOM elements (pin-spacer) that break your layout math
  2. z-index doesn't fix stacking context issues caused by position: fixed
  3. scrub without pin gives you scroll-driven animation without layout conflicts
  4. Test on real mobile devices — DevTools responsive mode doesn't always trigger the same GSAP calculations
  5. Character-level animation (splitting text into spans) looks MORE premium than a simple fade, and doesn't require pinning

FAQ

Q: Does GSAP ScrollTrigger's pin: true work reliably on mobile browsers?

A: Not always. GSAP's pin: true creates a pin-spacer wrapper that doubles the element's height in the document flow. On mobile browsers (particularly Safari iOS and Chrome Android), this can cause scroll position miscalculations due to dynamic viewport height changes (URL bar hiding/showing), font loading shifts, and dynamic content that changes section heights after ScrollTrigger initialization. For mobile-first designs, use scrub without pin for more reliable behavior.

Q: What's the best alternative to GSAP pin for scroll animations in React?

A: Use GSAP ScrollTrigger with scrub (no pin) combined with IntersectionObserver. Set start: "top 85%" and end: "top 10%" to create a scroll-driven animation window. The element stays in normal document flow — no pin-spacer, no position: fixed, no stacking context conflicts. For even simpler cases, CSS @scroll-timeline (where supported) or the Web Animations API with ScrollTimeline are native alternatives.

Q: How do you create character-level animation without a library?

A: Split your text into individual <span> elements with JavaScript: text.split('').map(char => <span>{char}</span>). Apply CSS transform-origin: center bottom and perspective on the parent. Use GSAP's stagger property to animate each character sequentially. The back.out(1.4) easing creates a satisfying overshoot effect. Set will-change: transform, opacity on each span for GPU-accelerated compositing.


I'm Nguyễn Xuân Hải, a Fullstack Developer building production web apps with React, .NET Core & AI in Ho Chi Minh City. Check out my portfolio or connect on LinkedIn.

🔗 See the fix in action: hailamdev.space — scroll through Experience → PROJECTS → Portfolio
📦 Source code: github.com/xuanhai0913/My-Portfolio-NXH

Top comments (0)