MotionBase/Research/Scroll Animations Best PracticesBack to App
scrollperformanceGSAPFramer MotionReacta11y

Scroll Animations Best Practices

Next.js / React / vanilla JS — GSAP, Framer Motion, bez bibliotek

2026-03-10·~25 min·6 interaktywnych demo
01

Typy scroll animations

W świecie scroll animations wyróżniamy dwa fundamentalnie różne podejścia, które determinują architekturę, performance i UX całego rozwiązania.

Scroll-Triggered

Animacja odpala się jednorazowo gdy element wchodzi lub wychodzi z viewportu. Typowe zastosowania: fade-in sekcji, pojawianie się kart, counter-up.

IntersectionObserver · whileInView · ScrollTrigger (once)
Scroll-Linked

Wartość animacji jest płynnie powiązana z pozycją scrolla. Elementy reagują w czasie rzeczywistym na każdy piksel przewinięcia.

useScroll · scrollProgress · ScrollTrigger (scrub)
Interactive Demo
Scroll-Triggered
↓ Scrolluj w dół
fired: 0x · waiting
Scroll-Linked
↓ Scrolluj — box reaguje ciągle
progress: 0%

Konsekwencje architektoniczne

Scroll-triggered animacje można odseparować per komponent i odpalać raz — mają minimalny wpływ na performance. Scroll-linked wymagają myślenia „silnikowo":

  • Jeden globalny driver odczytu scrolla (ScrollTrigger ticker, requestAnimationFrame)
  • Reużywane instancje animacji (gsap.quickTo)
  • Żadnego gsap.to() ani setState wewnątrz handlera scrolla
Scroll-triggered vs linked — wzorzec
1const observer = new IntersectionObserver(
2 ([entry]) => {
3 if (entry.isIntersecting) {
4 entry.target.classList.add('visible');
5 observer.unobserve(entry.target); // once
6 }
7 },
8 { threshold: 0.5 }
9);
10
11document.querySelectorAll('.animate-on-scroll')
12 .forEach(el => observer.observe(el));
02

UX i dostępność

Animacje powinny być subtelnym wzmocnieniem UX, nie przeszkodą. Trzy kluczowe zasady:

⚖️
Nie przesadzaj

Zbyt wiele intensywnych efektów parallax i pinning tworzy wrażenie przeciążenia i spowalnia stronę.

prefers-reduced-motion

Szanuj ustawienia systemowe użytkownika. Zastępuj złożone animacje prostym fade lub wyłączaj je całkowicie.

🧭
Jasna nawigacja

Pinning sekcji powinien mieć jasny koniec. Dodaj przycisk "skip" do długich scroll-storytelling.

Interactive Demo — prefers-reduced-motion
Full Motion
Właściwości animacji
opacity: 0 → 1
translateY: 30px → 0
scale: 0.8 → 1
rotate: -8deg → 0

Implementacja w React / Next.js

Najczęstszy wzorzec to custom hook useReducedMotion i warunkowe renderowanie lżejszych wariantów animacji:

useReducedMotion hook
1function useReducedMotion() {
2 const [reduced, setReduced] = useState(false);
3
4 useEffect(() => {
5 const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
6 setReduced(mq.matches);
7 const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
8 mq.addEventListener('change', handler);
9 return () => mq.removeEventListener('change', handler);
10 }, []);
11
12 return reduced;
13}
14
15// Użycie w komponencie:
16function AnimatedSection() {
17 const reduced = useReducedMotion();
18
19 return (
20 <motion.div
21 initial={{ opacity: 0, y: reduced ? 0 : 30 }}
22 whileInView={{ opacity: 1, y: 0 }}
23 transition={{ duration: reduced ? 0.3 : 0.8 }}
24 />
25 );
26}
Tip: Testuj na urządzeniach mobilnych. Efekty, które wyglądają świetnie na desktopie, często frustrują na touch scrollu — szczególnie pinning i horizontal scroll.
03

Performance

Cztery zasady, których przestrzeganie gwarantuje płynne 60 fps niezależnie od wybranego stacku.

1Animuj transform i opacity

To jedyne właściwości CSS w pełni obsługiwane przez GPU na etapie composite. Zmiany top, left, width lub height wymuszają kosztowny cykl layout → paint → composite w każdej klatce.

