این درس، قلب کل سری است. تا اینجا اسکلت صفحه را ساختیم و با اسکرول نرم حس حرکت را درست کردیم. حالا میخواهیم کاری کنیم که اسکرول کاربر تبدیل به نوار زمان انیمیشن شود.
این همان چیزی است که وقتی میبینی، فکر میکنی سایت جادو شده: صفحه میایستد، اما محتوا جلو میرود؛ اسکرول را برمیگردانی و انیمیشن هم دقیقاً برعکس میشود. اسم فنی این دو رفتار پین و اسکراب است و ابزار استانداردش، افزونهٔ ScrollTrigger از کتابخانهٔ GSAP.
خبر خوب اینکه GSAP و همهٔ افزونههایش امروز کاملاً رایگاناند؛ یعنی همان ابزاری که پشت سایتهای دههزار دلاری است، بدون هیچ هزینهای در اختیار توست.

لیست مطالب
سه مفهوم کلیدی که باید از هم تفکیک کنی
| مفهوم | یعنی چه | مثال |
|---|---|---|
| تریگر | انیمیشن با رسیدن به نقطهای شروع میشود و خودش تا آخر میرود | ظهور کارتها هنگام ورود به کادر |
| اسکراب | پیشرویِ انیمیشن دقیقاً به موقعیت اسکرول گره میخورد | بزرگشدن تصویر همراه اسکرول |
| پین | عنصر سرِ جایش قفل میشود در حالی که اسکرول ادامه دارد | بخشی که میایستد و محتوایش عوض میشود |
اکثر بخشهای چشمگیر، ترکیب پین + اسکراب هستند: بخش قفل میشود و انیمیشن داخلش با اسکرول جلو و عقب میرود.
راهاندازی اولیه
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ScrollTrigger.min.js"></script>
<script>
gsap.registerPlugin(ScrollTrigger);
</script>
اگر از اسکرول نرم درس قبل هم استفاده میکنی، باید این دو را به هم معرفی کنی؛ وگرنه ScrollTrigger موقعیت واقعی اسکرول را نمیفهمد:
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
این سه خط را فراموش نکن. بدون آنها، پینها یکجای اشتباه فعال میشوند و ساعتها دنبال باگ میگردی.
اولین انیمیشن: ظهور هنگام ورود به کادر
gsap.utils.toArray('[data-reveal]').forEach(el => {
gsap.from(el, {
y: 40,
opacity: 0,
duration: .8,
ease: 'power2.out',
scrollTrigger: {
trigger: el,
start: 'top 85%', // وقتی بالای عنصر به ۸۵٪ ارتفاع پنجره رسید
toggleActions: 'play none none reverse',
}
});
});
یادت هست در درس آناتومی گفتیم عناصر را در CSS پنهان نکن؟ اینجا دلیلش را میبینی: gsap.from خودش عنصر را از حالت پنهان شروع میکند و به حالت طبیعی میرساند. اگر جاوااسکریپت اجرا نشود، عنصر همانطور طبیعی سرِ جایش میماند.
رمزگشایی از start
مقدار start همیشه دو بخش دارد: نقطهای از عنصر و نقطهای از پنجره.
start: 'top 85%' // بالای عنصر ↔ ۸۵٪ ارتفاع پنجره از بالا
start: 'center center' // مرکز عنصر ↔ مرکز پنجره
start: 'top top' // بالای عنصر ↔ بالای پنجره (رایج برای پین)
end: '+=300%' // ۳۰۰٪ ارتفاع پنجره بعد از نقطهٔ شروع
وقتی این را فهمیدی، عملاً هشتاد درصد ScrollTrigger را فهمیدهای. برای دیدن این نقطهها روی صفحه، گزینهٔ markers: true را موقتاً روشن کن؛ دو خط رنگی روی صفحه ظاهر میشود که دقیقاً نشان میدهد کجاست.

