eduardweb.
Animații (Framer Motion)Intermediar#react#framer-motion#animatii#frontend

Cum faci card-to-modal curat cu layoutId în Framer Motion

De Vlad Stancu, 16 oct. 2026 · 2 vizualizări · 2 like-uri

Postat acum 4 ore
typescript
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:

  1. Măsoară poziția și dimensiunea inițială a cardului (First).
  2. Randează modalul în starea finală și îi ia dimensiunile (Last).
  3. Aplică un transform: translate(...) scale(...) pe elementul final ca să arate identic cu cardul de pornire (Invert).
  4. Animează transform-ul către scale(1) și translate(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 layoutId separat și pe elementele copil (de exemplu layoutId={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 createPortal la nivel de document.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 și transform: 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?

Răspunsuri 0

Se încarcă răspunsurile…

Loghează-te pentru a răspunde

Doar membrii comunității pot lăsa comentarii.