Click a photo in a grid and a good lightbox doesn't just fade in. The photo grows out of the thumbnail you clicked, and when you close it, it shrinks back into the same spot. Your eye never loses track of it.

Most implementations get close and then break on one detail: the thumbnail is usually cropped. Grids use object-fit: cover so every cell has the same shape, which means the thumbnail shows only the middle of the photo. Grow the full image from that box and you see a jump in the first frame, because the edges the thumbnail was hiding suddenly appear.

This is how liftbox handles it. The whole thing is about thirty lines.

Step 1: lay the image out where it will end up

Render the lightbox with the image at its final size and position, then measure both boxes:

const to = image.getBoundingClientRect(); // where the image rests const from = thumbnail.getBoundingClientRect(); // the thumbnail that was clicked 

This is the FLIP idea: First and Last are measured, then you Invert and Play. The image never animates its layout. It only gets a temporary transform that makes it look like it is still sitting on the thumbnail.

Step 2: move and scale it onto the thumbnail

Line up the centers, and pick a uniform scale. Uniform matters: scaling width and height by different amounts would squash the photo.

function getFlipTransform(from: Rect, to: Rect) { return { x: from.left + from.width / 2 - (to.left + to.width / 2), y: from.top + from.height / 2 - (to.top + to.height / 2), scale: Math.max(from.width / to.width, from.height / to.height), }; } 

Math.max is the important part. It picks the smallest scale at which the image covers the thumbnail completely, which is exactly what object-fit: cover did to it in the grid.

Step 3: crop it like the thumbnail

At that scale the image is bigger than the thumbnail along one axis. The thumbnail hid that overflow, so we hide it too, with clip-path:

function getCropInset(media: Size, window: Size, scale: number) { const x = Math.max((media.width - window.width / scale) / 2, 0); const y = Math.max((media.height - window.height / scale) / 2, 0); return `inset(${y}px ${x}px)`; } 

The detail that is easy to get wrong: clip-path applies in the element's own coordinates, before the transform. So the visible area is the thumbnail's size divided by the scale, not the thumbnail's size itself. Whatever is left over gets split evenly between the two sides, because object-fit: cover centers by default.

Step 4: play it

The Web Animations API animates both properties together, so there is no need for a CSS class dance:

const { x, y, scale } = getFlipTransform(from, to); const keyframes = [ { transform: `translate(${x}px, ${y}px) scale(${scale})`, clipPath: getCropInset(to, from, scale), }, { transform: "none", clipPath: "inset(0px 0px)" }, ]; image.animate(keyframes, { duration: 320, easing: "cubic-bezier(0.2, 0, 0, 1)" }); 

Closing is the same keyframes reversed, with fill: "forwards" so the image stays on the thumbnail for the frame before the dialog unmounts. Neither property touches layout, so the page underneath never reflows while the photo is in the air.

The edge cases that take the rest of the time

The thumbnail scrolled away. If the user navigated to slide 30 and the grid only shows slide 1, there is nothing to fly back into. liftbox checks that the thumbnail's rect is on screen and otherwise falls back to a short zoom and fade.

The image is rotated. If the user turned the photo a quarter, its width and height swap. The crop has to be computed along the image's own axes, so swap the thumbnail's width and height before calling getCropInset.

The image is zoomed. Closing from a zoomed state should start from where the image is actually drawn, not from its layout box. Measure it with the zoom applied, reset the zoom, and use that rect as the first keyframe.

Reduced motion. Check prefers-reduced-motion before animating and skip the flight entirely when it is set. liftbox also reads the duration from a CSS variable, so a site can tune or turn off the animation without touching JavaScript.

Focus. When the flight ends, move focus back to the thumbnail. Keyboard and screen reader users then land where they started, which is the same promise the animation makes visually.

Try it

All of this is in liftbox, an MIT React lightbox with zero dependencies, about 13 kB, with grids, zoom and video built in.

npm i liftbox 

Docs and live demos: https://liftbox.digital. The flight code is in src/Lightbox.tsx and src/geometry.ts if you want to read the real thing.