Sep 26, 2026 · Component
PhotoCard
A framed photo that leans toward the cursor under a pearlescent light, and opens into a blurred room on click. Built for the pinboard in my footer; extracted here as a single file you can drop into your own project.
What it does, and why
Three decisions carry the feel. The open is a shared-layout morph, not a crossfade: the photo itself travels from its seat to the centre of the room and back, so the eye never loses the object. One spring drives both directions, 0.6s, no bounce, because an exit that snaps faster than its entrance reads as a cut, and a photo is paper, not rubber.
The light is two gradients in one span: a warm white core over a pastel cone whose angle rides the cursor, the way mother-of-pearl shifts hue with the eye. It composites through mix-blend overlay so the photo's own tones carry it. And the lean is sprung at 10 degrees on the small card, 6 on the large one: a big surface amplifies the same angle, so the number goes down for the movement to stay equal.
Use it
One file, two dependencies you likely have: framer-motion and Tailwind. Everything is in the single block below: the usage sits at the top as a comment, the component follows, and Copy takes the whole thing.
Escape and a click anywhere close it; the overlay is a focused dialog while it is up, and every movement collapses under prefers-reduced-motion.
// Usage: copy this whole file, then
//
// import { PhotoCard } from "@/components/photo-card";
//
// <PhotoCard
// src="https://picsum.photos/id/429/600/800"
// alt="A framed photo"
// width={180} // optional, px
// aspect="3 / 4" // optional
// rotate={-3} // optional resting tilt, degrees
// />
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
motion,
AnimatePresence,
useMotionValue,
useSpring,
useReducedMotion,
} from "framer-motion";
const ZOOM_SPRING = { type: "spring", duration: 0.6, bounce: 0 } as const;
function Halo({
radius,
className = "",
}: {
radius: number;
className?: string;
}) {
return (
<span
aria-hidden
className={`pointer-events-none absolute inset-0 rounded-[inherit] mix-blend-overlay ${className}`}
style={{
background: [
`radial-gradient(${radius}px at var(--lx, -999px) var(--ly, -999px), rgba(255,250,240,0.75), rgba(255,250,240,0) 70%)`,
`conic-gradient(from var(--la, 0deg) at var(--lx, -999px) var(--ly, -999px), rgba(255,196,214,0.5), rgba(255,228,178,0.5), rgba(196,235,214,0.5), rgba(198,212,255,0.5), rgba(240,200,255,0.5), rgba(255,196,214,0.5))`,
].join(","),
maskImage: `radial-gradient(${radius}px at var(--lx, -999px) var(--ly, -999px), black, transparent 72%)`,
}}
/>
);
}
export function PhotoCard({
src,
alt,
width = 180,
aspect = "3 / 4",
rotate = 0,
}: {
src: string;
alt: string;
width?: number;
aspect?: string;
rotate?: number;
}) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [lit, setLit] = useState(false);
const reduced = useReducedMotion();
useEffect(() => setMounted(true), []);
const tiltX = useMotionValue(0);
const tiltY = useMotionValue(0);
const leanX = useSpring(tiltX, { stiffness: 300, damping: 30 });
const leanY = useSpring(tiltY, { stiffness: 300, damping: 30 });
function track(e: React.PointerEvent, el: HTMLElement, deg: number) {
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
if (reduced) {
tiltX.jump(-py * deg);
tiltY.jump(px * deg);
} else {
tiltX.set(-py * deg);
tiltY.set(px * deg);
}
el.style.setProperty("--lx", `${e.clientX - r.left}px`);
el.style.setProperty("--ly", `${e.clientY - r.top}px`);
el.style.setProperty(
"--la",
`${((e.clientX + e.clientY) * 0.22) % 360}deg`,
);
}
function rest() {
tiltX.set(0);
tiltY.set(0);
}
const dialog = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
dialog.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const frame =
"rounded-xl border border-black/10 bg-white p-1.5 shadow-[0_1px_3px_rgba(0,0,0,0.1),0_8px_24px_rgba(0,0,0,0.12)] dark:border-white/10 dark:bg-[#1c1c1c]";
const print = (big: boolean) => (
<img
src={src}
alt={big ? alt : ""}
draggable={false}
className="block w-full rounded-lg object-cover"
style={{ aspectRatio: aspect }}
/>
);
return (
<>
<motion.button
type="button"
layoutId={src}
onClick={() => setOpen(true)}
onPointerMove={(e) => track(e, e.currentTarget, 10)}
onPointerDown={() => setLit(true)}
onPointerUp={() => setLit(false)}
onPointerLeave={() => {
setLit(false);
rest();
}}
whileHover={{ scale: 1.03 }}
initial={{ rotate }}
className={`group relative block cursor-pointer touch-none ${frame}`}
style={{
width,
visibility: open ? "hidden" : undefined,
transformPerspective: 600,
rotateX: leanX,
rotateY: leanY,
}}
aria-label={alt}
>
{print(false)}
<Halo
radius={230}
className={`transition-opacity duration-300 group-hover:opacity-100 ${
lit ? "opacity-100" : "opacity-0"
}`}
/>
</motion.button>
{mounted &&
createPortal(
<AnimatePresence>
{open && (
<motion.div
ref={dialog}
role="dialog"
aria-modal="true"
aria-label={alt}
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-white/90 p-6 outline-none backdrop-blur-md dark:bg-[#111]/90"
onClick={() => setOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: { duration: 0.45, ease: "easeOut" },
}}
>
<motion.div layoutId={src} transition={ZOOM_SPRING}>
<motion.div
initial={{ rotate }}
animate={{ rotate: 0 }}
exit={{ rotate }}
transition={ZOOM_SPRING}
onPointerMove={(e) => track(e, e.currentTarget, 6)}
onPointerLeave={rest}
className={`relative w-[min(420px,90vw)] touch-none ${frame} p-2`}
style={{
rotateX: leanX,
rotateY: leanY,
transformPerspective: 900,
}}
>
{print(true)}
<Halo radius={320} />
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}