پین: قفلکردن یک بخش
سادهترین حالت پین:
ScrollTrigger.create({
trigger: '.scene--pin',
start: 'top top',
end: '+=200%', // بهاندازهٔ دو صفحه اسکرول، بخش قفل میماند
pin: '.pin__stage', // چه چیزی قفل شود
pinSpacing: true, // فضای لازم را خودش اضافه کند
});
ساختاری که در درس آناتومی ساختیم دقیقاً برای همین لحظه بود: .scene--pin ظرف بلندی است که مسیر اسکرول را میسازد و .pin__stage چیزی است که قفل میشود.
گزینهٔ pinSpacing را روشن بگذار. اگر خاموشش کنی، بخش بعدی میآید روی بخش پینشده و چیدمان بههم میریزد — مگر اینکه عمداً افکت همپوشانی بخواهی.
اسکراب: گرهزدن انیمیشن به اسکرول
حالا ترکیب پین و اسکراب — همان چیزی که همه دنبالش هستند:
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.scene--pin',
start: 'top top',
end: '+=300%',
pin: '.pin__stage',
scrub: 1, // ۱ ثانیه تأخیر نرم؛ true یعنی چسبیده و بیتأخیر
}
});
tl.from('.panel[data-step="1"]', { yPercent: 60, opacity: 0 })
.to ('.panel[data-step="1"]', { scale: .85, opacity: .4 })
.from('.panel[data-step="2"]', { yPercent: 60, opacity: 0 }, '<')
.to ('.panel[data-step="2"]', { scale: .85, opacity: .4 })
.from('.panel[data-step="3"]', { yPercent: 60, opacity: 0 }, '<');
سه نکتهٔ مهم در این کد:
scrub: 1بهتر ازscrub: trueاست. عدد یک، کمی تأخیر نرم اضافه میکند که حرکت را لطیفتر نشان میدهد. حالتtrueکاملاً چسبیده است و روی موس چرخشی پرشی حس میشود.- علامت
'<'یعنی «این انیمیشن همزمان با قبلی شروع شود». بدون آن، هر انیمیشن منتظر تمامشدن قبلی میماند. - در حالت اسکراب،
durationمعنای زمان ندارد؛ فقط نسبت سهم هر انیمیشن از کل مسیر اسکرول را تعیین میکند.
محاسبهٔ طول مسیر
فرمول عملی: برای هر پنل حدود ۱۰۰٪ ارتفاع پنجره مسیر بگذار. سه پنل یعنی end: '+=300%'. اگر مسیر کوتاه باشد، پنلها با یک چرخش ماوس رد میشوند و کاربر متن را نمیخواند. اگر خیلی بلند باشد، کاربر حس میکند صفحه گیر کرده است.
چهار الگوی آماده که بارها استفاده میکنی
۱. بزرگشدن تصویر هیرو
gsap.to('.hero__media img', {
scale: 1.25,
ease: 'none',
scrollTrigger: {
trigger: '.scene--hero',
start: 'top top',
end: 'bottom top',
scrub: true,
}
});
۲. شمارش عددها هنگام ورود
gsap.utils.toArray('[data-count]').forEach(el => {
const target = +el.dataset.count;
gsap.fromTo(el, { innerText: 0 }, {
innerText: target,
duration: 1.6,
snap: { innerText: 1 },
scrollTrigger: { trigger: el, start: 'top 85%', once: true }
});
});
۳. تغییر رنگ زمینه بین پردهها
gsap.utils.toArray('[data-bg]').forEach(section => {
ScrollTrigger.create({
trigger: section,
start: 'top 50%',
end: 'bottom 50%',
onToggle: self => {
if (self.isActive)
gsap.to('body', { backgroundColor: section.dataset.bg, duration: .6 });
}
});
});
۴. اسلاید افقی با اسکرول عمودی
const track = document.querySelector('.proof__track');
gsap.to(track, {
x: () => -(track.scrollWidth - innerWidth),
ease: 'none',
scrollTrigger: {
trigger: '.scene--proof',
start: 'top top',
end: () => '+=' + (track.scrollWidth - innerWidth),
pin: true,
scrub: 1,
invalidateOnRefresh: true,
}
});
در این الگو، invalidateOnRefresh حیاتی است: باعث میشود بعد از تغییر اندازهٔ پنجره، عرضها دوباره محاسبه شوند. بدون آن، روی چرخاندن گوشی همهچیز بههم میریزد.

