import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
export function ExpandableCard({ item }: { item: { id: string; title: string } }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<motion.div
layoutId={`card-${item.id}`}
onClick={() => setIsOpen(true)}
style={{ borderRadius: 16 }}
className="cursor-pointer bg-neutral-900 p-6 shadow-md"
>
<motion.h3 layout="position" 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}`}
style={{ borderRadius: 24 }}
className="w-full max-w-xl bg-neutral-900 p-8 shadow-2xl"
>
<motion.h3 layout="position" className="text-2xl font-bold text-white">
{item.title}
</motion.h3>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-4 text-neutral-300">
Conținutul detaliat se animă separat cu fade-in pentru a preveni deformarea.
</motion.p>
<button onClick={() => setIsOpen(false)} className="mt-6 text-sm text-neutral-400">
Închide
</button>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}Toată lumea vrea tranziții fluide de tip App Store unde apeși pe un card și se deschide instant într-o pagină completă. În React, Framer Motion face asta aparent din două linii cu proprietatea layoutId, dar în practică te lovești rapid de deformări vizuale ciudate.
Am implementat pattern-ul ăsta anul trecut pe un catalog B2B cu vreo 4.000 de produse unice. Designerul voia fix experiența aia nativă de iOS pe mobile web, fără redirecturi și fără flash de layout. A ieșit bine până la urmă, dar am stat o jumătate de zi doar să înțeleg de ce textul și imaginile arătau de parcă erau trase printr-un filtru de cauciuc în timpul animației.
De ce deformează Framer Motion elementele din interior
Când folosești layoutId, Framer Motion nu randează ambele elemente și le face crossfade cum cred mulți. În culise, el măsoară bounding box-ul cardului inițial și bounding box-ul modalului țintă, iar apoi animează proprietățile de transform: scale().
Aici apare buba. Pentru că face scale pe containerul părinte, tot ce este înăuntru — paragrafe, butoane, iconițe SVG — suferă aceeași scalare distorsionată pe axele X și Y dacă proporțiile cardului diferă de cele ale modalului (ceea ce se întâmplă aproape mereu).
Soluția pe care mulți o ratează este să transmiți layout="position" sau să pui layout individual pe componentele copil. Când setezi layout="position", Framer Motion animează doar mutarea pe coordonate (top, left), lăsând lățimea și înălțimea să fie calculate nativ sau compensate automat cu o transformare inversă.
Atenție la border-radius și backdrop
Altă capcană clasică e colțul rotunjit. Dacă ai border-radius: 12px pe card și border-radius: 0px sau 24px pe modal, nu seta clasa direct în Tailwind (rounded-xl). Scalarea GPU va face ca rotunjimea să se lungească urât pe diagonale.
Framer Motion are nevoie să știe explicit de acea proprietate pentru a rula interpolarea corectă. Pune style={{ borderRadius: ... }} direct pe componenta motion.div. Așa, motorul de animație calculează corect curbura pe fiecare frame în loc să aplice un scale brutal pe border-ul existent.
Trade-off-uri reale: când NU are sens layoutId
Merge fantastic pentru carduri de produs, galerii foto sau preview de articole unde conținutul din card se regăsește în proporție de 80% și în modal.
E un coșmar dacă:
- Conținutul modalului e încărcat asincron: dacă deschizi modalul și faci un fetch de 300ms pentru date, animația se termină pe un schelet gol, iar când vin datele ai un salt vizibil (layout shift) care strică tot farmecul.
- Ai scroll lock complex: pe Safari iOS, blocarea scroll-ului pe body când deschizi un container animat cu
layoutIdcere adesea hack-uri cuposition: fixedpe body ca să eviți săritura viewport-ului. - Performanță pe telefoane low-end: dacă ai 50 de carduri randate într-o listă și fiecare are
layoutId, bundle-ul de layout calculations devine greoi la scrolling rapid.
Voi cum ați rezolvat scroll lock-ul pe mobil în combinație cu tranzițiile de layout?