Review
Shared-layout expand for plant specimen cards
Botanic shop catalog feels abrupt when a specimen opens into the detail sheet. Add framer-motion shared layout morph from grid card → expanded panel so the photo and title glide in place. Each specimen gets its own layoutId pair so only the opened card animates.
ReactTier 1framer-motionlayoutfrontend
Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.
components/catalog/SpecimenGrid.tsx+19-10
| 22 | 22 | export function SpecimenGrid({ specimens }: { specimens: Specimen[] }) { | |
| 23 | 23 | const [openId, setOpenId] = useState<string | null>(null); | |
| 24 | 24 | const open = specimens.find((s) => s.id === openId) ?? null; | |
| 25 | 25 | ||
| 26 | 26 | return ( | |
| 27 | 27 | <LayoutGroup> | |
| 28 | 28 | <ul className="specimen-grid"> | |
| 29 | 29 | {specimens.map((s) => ( | |
| 30 | - | <li key={s.id} onClick={() => setOpenId(s.id)}> | |
| 31 | - | <img src={s.thumbUrl} alt={s.latinName} /> | |
| 32 | - | <h3>{s.commonName}</h3> | |
| 33 | - | </li> | |
| 30 | + | <li key={s.id}> | |
| 31 | + | <button type="button" onClick={() => setOpenId(s.id)} className="specimen-card"> | |
| 32 | + | <motion.img layoutId={`specimen-photo-${s.id}`} src={s.thumbUrl} alt={s.latinName} /> | |
| 33 | + | <motion.h3 layoutId={`specimen-title-${s.id}`}>{s.commonName}</motion.h3> | |
| 34 | + | </button> | |
| 35 | + | </li> | |
| 34 | 36 | ))} | |
| 35 | 37 | </ul> | |
| 36 | 38 | <AnimatePresence> | |
| 37 | 39 | {open && ( | |
| 38 | - | <div className="specimen-sheet" role="dialog" aria-modal="true"> | |
| 39 | - | <img src={open.heroUrl} alt={open.latinName} /> | |
| 40 | - | <h2>{open.commonName}</h2> | |
| 41 | - | <p>{open.careNotes}</p> | |
| 42 | - | <button type="button" onClick={() => setOpenId(null)}>Close</button> | |
| 43 | - | </div> | |
| 40 | + | <motion.div | |
| 41 | + | className="specimen-sheet" | |
| 42 | + | role="dialog" | |
| 43 | + | aria-modal="true" | |
| 44 | + | initial={{ opacity: 0 }} | |
| 45 | + | animate={{ opacity: 1 }} | |
| 46 | + | exit={{ opacity: 0 }} | |
| 47 | + | > | |
| 48 | + | <motion.img layoutId={`specimen-photo-${open.id}`} src={open.heroUrl} alt={open.latinName} /> | |
| 49 | + | <motion.h2 layoutId={`specimen-title-${open.id}`}>{open.commonName}</motion.h2> | |
| 50 | + | <p>{open.careNotes}</p> | |
| 51 | + | <button type="button" onClick={() => setOpenId(null)}>Close</button> | |
| 52 | + | </motion.div> | |
| 44 | 53 | )} | |
| 45 | 54 | </AnimatePresence> | |
| 46 | 55 | </LayoutGroup> | |
| 47 | 56 | ); | |
| 48 | 57 | } |