موبایل: کجا انیمیشن را خاموش کنیم
ابزار matchMedia در ScrollTrigger دقیقاً برای همین ساخته شده و بهمراتب تمیزتر از نوشتن شرط دستی است:
const mm = gsap.matchMedia();
mm.add('(min-width: 900px)', () => {
// انیمیشنهای سنگین فقط روی دسکتاپ
const tl = gsap.timeline({ scrollTrigger: { /* پین و اسکراب */ } });
return () => tl.kill(); // پاکسازی خودکار هنگام خروج از این بازه
});
mm.add('(max-width: 899px)', () => {
// نسخهٔ ساده: فقط ظهور تدریجی
gsap.utils.toArray('.panel').forEach(p =>
gsap.from(p, { opacity: 0, y: 30, scrollTrigger: { trigger: p, start: 'top 85%' } })
);
});
mm.add('(prefers-reduced-motion: reduce)', () => {
ScrollTrigger.getAll().forEach(t => t.kill());
gsap.set('[data-reveal], .panel', { clearProps: 'all' });
});
آن تابعی که در انتهای هر بلوک return میشود، هنگام خروج از آن بازهٔ اندازه اجرا میشود و انیمیشنها را پاک میکند. این همان چیزی است که از نشت حافظه و رفتار عجیب هنگام چرخاندن گوشی جلوگیری میکند.
شش اشتباه رایج و راهحل
| نشانه | علت | راهحل |
|---|---|---|
| پین یکجای اشتباه فعال میشود | تصاویر بعد از محاسبه لود شدهاند | ScrollTrigger.refresh() بعد از load |
| محتوا زیر بخش پین میرود | pinSpacing:false |
روشنش کن |
| پین اصلاً کار نمیکند | overflow:hidden روی یکی از والدها |
حذفش کن یا به clip تغییر بده |
| انیمیشن پرشی است | scrub:true بدون اسکرول نرم |
scrub: 1 بگذار |
| روی موبایل بههم میریزد | محاسبه بر اساس vh متغیر |
svh + invalidateOnRefresh |
| بعد از چرخاندن گوشی خراب میشود | انیمیشنهای قدیمی پاک نشدهاند | gsap.matchMedia با تابع پاکسازی |
پرامپت ساخت این لایه
نقش: توسعهدهندهٔ فرانتاند متخصص GSAP و ScrollTrigger.
کار: فقط لایهٔ انیمیشن اسکرول را برای صفحهٔ موجود بنویس.
ساختار HTML را تغییر نده.
بخشها:
- هیرو: تصویر با اسکراب تا مقیاس ۱٫۲۵ بزرگ شود.
- آمار: عددهای data-count یکبار هنگام ورود شمرده شوند.
- بخش پین: .pin__stage پین شود، مسیر ۳۰۰٪، سه پنل
یکییکی وارد شوند و پنل قبلی کوچک و کمرنگ شود.
- عناصر data-reveal: ظهور ساده با start در 'top 85%'.
قیدها:
- scrub برابر ۱ باشد، نه true.
- از gsap.matchMedia استفاده کن: زیر ۹۰۰ پیکسل فقط ظهور ساده.
- برای prefers-reduced-motion همهٔ تریگرها kill شوند.
- invalidateOnRefresh روی محاسبات وابسته به عرض روشن باشد.
- بعد از load یک refresh صدا زده شود.
- markers خاموش باشد ولی در یک ثابت DEBUG بالای فایل قابل روشنکردن.
خروجی: یک بلوک script کامل + جدول کوتاه اینکه هر پارامتر
start و end دقیقاً چه معنایی دارد.
هرچه قیدها دقیقتر باشند، خروجی قابلاستفادهتر است. این دقیقاً همان تفاوتی است که در آناتومی پرامپت حرفهای رویش تأکید کردهایم: مدل، ضعیف در حدسزدن است و قوی در اجرای دستور روشن.
پرسشهای پرتکرار
واقعاً GSAP رایگان است؟
بله، کتابخانه و همهٔ افزونههایش از جمله ScrollTrigger اکنون بدون هزینه در دسترساند؛ حتی برای پروژههای تجاری.
میشود همین کارها را بدون کتابخانه انجام داد؟
پین ساده را با position:sticky و ظهور را با IntersectionObserver میشود ساخت. اما اسکراب دقیق و تایملاین چندمرحلهای را دستی نوشتن، کاری طاقتفرساست که ارزشش را ندارد.
روی سرعت سایت اثر میگذارد؟
حجم کتابخانه و افزونه روی هم حدود ۷۰ کیلوبایت فشرده است. آنچه واقعاً سرعت را میکُشد، تصاویر بهینهنشده و ویدئوی سنگین است، نه این کتابخانه. جزئیاتش را در درس کارایی میبینیم.
چطور بفهمم نقطهٔ شروع کجاست؟
گزینهٔ markers: true را موقتاً روشن کن. دو خط رنگی روی صفحه ظاهر میشود که دقیقاً نقطهٔ شروع و پایان را نشان میدهد. قبل از انتشار حتماً خاموشش کن.
جمعبندی
سه مفهوم را از هم تفکیک کن: تریگر (شروع)، اسکراب (گرهخوردن به اسکرول) و پین (قفلشدن). ساختار درست HTML را از درس آناتومی بیاور، scrub: 1 بگذار، برای هر پنل ۱۰۰٪ ارتفاع مسیر در نظر بگیر، و با gsap.matchMedia نسخهٔ موبایل و کاهش حرکت را جدا کن.
در درس بعدی سراغ پارالاکس، لایهبندی و تایپوگرافی متحرک میرویم؛ یعنی همان جزئیاتی که به صفحه عمق و شخصیت میدهد. مسیر کامل را در نقشهٔ راه طراحی سایت سینمایی ببین و در @MrChatGPT_IR همراه ما باش.
