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
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 },
);
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>
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; }
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 */
}
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, ... }
);
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
}
});
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>
);
// 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");
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); }
}
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:
- Full-page takeover — Interactive storytelling where you literally need the viewport to "pause" on a section
- Video sync — Scrubbing through a video that should fill the viewport
- Step-by-step reveals — Wizards or onboarding flows where each step takes the full screen
- 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
-
pin: truecreates invisible DOM elements (pin-spacer) that break your layout math -
z-indexdoesn't fix stacking context issues caused byposition: fixed -
scrubwithoutpingives you scroll-driven animation without layout conflicts - Test on real mobile devices — DevTools responsive mode doesn't always trigger the same GSAP calculations
- 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)