Interactive Demo — Performance
GPU-compositedtransform + opacity
Layout-triggeringleft + width
💡
transform i opacity są obsługiwane przez GPU na etapie composite — przeglądarka nie musi przeliczać layoutu. left, width wymuszają layout → paint → composite w każdej klatce.

2Ogranicz liczbę observerów i handlerów

Dla scroll-triggered: IntersectionObserver zamiast ręcznego nasłuchiwania scroll event na każdej sekcji. Dla scroll-linked: jedna globalna pętla (GSAP ticker lub rAF) obsługująca wszystkie efekty.

3Throttling zamiast debouncing

Debounce przy ciągłym scrollu nigdy nie wyzwoli callbacka — animacja może się nie odpalić. Throttling gwarantuje wywołania co X ms. Jeszcze lepiej: pętla requestAnimationFrame zsynchronizowana z odświeżaniem ekranu.

Interactive Demo — rAF vs Throttle vs Debounce
↓ Scrolluj tutaj:
↕ Scrolluj, aby zobaczyć różnice w aktualizacjach
requestAnimationFrameupdates: 0
throttle(16ms)updates: 0
debounce(150ms)updates: 0
rAF — płynne, zsynchronizowane z odświeżaniem ekranu. Throttle — gwarantowane wywołania co X ms. Debounce — odpala dopiero po zakończeniu scrollowania — nigdy nie nadąża przy ciągłym scrollu.

4Minimalizacja DOM i kosztów layoutu

Prosta struktura DOM — jeden wrapper + elementy animowane — zmniejsza koszty layoutu. Logika manipuluje wyłącznie transform/opacity, a nie tworzy/usuwa elementów.

JavaScript
Style
Layout
Paint
Composite

transform/opacity pomijają Layout i Paint — trafiają prosto do Composite.

04

Architektura Next.js / React

Next.js z App Router wprowadza podział na Server i Client Components. Animacje scroll wymagają dostępu do window i document, więc zawsze muszą żyć w client components.

Client Components i SSR

Biblioteki animacyjne (GSAP, Framer Motion) ładuje się wyłącznie po stronie klienta. W Next.js najczęściej przez dynamic(import, { ssr: false }) lub oznaczenie komponentu dyrektywą "use client".

Wzorzec: dynamic import bez SSR
1import dynamic from 'next/dynamic';
2
3// Komponent animacyjny ładowany tylko po stronie klienta
4const ScrollAnimation = dynamic(
5 () => import('./ScrollAnimation'),
6 { ssr: false }
7);
8
9// Server Component — może renderować layout
10export default function HeroSection() {
11 return (
12 <section>
13 <h1>Hero Title</h1>
14 <ScrollAnimation /> {/* Client-only */}
15 </section>
16 );
17}

Lifecycle i cleanup animacji

Kluczowy problem w SPA: bez czyszczenia instancji po zmianie route'a, stare ScrollTriggery i observery wciąż nasłuchują i powodują wycieki pamięci.

Cleanup patterns
1import { useGSAP } from '@gsap/react';
2import gsap from 'gsap';
3import { ScrollTrigger } from 'gsap/ScrollTrigger';
4
5gsap.registerPlugin(ScrollTrigger);
6
7function AnimatedSection() {
8 const containerRef = useRef<HTMLDivElement>(null);
9
10 // useGSAP automatycznie czyści WSZYSTKIE animacje
11 // i ScrollTriggery stworzone wewnątrz callbacka
12 useGSAP(() => {
13 gsap.from('.card', {
14 y: 60,
15 opacity: 0,
16 stagger: 0.1,
17 scrollTrigger: {
18 trigger: containerRef.current,
19 start: 'top 80%',
20 },
21 });
22 }, { scope: containerRef }); // scope = cleanup boundary
23
24 return <div ref={containerRef}>...</div>;
25}

Layout shift i hydration

Bezpieczny wzorzec: serwer renderuje finalną pozycję elementu, a animacja startuje od drobnej różnicy (transform/opacity) dopiero po hydration. Dzięki temu nie ma layout shift i użytkownik widzi stabilny layout od razu.

Avoid: Nie animuj krytycznych elementów layoutu (szerokość, wysokość, margin) w sposób, który powoduje inny DOM/układ na serwerze vs kliencie — to prowadzi do hydration mismatch i CLS.
Prefer: Serwer renderuje element w finalnej pozycji z opacity: 1. Client component nakłada opacity: 0 + transform po mount i animuje do docelowego stanu.
05

