import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
export function ExpandableCard({ item }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<motion.div
layoutId={`card-${item.id}`}
onClick={() => setIsOpen(true)}
className="p-4 bg-zinc-800 rounded-xl cursor-pointer"
>
<motion.h3 layoutId={`title-${item.id}`} className="text-lg font-bold text-white">
{item.title}
</motion.h3>
</motion.div>
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60">
<motion.div
layoutId={`card-${item.id}`}
className="w-full max-w-lg p-6 bg-zinc-900 rounded-2xl"
>
<motion.h3 layoutId={`title-${item.id}`} className="text-2xl font-bold text-white">
{item.title}
</motion.h3>
<p className="mt-4 text-zinc-400">{item.fullDescription}</p>
<button onClick={() => setIsOpen(false)} className="mt-6 px-4 py-2 bg-blue-600 rounded-lg text-white">
Închide
</button>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}Dacă ai încercat vreodată să implementezi manual tranziții de tip shared element (cardul dintr-o listă care se deschide fluid într-un modal pe tot ecranul), știi cât de mult boilerplate cu getBoundingClientRect() și tehnica FLIP implică. Framer Motion rezolvă asta aproape declarativ prin prop-ul layoutId.
Am folosit abordarea asta pe un dashboard cu vreo 40 de widget-uri interactive unde clienții voiau tranziții native-like pe mobile fără să scadă sub 60fps. Câștigul a fost uriaș pe partea de mentenanță de cod: am șters vreo 250 de linii de JS custom pentru calculul coordonatelor.
Cum funcționează sub capotă
Secretul lui layoutId stă în faptul că Framer Motion ține minte poziția și dimensiunea elementului din DOM înainte de mutare sau demontare. Când un nou nod cu același layoutId apare pe ecran (sau nodul curent își schimbă structura), biblioteca aplică automat transformări CSS (transform: translate(...) scale(...)) pentru a interpola poziția veche cu cea nouă.
Asta înseamnă că nu ai reflow sau repaint agresiv în timpul tranziției; totul se execută pe GPU, fiind accelerat hardware.
Capcanele clasice de care te lovești în producție
Deși pare magie curată la primul demo, în aplicații reale apar câteva probleme enervante:
-
Deformarea textului și a copiilor (Scale distortion): Când Framer Motion animează mărimea containerului via
scale, tot conținutul din interior se întinde ca un elastic dacă nu ești atent. Soluția este să adaugi prop-ullayoutși pe componentele copil (titluri, butoane, imagini) sau să foloseștilayoutIddiferențiat pentru elementele interioare comune (de exemplulayoutId={image-${item.id}}). -
Raza colțurilor (
border-radius): Dacă aiborder-radius: 12pxpe card șiborder-radius: 0pe modalul fullscreen, animația nativă o să distorsioneze curba dacă o setezi din Tailwind sau CSS simplu. Trebuie să pasezi proprietatea direct înanimate={{ borderRadius: ... }}sau ca stil inline pemotion.divca Framer să poată corecta scale-ul matricei. -
Ordinea în DOM și Z-Index: Când modalul se deschide, trebuie neapărat randat într-un
<AnimatePresence>și ideal poziționat cu unz-indexmare direct peste viewport, altfel riști să fie tăiat de containere părinte cuoverflow: hidden.
Trade-off sincer
Pentru liste mici și medii (până în 50-100 de elemente pe ecran), performanța este impecabilă. Dacă ai însă un feed infinit cu 1.000 de carduri virtualizate, sincronizarea layoutId devine costisitoare la memorie și poate duce la frame drops la închiderea rapidă a modalului. În cazul ăla, o simplă animație de fade/scale clasică fără shared-layout e mult mai sigură.
Voi cum gestionați tranzițiile de genul pe mobile web? Vă bazați pe Framer Motion sau preferați View Transitions API-ul nativ din browsere?