Skip to main content
Back

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.

Dialog prototype
Dialog prototype

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.

Popover prototype
Popover prototype

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.

Popover prototype

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.

Pure Motion prototype
Pure Motion prototype

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.