GSAP + ScrollTrigger

GSAP daje największą kontrolę nad złożonymi scroll-linked animacjami — timelines, pinning, scrub, scrollerProxy. Oto kluczowe wzorce i pułapki.

Integracja z Lenis (smooth scroll)

Kanoniczna integracja GSAP + Lenis polega na zsynchronizowaniu obu silników w jednej pętli, tak aby ScrollTrigger reagował na wirtualny scroll Lenisa:

GSAP + Lenis — kanoniczna integracja
1import Lenis from 'lenis';
2import gsap from 'gsap';
3import { ScrollTrigger } from 'gsap/ScrollTrigger';
4
5gsap.registerPlugin(ScrollTrigger);
6
7// 1. Inicjalizacja Lenis
8const lenis = new Lenis({ duration: 1.2 });
9
10// 2. Podpięcie do ScrollTrigger
11lenis.on('scroll', ScrollTrigger.update);
12
13// 3. Synchronizacja z GSAP ticker
14gsap.ticker.add((time) => {
15 lenis.raf(time * 1000);
16});
17
18// 4. Wyłączenie lag smoothing dla idealnej synchronizacji
19gsap.ticker.lagSmoothing(0);

Dzięki temu tylko GSAP ticker zarządza timem, a Lenis działa w jego ramach — zero podwójnych pętli, brak tearingu animacji.

quickTo — wydajne aktualizacje

gsap.quickTo pre-tworzy funkcję animującą daną właściwość. Zamiast tworzyć nowy tween 60 razy na sekundę, podajesz tylko nową wartość:

quickTo vs gsap.to
1// Pre-tworzenie settera — wykonaj RAZ
2const moveX = gsap.quickTo('.element', 'x', {
3 duration: 0.3,
4 ease: 'power2.out',
5});
6
7// W scroll handler — tylko podaj wartość
8ScrollTrigger.create({
9 onUpdate: (self) => {
10 moveX(self.progress * 500); // ultra-wydajne
11 },
12});
Interactive Demo — Scroll Easing
↓ Scrolluj
input: 0%
output: 0%
Preview
Easing Preset

Projektowanie triggerów

Typowy trigger: start: 'top bottom', end: 'bottom top' — animacja zaczyna się gdy wrapper wchodzi do dolnej krawędzi viewportu i kończy gdy go opuszcza od góry.

ScrollTrigger — pełny wzorzec
1gsap.to('.hero-content', {
2 y: -100,
3 opacity: 0,
4 ease: 'none',
5 scrollTrigger: {
6 trigger: '.hero-wrapper',
7 start: 'top top', // trigger.top = viewport.top
8 end: 'bottom top', // trigger.bottom = viewport.top
9 scrub: true, // scroll-linked (nie triggered)
10 pin: true, // pin sekcji podczas animacji
11 markers: true, // DEBUG: wizualizacja start/end
12 anticipatePin: 1, // zapobieganie scroll jump
13 },
14});
Tip: Posiadanie 5+ ScrollTriggerów per page jest normalne. Nie ma konieczności budowania jednego master-timelinu — ważniejsze jest spójne tworzenie i niszczenie ich w SPA/Next.
06

Framer Motion

Framer Motion (teraz Motion) jest idiomatyczny dla React/Next.js — integruje się z komponentami, lifecycle i warunkowym renderowaniem. Dwa tryby scroll animations:

Scroll-Triggered
whileInView
useAnimation + useInView
viewport prop
Scroll-Linked
useScroll
useTransform
scrollYProgress

whileInView — proste pojawianie się

Scroll-triggered z whileInView
1import { motion } from 'framer-motion';
2
3function FadeInCard({ children }) {
4 return (
5 <motion.div
6 initial={{ opacity: 0, y: 40 }}
7 whileInView={{ opacity: 1, y: 0 }}
8 viewport={{ once: true, margin: '-100px' }}
9 transition={{
10 duration: 0.6,
11 ease: [0.16, 1, 0.3, 1], // custom ease-out
12 }}
13 >
14 {children}
15 </motion.div>
16 );
17}
18
19// viewport options:
20// once: true — animacja odpala się raz
21// margin: '-100px' — trigger 100px wcześniej
22// amount: 0.5 — 50% elementu musi być widoczne

useScroll + useTransform — parallax i progress

