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.

Hover, then click. Escape or a click anywhere sends it home.

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.

photo-card.tsx
// 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,
        )}
    </>
  );
}
The component, verbatim: this page renders this exact file.