Image Modal Prototypes
Comparing three approaches to an image lightbox with smooth layoutId transitions: Base UI Dialog, Base UI Popover, and pure Motion. The goal is a seamless expand/collapse animation without stretching, blank frames, or misalignment on first open.
A. DialogDialog + layoutId
Base UI Dialog for accessibility. Conditional render swaps thumbnail for a hidden placeholder when open, so only one layoutId element exists at a time. aspectRatio on the card wrapper prevents collapsed-height snapshots. Skeleton fallback for loading polish.

Pros
- Full accessibility (focus trap, Escape, aria)
- Scrollable viewport
- Skeleton loading state
Cons
- Conditional swap adds complexity
- Dialog wrapper hierarchy can interfere with Motion projection
B. PopoverPopover + layoutId
Base UI Popover with modal mode. Trigger stays in DOM (no swap). Positioner overridden to fixed-center. layoutId animates from anchor position to center.

Pros
- No conditional swap needed
- Anchor-aware positioning
- Trigger stays in DOM
Cons
- Fighting Popover's positioning model
- Extra Positioner wrapper in DOM
2b. Popover 2Popover + layoutId (motion.div)
Base UI Popover with modal mode. Trigger stays in DOM (no swap). Positioner overridden to fixed-center. layoutId animates from anchor position to center.

C. Pure MotionMotion + portal
No Base UI dialog/popover primitives for layout. layoutId with AnimatePresence and createPortal. Manual accessibility handling. Follows Motion's documented shared layout animation pattern.

Pros
- Simplest DOM tree
- No wrapper interference with Motion projection
- Motion has full control of FLIP math
Cons
- Must handle accessibility manually
- No scroll-lock or focus-trap for free
What we tried
A reference of every approach attempted across the full history of this component, documenting what worked, what broke, and why.
- layoutId on card wrapper, conditional swap (thumbnail unmounts)Partial
- Works on second open; first open collapses to ~40px because the <img> hasn't painted and the card has no height hint. Motion captures the FLIP "to" snapshot before the browser resolves intrinsic dimensions.
- layoutId on card + aspectRatio on card wrapperWorks
- Fixes the first-open collapse. aspectRatio on the motion.div itself (not just the inner <img>) gives Motion a correct layout rect to snapshot, regardless of image load state.
- layoutId on inner motion.img (not the card)Broken
- Image animates but card styling (border-radius, shadow, padding) doesn't transition — it pops. Only the layoutId element gets the FLIP animation; the card wrapper is a different element.
- layout on child motion.img inside layoutId parentBroken
- Causes stretching/distortion. The child's layout animation applies its own scale correction that compounds with the parent's layoutId scale, creating a double-transform.
- layout="position" on motion.imgPartial
- Position animates but size snaps instantly. layout="position" only animates translate, not scale. For a lightbox, the size change IS the animation.
- layoutRoot on a fixed container wrapping the modalBroken
- Broke centering and caused offset issues. layoutRoot accounts for page scroll inside fixed containers, but combined with Dialog.Viewport (also fixed with its own centering), it added an extra projection layer that warped coordinate math.
- LayoutGroup wrapping thumbnail + modalNo effect
- No observable improvement. LayoutGroup synchronizes layout animations across sibling components that re-render independently. Here, thumbnail and modal are in the same React tree and re-render together via the same open state.
- Parent motion.div with initial={{ opacity: 0 }} wrapping the layoutId cardBroken
- Modal appeared blank/invisible on open, then popped in. Motion's layoutId crossfade already manages opacity between old and new elements. A separate parent opacity animation compounded with the crossfade (0 × crossfade = invisible).
- objectFit: contain on modal imageBroken
- Empty whitespace below/beside the image. object-fit: contain letterboxes the image when the container's aspect ratio doesn't exactly match. Since aspectRatio already ensures correct proportions, object-fit was redundant and harmful.
- Dialog.Popup with h-full w-full + no centering on itselfBroken
- Modal card pinned to top-left, not centered. The Popup filled the entire viewport but had no items-center justify-center. The parent Viewport's centering applied to the Popup container, but the card inside sat at flex-start.