Hook useScroll zwraca scrollYProgress (0–1), który useTransform mapuje na zakresy transformacji — bez re-renderów React, bo operuje na Motion Values.

Scroll-linked parallax
1import { motion, useScroll, useTransform } from 'framer-motion';
2
3function ParallaxSection() {
4 const ref = useRef(null);
5
6 const { scrollYProgress } = useScroll({
7 target: ref,
8 offset: ['start end', 'end start'],
9 // start end = element.top reaches viewport.bottom
10 // end start = element.bottom reaches viewport.top
11 });
12
13 const y = useTransform(scrollYProgress, [0, 1], [100, -100]);
14 const opacity = useTransform(scrollYProgress, [0, 0.3, 0.7, 1], [0, 1, 1, 0]);
15 const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.95]);
16
17 return (
18 <section ref={ref}>
19 <motion.div style={{ y, opacity, scale }}>
20 <h2>Parallax Content</h2>
21 </motion.div>
22 </section>
23 );
24}
Interactive Demo — Parallax Layers
Background · speed=0.15
Midground · speed=0.40
Foreground · speed=0.75
Speed Controls
Background
0.15
Y: 0px
Midground
0.40
Y: 0px
Foreground
0.75
Y: 0px
Tip: Przy złożonych scenach grupuj elementy w jeden komponent ze wspólnym useScroll, zamiast wywoływać hook w wielu rozrzuconych komponentach.
07

Vanilla JS

Brak zależności, pełna kontrola, minimalny bundle. IntersectionObserver jako domyślny wybór dla triggerów, requestAnimationFrame dla scroll-linked.

IntersectionObserver — konfiguracja

IO API przyjmuje trzy parametry konfiguracyjne: root (domyślnie viewport), rootMargin (rozszerzenie/zawężenie obszaru obserwacji), threshold (próg widoczności 0–1).

Interactive Demo — IntersectionObserver
Box 1
Box 2
Box 3
Box 4
Box 5
Box 6
Live State
Box 1
intersecting: false
ratio: 0.00
Box 2
intersecting: false
ratio: 0.00
Box 3
intersecting: false
ratio: 0.00
Box 4
intersecting: false
ratio: 0.00
Box 5
intersecting: false
ratio: 0.00
Box 6
intersecting: false
ratio: 0.00
IO z CSS-only animacją
1// JavaScript — tylko toggle klasy
2const observer = new IntersectionObserver(
3 (entries) => {
4 entries.forEach(entry => {
5 entry.target.classList.toggle(
6 'in-view',
7 entry.isIntersecting
8 );
9 });
10 },
11 {
12 threshold: 0.3,
13 rootMargin: '0px 0px -10% 0px', // trigger 10% wcześniej
14 }
15);
16
17document.querySelectorAll('[data-animate]')
18 .forEach(el => observer.observe(el));
CSS — animacja działa bez JS runtime cost
1/* Domyślny stan — niewidoczny */
2[data-animate] {
3 opacity: 0;
4 transform: translateY(20px);
5 transition: opacity 0.6s ease-out,
6 transform 0.6s ease-out;
7}
8
9/* Stan po dodaniu klasy przez IO */
10[data-animate].in-view {
11 opacity: 1;
12 transform: translateY(0);
13}
14
15/* Respektuj preferencje użytkownika */
16@media (prefers-reduced-motion: reduce) {
17 [data-animate] {
18 transition: opacity 0.3s ease-out;
19 transform: none;
20 }
21}

Scroll-linked: wzorzec rAF

Optymalny wzorzec: IO wykrywa aktywną sekcję, rAF uruchamia pętlę animacyjną tylko dla niej, a po wyjściu z viewportu pętla się zatrzymuje:

