import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface Item {
id: string;
title: string;
desc: string;
}
export function ExpandableCards({ items }: { items: Item[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const activeItem = items.find((i) => i.id === selectedId);
return (
<div className="grid grid-cols-2 gap-4">
{items.map((item) => (
<motion.div
key={item.id}
layoutId={`card-${item.id}`}
onClick={() => setSelectedId(item.id)}
className="cursor-pointer rounded-lg bg-neutral-900 p-4"
>
<motion.h3 layoutId={`title-${item.id}`}>{item.title}</motion.h3>
</motion.div>
))}
<AnimatePresence>
{activeItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<motion.div
layoutId={`card-${activeItem.id}`}
className="w-full max-w-lg rounded-xl bg-neutral-900 p-6"
>
<motion.h3 layoutId={`title-${activeItem.id}`}>
{activeItem.title}
</motion.h3>
<motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{activeItem.desc}
</motion.p>
<button onClick={() => setSelectedId(null)}>Închide</button>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}Dacă ai vrut vreodată să transformi un card dintr-un grid într-un modal detaliat fără să scrii zeci de linii de CSS manual, layoutId din Framer Motion e scurtătura ideală. Îți arăt exact cum funcționează mecanismul și unde se rupe filmul dacă nu ești atent la detalii.
Cum funcționează de fapt sub capotă
Mulți cred că Framer Motion animează width, height, top și left. Greșit. Dacă ar face asta, pe un ecran de telefon mobil cu 120Hz ai avea frame drops instant.
Sub capotă se aplică tehnica FLIP (First, Last, Invert, Play). Când starea ta se schimbă și un element cu același layoutId apare în altă parte a arborelui DOM, Framer face următoarele:
- Măsoară poziția și dimensiunea inițială a cardului (First).
- Randează modalul în starea finală și îi ia dimensiunile (Last).
- Aplică un
transform: translate(...) scale(...)pe elementul final ca să arate identic cu cardul de pornire (Invert). - Animează transform-ul către
scale(1)șitranslate(0, 0)pe GPU (Play).
La un magazin online de haine unde am implementat chestia asta pe o listă de 40 de produse, am trecut de la vreo 35-40 FPS cu animații clasice direct la 60 FPS constant. Diferența pe mobil a fost de la cer la pământ.
Capcana de care te lovești garantat: deformarea conținutului
Pentru că FLIP scalează containerul, tot ce e înăuntru va fi deformat temporar. Dacă ai un titlu <h2>, fontul se va lăți ca o gumă mestecată timp de 300ms. Arată ieftin și neprofesionist.
Ca să eviți mizeria asta, ai două variante:
- Folosești
layoutIdseparat și pe elementele copil (de exemplulayoutId={title-${item.id}}). În felul ăsta, Framer calculează o animație independentă pentru text și inversează deformarea generată de părinte. - Pui
layout="position"pe text dacă dimensiunea fontului nu se schimbă între card și modal, forțând motorul să animeze doar poziția X/Y, nu și scalarea.
Trade-off-uri reale
layoutId e genial pentru UI-uri de tip master-detail, dar vine cu niște compromisuri clare:
- Portal-uri și stacking context: Dacă montezi modalul într-un
createPortalla nivel dedocument.body(așa cum ar trebui pentru accesibilitate),layoutIdîncă funcționează, dar trebuie să ai grijă ca ambele noduri să folosească același context de animație. Altfel, tranziția se rupe și cardul pur și simplu sare în modal fără interpolare. - Safari font smoothing: Pe iOS Safari o să observi uneori un mic „flicker” la finalul animației, exact când GPU-ul predă controlul randării textului către CPU. Se rezolvă de obicei cu
-webkit-backface-visibility: hiddenșitransform: translateZ(0)pe containerul modalului.
Merge fantastic pentru tranziții între stări bine definite, dar devine un coșmar dacă ai liste virtualizate (gen react-window), unde nodul de pornire poate fi demontat din DOM în timp ce tu derulezi pagina dedesubt.
Voi ați lovit vreodată problema cu font-flicker pe Safari la scale animations? Ați găsit alt workaround mai curat decât forțarea compozitării pe GPU?