| 57 | // to sit over the clicked thumbnail, then animate the transform away so it |
| 58 | // visually grows from the thumbnail into the full view. |
| 59 | const playFlip = () => { |
| 60 | const img = imgRef.current; |
| 61 | if (!originRect || !img || hasPlayedRef.current) { |
| 62 | return; |
| 63 | } |
| 64 | const final = img.getBoundingClientRect(); |
| 65 | if (!final.width || !final.height) { |
| 66 | // Not laid out yet (image still loading) — onLoad will retry. |
| 67 | return; |
| 68 | } |
| 69 | hasPlayedRef.current = true; |
| 70 | |
| 71 | // A single uniform scale (not independent sx/sy) so the image keeps its |
| 72 | // aspect ratio and never warps mid-flight. `min` contains the start frame |
| 73 | // within the thumbnail's footprint, so it reads as lifting off from there. |
| 74 | const scale = Math.min( |
| 75 | originRect.width / final.width, |
| 76 | originRect.height / final.height, |
| 77 | ); |
| 78 | // Translate centers together (transform-origin is the center too), so the |
| 79 | // image grows out of where the thumbnail sat rather than from a corner. |
| 80 | const dx = |
| 81 | originRect.left + originRect.width / 2 - (final.left + final.width / 2); |
| 82 | const dy = |
| 83 | originRect.top + originRect.height / 2 - (final.top + final.height / 2); |
| 84 | // Transform scales the corner radius along with everything else, so a fixed |
| 85 | // radius would render near-square at the small start scale and then "snap" |
| 86 | // round. Pre-divide by the scale so the rendered radius stays a constant |
| 87 | // ~16px (rounded-16) for the whole animation; read the resting value off the |
| 88 | // element so it tracks the class instead of a hardcoded number. |
| 89 | const restRadius = |
| 90 | parseFloat(getComputedStyle(img).borderTopLeftRadius) || 16; |
| 91 | |
| 92 | img.style.transformOrigin = 'center center'; |
| 93 | img.style.transition = 'none'; |
| 94 | img.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`; |
| 95 | img.style.borderRadius = `${restRadius / scale}px`; |
| 96 | img.style.opacity = '1'; |
| 97 | // Two frames: let the browser paint the start state first, then transition |
| 98 | // to identity — doing both in one frame can coalesce and snap. |
| 99 | requestAnimationFrame(() => { |
| 100 | requestAnimationFrame(() => { |
| 101 | img.style.transition = |
| 102 | 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1), border-radius 300ms cubic-bezier(0.16, 1, 0.3, 1)'; |
| 103 | img.style.transform = 'none'; |
| 104 | img.style.borderRadius = `${restRadius}px`; |
| 105 | }); |
| 106 | }); |
| 107 | }; |
| 108 | |
| 109 | useLayoutEffect(() => { |
| 110 | playFlip(); |