IO + rAF — hybrydowy wzorzec
1function createScrollAnimation(element) {
2 let active = false;
3 let rafId = null;
4
5 function animate() {
6 if (!active) return;
7
8 const rect = element.getBoundingClientRect();
9 const viewH = window.innerHeight;
10 const progress = 1 - (rect.top / viewH);
11
12 // Bezpośrednia manipulacja DOM — zero frameworku
13 element.style.transform =
14 `translateY(${progress * -50}px) scale(${0.9 + progress * 0.1})`;
15 element.style.opacity = String(Math.min(1, progress * 1.5));
16
17 rafId = requestAnimationFrame(animate);
18 }
19
20 // IO startuje/stopuje pętlę rAF
21 const observer = new IntersectionObserver(([entry]) => {
22 active = entry.isIntersecting;
23 if (active && !rafId) {
24 rafId = requestAnimationFrame(animate);
25 } else if (!active && rafId) {
26 cancelAnimationFrame(rafId);
27 rafId = null;
28 }
29 });
30
31 observer.observe(element);
32 return () => {
33 observer.disconnect();
34 if (rafId) cancelAnimationFrame(rafId);
35 };
36}
Korzyść: Ruch jest płynny (zsynchronizowany z ekranem przez rAF), ale funkcja nie działa dla niewidocznych sekcji — zero marnowanego CPU.
08

Porównanie podejść

Wybór między GSAP, Framer Motion a vanilla JS zależy od złożoności projektu, wymagań performance i preferencji zespołu.

AspektGSAP + ScrollTriggerFramer MotionVanilla JS + CSS
Krzywa naukiWiększa — pełny silnik timelineŚrednia — idiomatyczne dla ReactMała/średnia — więcej boilerplate
Kontrola scroll-linkedBardzo wysoka — timelines, quickTo, pinning, scrollerProxyDobra — useScroll + useTransformPełna kontrola, ale ręczna implementacja
Integracja z Next.jsWymaga @gsap/react i client componentsNaturalna — biblioteka React-owaNaturalna — natywny JS + IO
PerformanceBardzo dobra przy trzymaniu się transform/opacityBardzo dobra — Motion Values eliminują re-renderyZależy od implementacji
Bundle size~30KB min+gz (core + ScrollTrigger)~16KB min+gz0KB — zero zależności
Kiedy używaćHero storytelling, złożone sekwencje, parallax, pinningPage transitions, fade-in, micro-interakcje, prostszy parallaxProste efekty pojawiania się, minimalny bundle, restrykcyjny budżet JS

Szybka decyzja

Potrzebujesz pinning, scrub timeline lub scroll-hijacking?→ GSAP + ScrollTrigger
Tworzysz komponent React z prostym fade-in / parallax?→ Framer Motion
Budżet JS jest ograniczony, efekt to prosty fade/slide?→ Vanilla JS + CSS
Potrzebujesz smooth scroll z synchronizacją animacji?→ GSAP + Lenis
09

Checklista best practices

Podsumowanie kluczowych zasad do stosowania w każdym projekcie ze scroll animations.

Animuj głównie transform i opacity
Unikaj layout-owych właściwości (top, left, width, height). Transform i opacity są GPU-composited.
Używaj IntersectionObserver dla triggerów
whileInView (Framer), ScrollTrigger (GSAP) lub natywny IO. Nie nasłuchuj scroll event ręcznie na każdej sekcji.
Jeden driver dla scroll-linked
ScrollTrigger ticker, useScroll hook lub własny requestAnimationFrame. Nie wiele rozproszonych pętli.
Client components + cleanup w Next.js
Animacje w "use client" komponentach. useGSAP dla GSAP, observer.disconnect() w useEffect cleanup.
Szanuj prefers-reduced-motion
Wyłączaj lub upraszczaj animacje dla użytkowników z nadwrażliwością na ruch. Oferuj fallback z prostym fade.
Testuj na mobilkach
Performance + ergonomia touch scroll. Pinning i horizontal scroll często frustrują na urządzeniach dotykowych.
Debuguj punkty start/end
GSAP: markers: true. Framer: wizualizuj scrollProgress jako tymczasowy progress bar.
Projektuj animacje jak design system
Ograniczona liczba wzorców (fade-up, slide-in, sticky section) reużywanych w całym serwisie zamiast unikalnych hacków.

Antypatterns

gsap.to() w scroll handlerze — tworzy nowy tween 60x/s
setState() w scroll callback — powoduje re-render 60x/s
Debounce dla scroll-linked — animacja nigdy się nie odpali przy ciągłym scrollu
Animowanie width/height/top/left — wymusza layout recalc w każdej klatce
Brak cleanup w SPA — wycieki pamięci, duchy ScrollTriggerów po zmianie route
Podsumowanie: Dobre scroll animations sprowadzają się do trzech filarów — transform/opacity only, jeden driver + cleanup, szacunek dla użytkownika (reduced-motion, jasna nawigacja, testowanie na mobilkach).