Photo Stamp

A photo that lifts off the page at full size over a blurred backdrop, the same element the whole way.

Loading preview

Installation

npx shadcn@latest add @loomui/photo-stamp

Usage

import { PhotoStamp } from "@/components/ui/photo-stamp"
<PhotoStamp
  src="/photos/gorge.jpg"
  alt="A river cutting through a valley"
  caption="Gorge, 06:14"
  imageClassName="h-28 w-40"
/>

Ship the full-size file. The open state is the photo at its real size, so a thumbnail-resolution source arrives soft at the end of a journey that spent the whole way looking sharp.

How it works

One photo, not two

The obvious build is a small photo and a big photo that trade places. It is also the one that never quite works: for a frame or two both exist, and the seam between them is the only thing anyone sees.

There is one photo here. It is measured where it sits on the page, drawn at its final size, and put back down onto that spot with a transform. Then the transform is released.

const scale = geo.scale + (1 - geo.scale) * p
const x = (geo.from.x - geo.to.x) * (1 - p)
const y = (geo.from.y - geo.to.y) * (1 - p)

from is the rect the resting photo reported. to is the box it is going to. Nothing in between is measured, because nothing in between is laid out.

The open box borrows the photo's own aspect

The open size is fitted to the viewport at the aspect the photo already has on the page, not the aspect of the file. A thumbnail cropping a landscape photo into a square keeps that crop the whole way up.

That is what makes one number enough. Both boxes share an aspect, so the scale is the same on both axes, and none of the un-distorting a two-axis morph needs has to happen at all.

The corner is written every frame

A scaled box scales its corners with it. Left alone, a 10px radius on a photo that starts at a quarter size arrives four times too round and shrinks back down over the course of the lift, which reads as the corners breathing.

node.style.borderRadius = `${radius / scale}px`

Dividing by the scale the photo is at right now holds the corner at one size in screen pixels. This is the one property here that is not on the compositor. It is a paint on a single element, driven by the same loop that is already painting, and it is the difference between a photo and a picture of a photo being zoomed.

Why a spring, and what it costs

The lift can be interrupted. Click to close while it is still going up and a curve would stop dead and start again from nothing. A spring carries the velocity it already had.

<PhotoStamp spring={{ duration: 0.5, bounce: 0 }} />

bounce stays at 0 by default. A photograph overshooting its own frame is a personality the photograph did not ask for. Raise it if the surface is a marketing page rather than a gallery.

The cost is that a spring has no fixed end, so the close cannot unmount on a timer. It unmounts when the value it is travelling on reaches zero, which is also the frame the photo is back where it started.

The close runs at 80% of the open's duration, because an exit should be quicker than the entrance it undoes. Retargeting the spring's stiffness mid-flight rather than starting a second animation is what lets a close that interrupts an open keep the speed the photo already had.

The photo is also remeasured at the moment the close begins, not reused from when it opened. Where it has to land is wherever the page is showing it now.

The veil is on a clock

Only the photo is on the spring. The blur behind it and the caption under it are CSS animations, because they are a running order rather than something that has to match where the photo is.

A spring covers most of its distance in the first third, so anything sequenced against its progress fires almost at once and reads as a twitch.

Both veil keyframes name one end and let the browser fill the other:

@keyframes photo-stamp-veil-out {
  to {
    opacity: 0;
  }
}

The exit therefore starts from wherever the veil actually is. Written from opacity: 1 instead, an exit interrupting an entrance would flash back to full for a frame first.

The page is held still without taking its scrollbar away

The usual scroll lock puts overflow: hidden on the root. That removes the scrollbar, the viewport gets wider by its width, and everything positioned against the viewport moves: a fixed header, a sticky sidebar, anything centred.

It all moves back when the lock is released, and the lock is released on the last frame of the close. That is the single frame in the whole interaction that has to be perfectly still, and instead the photo lands into a page jumping sideways. It reads as the photo snapping at the end, which is not where the fault is.

So nothing is locked. The gestures are refused instead:

window.addEventListener("wheel", block, { passive: false })
window.addEventListener("touchmove", block, { passive: false })

React attaches wheel and touchmove passively at the root, so an onWheel prop cannot refuse them. These have to be native listeners. Scroll keys are turned away in the dialog's own keydown.

The layout is untouched from the first frame to the last. Nothing is measured, nothing is compensated, nothing moves.

focus() scrolls its target into view by default, which is the same jolt by another route. Both the focus into the dialog and the focus back onto the photo pass preventScroll: true.

Props

PhotoStamp takes everything a figure takes, plus:

PropTypeDefaultDescription
srcstringrequiredThe photo, at full size.
altstringrequiredAlso the trigger's accessible name.
captionReactNodenoneSits under the photo, in both states.
radiusnumber10Corner radius, held steady in screen pixels.
fillnumber0.72How much of the viewport the open photo fills.
springSpringOptions{duration: .5, bounce: 0}How the photo travels.
imageClassNamestringh-24 w-32Sizes the photo at rest.

Escape closes it, so does the backdrop, the photo itself, and the button in the corner. Focus moves into the dialog on open and back to the photo on close.

Reduced motion

With reduced motion the photo is already open on the first frame and closes on the last. The veil and the caption appear without their entrances. Nothing is hidden and nothing waits.