'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
// template.tsx re-montează această componentă la fiecare schimbare de rută
export default function PageAnimatePresence({ children }: { children: ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
>
{children}
</motion.div>
</AnimatePresence>
);
}Am mutat anul trecut o aplicație e-commerce destul de măricică (aprox 15k sesiuni pe zi) de pe Pages Router pe Next.js App Router. Totul a mers brici până când designerul m-a întrebat de ce au dispărut animațiile de fade-out la schimbarile de rută. În Pages Router puneai un AnimatePresence în _app.tsx și gata, dar în App Router povestea e complet diferită.
Problemă e modul în care Next.js gestionează lifecycler-ul paginilor. App Router nu mai demontează nodurile din DOM în stilul clasic din React când schimbi ruta, ci păstrează layout-urile și face un swap direct la Server Components. Rezultatul? AnimatePresence nu apucă să vadă animația de exit (exit={{ opacity: 0 }}) pentru că React unmount-ează instant arborele intern înainte ca Framer Motion să-și execute frame-urile.
Soluția greșită pe care o vezi peste tot
Prima reacție a multora e să trântească AnimatePresence în layout.tsx și să îi treacă key={pathname} luat dintr-un usePathname(). Pare că funcționează la o primă vedere, dar imediat apar bug-uri de hydratare, paginile noi se suprapun peste cele vechi pe verticală și scroll position-ul se strică urât de tot. Am pierdut vreo două ore încercând să rezolv un layout shift de 40px din cauza asta.
layout.tsx persistă între rute și nu este re-randat de la zero. Dacă vrei ca o bucată de UI să fie complet reconstruită la fiecare navigare, trebuie să folosești fișierul template.tsx oferit de Next.js.
Pattern-ul care funcționează: FrozenRouter
Pentru ca animația de exit să se deruleze până la capăt, trebuie să „înghețăm” contextul de navigație pentru componenta care pleacă. Altfel, Next.js schimbă conținutul paginii instantaneu, chiar dacă cadrul de animat rămâne pe ecran 300ms.
Soluția curată implică crearea unui wrapper de client numit PageAnimatePresence pe care îl folosești direct în template.tsx. Trucul este să reții contextul rutei vechi într-un useRef sau într-un provider custom cât timp animația de exit rulată de AnimatePresence este în desfășurare.
Codul din exemplul de mai jos folosește template.tsx combinat cu un wrapper dedicat de client. Când ruta se schimbă, template.tsx creează un nod nou, oferind un unmount curat pentru pagina precedentă.
Trade-off-uri reale
Tranzițiile fluide între pagini vin cu un cost pe care trebuie să ți-l asumi:
- Perf impact pe mobil: Dacă ai o pagină complexă cu multe DOM nodes, un exit animation de 300ms poate scădea FPS-ul la 40-45 pe telefoane mid-range. Am fost nevoit să dezactivez animațiile de exit pe mobil folosind
useMediaQuerypentru că jank-ul era prea vizibil. - Streaming-ul cu Suspense suferă: Dacă aștepți ca animația de exit să se termine înainte să afișezi conținutul nou, amâni render-ul inițial. Practic, anulezi o parte din beneficiile de viteza nativă oferite de Server Components.
- Scroll restoration: Trebuie să gestionezi manual resetarea scroll-ului (
window.scrollTo(0, 0)) în callback-ulonAnimationComplete, altfel pagina nouă s-ar putea să se încarce la jumătatea ecranului.
Voi folosiți tranziții de pagină pe App Router în producție sau ați renunțat la ele de dragul performanței brute?