Cards
PolaroidDevelop
An instant photo that starts blank and develops on its own once it is on screen. Shake it with the pointer or press it to speed it up. Press it when done for a new shot.
Download
- React
- Tailwind CSS v4
- motion
components/ui/polaroid-develop.tsx
"use client";
import { useRef, useState, type PointerEvent } from "react";
import { animate, motion, useAnimationFrame, useInView, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
type PolaroidDevelopProps = {
src: string;
alt: string;
/** Written on the white strip. */
caption?: string;
/** Seconds to develop on its own. */
duration?: number;
/** How much each shake speeds it up. 0 turns shaking off. */
shake?: number;
/** Resting tilt, in degrees. */
tilt?: number;
className?: string;
};
/* PolaroidDevelop: an instant photo that comes out blank and develops on its own. Shake it and it goes faster. Press it when done for a new shot. */
export function PolaroidDevelop({ src, alt, caption, duration = 9, shake = 1, tilt = -2, className = "" }: PolaroidDevelopProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const seen = useInView(box, { amount: 0.4 });
const [done, setDone] = useState(false);
const p = useMotionValue(0);
const flash = useMotionValue(0);
const push = useMotionValue(0);
const lean = useSpring(push, { stiffness: 320, damping: 11 });
const rotate = useTransform(lean, (v) => tilt + v);
const lastDir = useRef(0);
const saturate = useTransform(p, [0.1, 1], [0.15, 1.04]);
const contrast = useTransform(p, [0, 0.55, 1], [0.5, 0.82, 1.03]);
const bright = useTransform(p, [0, 0.45, 1], [1.15, 0.95, 1]);
const warm = useTransform(p, [0.2, 0.6, 1], [0, 0.38, 0.06]);
const filter = useMotionTemplate`saturate(${saturate}) contrast(${contrast}) brightness(${bright}) sepia(${warm})`;
const milk = useTransform(p, [0, 0.72], [1, 0]);
const reach = useTransform(p, [0, 0.72], [-30, 110]);
const edge = useTransform(reach, (r) => r + 46);
const mask = useMotionTemplate`radial-gradient(circle at 46% 42%, transparent ${reach}%, #000 ${edge}%)`;
useAnimationFrame((_, dt) => {
if (push.get() !== 0) push.set(Math.abs(push.get()) < 0.05 ? 0 : push.get() * 0.9);
if (!seen || done) return;
const next = reduce ? 1 : Math.min(1, p.get() + dt / (duration * 1000));
p.set(next);
if (next === 1) setDone(true);
});
function hurry(by: number) {
if (done) return;
const next = Math.min(1, p.get() + by);
p.set(next);
if (next === 1) setDone(true);
}
function move(e: PointerEvent<HTMLButtonElement>) {
if (reduce || !shake) return;
const dx = e.movementX;
if (Math.abs(dx) < 2) return;
push.set(Math.max(-9, Math.min(9, push.get() + dx * 0.5)));
const dir = Math.sign(dx);
if (dir !== lastDir.current) hurry(0.022 * shake);
lastDir.current = dir;
}
function press() {
if (done) {
p.set(0);
setDone(false);
if (!reduce) animate(flash, [1, 0], { duration: 0.6, ease: "easeOut" });
return;
}
if (reduce || !shake) return;
push.set(push.get() > 0 ? -8 : 8);
hurry(0.14 * shake);
}
return (
<div ref={box} className={`@container w-72 ${className}`}>
<motion.button
type="button"
onPointerMove={move}
onClick={press}
aria-label={done ? `${alt}. Take a new shot` : `Photo still developing: ${alt}. Shake it`}
style={{ rotate }}
className="block w-full touch-pan-y bg-[#f5f2ea] p-[1.05em] pb-0 text-[5cqw] shadow-[0_0.1em_0.2em_rgba(0,0,0,0.3),0_1.1em_1.7em_-0.5em_rgba(0,0,0,0.6)]"
>
<span className="relative block aspect-[1/1.02] overflow-hidden bg-[#2a2c27] shadow-[inset_0_0_0.2em_rgba(0,0,0,0.5)]">
<motion.img src={src} alt="" draggable={false} className="absolute inset-0 h-full w-full object-cover" style={{ filter }} />
<motion.span aria-hidden className="absolute inset-0 bg-[linear-gradient(150deg,#d4d8cb,#b9c2b3_60%,#a9b3a6)]" style={{ opacity: milk, maskImage: mask, WebkitMaskImage: mask }} />
<span aria-hidden className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,255,255,0.2),transparent_34%)]" />
<motion.span aria-hidden className="absolute inset-0 bg-white" style={{ opacity: flash }} />
</span>
<span className="flex h-[3.5em] items-center justify-center truncate px-[0.4em] font-['Segoe_Print','Bradley_Hand','Comic_Sans_MS',cursive] text-[1.1em] text-[#30343a]">
{caption}
</span>
</motion.button>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/polaroid-develop.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/polaroid-develop-demo.tsx
import { PolaroidDevelop } from "@/components/ui/polaroid-develop"; export default function PolaroidDevelopExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#3a2a1e] bg-[repeating-linear-gradient(92deg,rgba(0,0,0,0.13)_0_3px,transparent_3px_11px)] p-3"> <PolaroidDevelop src="/demo/dusk.jpg" alt="A hazy sky at dusk" caption="Kodai, last evening" className="w-[min(66cqh,84cqw,320px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | โ | Photo address. |
| alt | string | โ | What the photo shows, read by screen readers. |
| caption | string | โ | Written on the white strip. |
| duration | number | 9 | Seconds to develop on its own. |
| shake | number | 1 | How much each shake speeds it up. 0 turns shaking off. |
| tilt | number | -2 | Resting tilt, in degrees. |
| className | string | "w-72" | Set the width here. Everything inside scales with it. |