A cloth covered print box of the kind a photographer keeps finished work in, sitting on a dark table. It starts shut, showing a small plate set into a recess on the lid and a stamped title. The lid then swings up on its far edge, settles just past upright and becomes an easel. Each step takes the top print from the pile in the tray, slides its foot to the back of the box and tips it up to rest against the lid, slightly askew, on top of the ones before it. Stepping back lays the front print down again. After the fifth print the whole set drops back into the tray in a quick cascade. The box has board thickness on every wall, a lighter paper lining and a brass label holder on the front. It is narrow and tall, so it fits a phone screen almost at full size.
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
/** The loose prints in the box, top of the pile first. `tone` shows while a photograph loads. */
const prints = [
{ title: "Pool blue", src: "https://images.pexels.com/photos/35707515/pexels-photo-35707515.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Woman with an afro and dark glasses crouching in a pink dress against a bright blue backdrop", tone: "#1583d8", focus: "50% 30%" },
{ title: "Paper peony", src: "https://images.pexels.com/photos/9121152/pexels-photo-9121152.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Model in a white paper flower headpiece with a translucent red disc across the lower half of the frame", tone: "#c9cfca", focus: "50% 40%" },
{ title: "Blue on ochre", src: "https://images.pexels.com/photos/14793003/pexels-photo-14793003.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Seated woman in a blazer washed in blue light in front of an ochre backdrop", tone: "#c79a0c", focus: "50% 45%" },
{ title: "Moss and magenta", src: "https://images.pexels.com/photos/21352926/pexels-photo-21352926.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Woman in a moss green top with one arm behind her head against a magenta backdrop", tone: "#c40f43", focus: "50% 30%" },
{ title: "Silver crop", src: "https://images.pexels.com/photos/24972993/pexels-photo-24972993.jpeg?auto=compress&cs=tinysrgb&w=800", alt: "Black and white studio portrait of a seated woman with cropped silver hair resting her face on her hand", tone: "#5d5d5d", focus: "50% 24%" },
];
const cover = { title: "Colour studies", box: "Box 03", src: "https://images.pexels.com/photos/28262886/pexels-photo-28262886.jpeg?auto=compress&cs=tinysrgb&w=500", alt: "Model in round glasses under a pleated white paper hat", tone: "#d9d9d9" };
const N = prints.length;
// Design pixels in the box's own space: x across, y from the hinge toward you, z up off the table.
const W = 300;
const D = 390;
const T = 9; // board thickness
const WH = 44; // wall height
const PW = 250;
const PL = 330;
const LID_OPEN = 101; // degrees: just past upright, so the lid leans back
const LEAN = -80; // angle of a print resting on the lid
const SCENE_W = 460;
const SCENE_H = 700;
const HOLD_MS = 2500;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const rad = (d: number) => (d * Math.PI) / 180;
const jitter = [-1.3, 0.9, -0.5, 1.5, -0.8, 0.6];
const nudge = [-3, 4, -1, 3, -4, 2];
const CLOTH = "#cdc4b2";
const weave = "repeating-linear-gradient(0deg, rgba(60,48,30,0.10) 0 1px, rgba(0,0,0,0) 1px 3px), repeating-linear-gradient(90deg, rgba(255,255,255,0.14) 0 1px, rgba(0,0,0,0) 1px 3px)";
const LINING = "#efeae0";
/** A wall standing on the line y = `y`, facing you. */
function WallY({ y, x = 0, w, h, z = 0, bg, children }: { y: number; x?: number; w: number; h: number; z?: number; bg: React.CSSProperties; children?: React.ReactNode }) {
return (
<div className="absolute" style={{ left: x, top: y - h, width: w, height: h, transformOrigin: "50% 100%", transform: `translateZ(${z}px) rotateX(-90deg)`, ...bg }}>
{children}
</div>
);
}
/** A wall standing on the line x = `x`, facing right. */
function WallX({ x, y = 0, d, h, z = 0, bg }: { x: number; y?: number; d: number; h: number; z?: number; bg: React.CSSProperties }) {
return <div className="absolute" style={{ left: x - h, top: y, width: h, height: d, transformOrigin: "100% 50%", transform: `translateZ(${z}px) rotateY(90deg)`, ...bg }} />;
}
/**
* One loose print. `t` runs from 0 (lying in the tray) to 1 (leaning on the lid). The foot of the
* print skids toward the hinge exactly as fast as the top swings up, so the top edge climbs the lid
* and never passes through it. When the spring overshoots, the print rocks back off the lid.
*/
function Print({ i, up, top, delay, reduce }: { i: number; up: boolean; top: boolean; delay: number; reduce: boolean }) {
const p = prints[i];
const t = useSpring(0, { stiffness: 62, damping: 9.5, mass: 1 });
useEffect(() => {
const v = up ? 1 : 0;
if (reduce) {
t.jump(v);
return;
}
const id = window.setTimeout(() => t.set(v), up ? 0 : delay);
return () => window.clearTimeout(id);
}, [up, delay, reduce, t]);
const flatZ = 5 + (N - 1 - i) * 2.4;
const footEnd = 18 + i * 3;
const slack = D - (D - PL) / 2 - footEnd - PL * (1 - Math.cos(rad(LEAN)));
const transform = useTransform(t, (raw) => {
const v = raw > 1 ? 2 - raw : Math.abs(raw);
const a = LEAN * v;
const foot = footEnd + PL * (Math.cos(rad(a)) - Math.cos(rad(LEAN))) + slack * (1 - v);
const z = flatZ + (16 + i * 0.6 - flatZ) * v;
const twist = jitter[N - 1 - i] * 0.5 * (1 - v) + jitter[i] * v;
return `translate3d(${(nudge[i] * v).toFixed(2)}px, ${(foot - PL).toFixed(2)}px, ${z.toFixed(2)}px) rotateX(${a.toFixed(2)}deg) rotateZ(${twist.toFixed(2)}deg)`;
});
const gloss = useTransform(t, (v) => clamp(v, 0, 1));
const shade = useTransform(t, (v) => 0.2 * (1 - clamp(v, 0, 1)));
return (
<motion.div className="absolute overflow-hidden bg-[#f8f6f0]" style={{ left: (W - PW) / 2, top: 0, width: PW, height: PL, transformOrigin: "50% 100%", transform, boxShadow: "0 0 0 0.5px rgba(0,0,0,0.35)" }}>
<div className="absolute overflow-hidden" style={{ left: 13, right: 13, top: 13, bottom: 38, background: p.tone }}>
<img src={p.src} alt={top ? p.alt : ""} draggable={false} loading={i < 2 ? "eager" : "lazy"} className="h-full w-full object-cover" style={{ objectPosition: p.focus }} />
<div className="absolute inset-0" style={{ boxShadow: "inset 0 0 0 0.5px rgba(0,0,0,0.3)" }} />
</div>
<div className="absolute inset-x-[14px] bottom-[13px] flex items-baseline justify-between font-mono text-[7.5px] uppercase tracking-[0.18em] text-neutral-800">
<span className="font-semibold">{p.title}</span>
<span className="tabular-nums text-neutral-600">
{String(i + 1).padStart(2, "0")} / {String(N).padStart(2, "0")}
</span>
</div>
{/* glossy when it catches the light, dull when it lies in the box or behind another print */}
<motion.div aria-hidden="true" className="absolute inset-0" style={{ opacity: gloss, background: "linear-gradient(118deg, rgba(255,255,255,0.34) 0%, rgba(255,255,255,0.03) 26%, rgba(255,255,255,0) 60%, rgba(0,0,0,0.1) 100%)" }} />
<motion.div aria-hidden="true" className="absolute inset-0 bg-[#1a1712]" style={{ opacity: shade }} />
<div aria-hidden="true" className="absolute inset-0 bg-[#1a1712] transition-opacity duration-500" style={{ opacity: up && !top ? 0.12 : 0 }} />
</motion.div>
);
}
export default function ArchiveBoxGallery() {
const reduce = !!useReducedMotion();
const wrapRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [shown, setShown] = useState(0); // how many prints stand on the lid
const paused = useRef(false);
const timers = useRef<number[]>([]);
const live = useRef({ open: false, shown: 0 });
live.current = { open, shown };
const lid = useSpring(0, { stiffness: 70, damping: 13, mass: 1.2 });
const px = useMotionValue(0);
const py = useMotionValue(0);
const rz = useSpring(useTransform(px, (v) => -13 + v * 7), { stiffness: 60, damping: 14 });
const rx = useSpring(useTransform(py, (v) => 51 - v * 4), { stiffness: 60, damping: 14 });
const trayShade = useTransform(lid, (v) => 0.55 * (1 - clamp(v / 70, 0, 1)));
const later = (fn: () => void, ms: number) => {
timers.current.push(window.setTimeout(fn, ms));
};
useEffect(() => {
const v = open ? LID_OPEN : 0;
if (reduce) lid.jump(v);
else lid.set(v);
}, [open, reduce, lid]);
const next = useCallback(() => {
const s = live.current;
if (!s.open) {
setOpen(true);
later(() => setShown(1), reduce ? 0 : 650);
} else setShown(s.shown >= N ? 0 : s.shown + 1);
}, [reduce]);
const prev = useCallback(() => {
const s = live.current;
if (!s.open) return;
if (s.shown > 0) setShown(s.shown - 1);
else setOpen(false);
}, []);
const toggle = useCallback(() => {
const s = live.current;
if (!s.open) return next();
setShown(0);
later(() => setOpen(false), reduce || s.shown === 0 ? 0 : 520);
}, [next, reduce]);
useEffect(() => {
if (reduce) {
setOpen(true);
setShown(1);
return;
}
const first = window.setTimeout(next, 300);
const loop = window.setInterval(() => !paused.current && !document.hidden && next(), HOLD_MS);
return () => {
window.clearTimeout(first);
window.clearInterval(loop);
timers.current.forEach((t) => window.clearTimeout(t));
};
}, [next, reduce]);
useEffect(() => {
const wrap = wrapRef.current;
const scene = sceneRef.current;
if (!wrap || !scene) return;
const measure = () => {
const sc = clamp(wrap.clientWidth / (SCENE_W - 10), 0.6, 1);
wrap.style.height = `${Math.round(SCENE_H * sc + 78)}px`;
scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrap);
return () => ro.disconnect();
}, []);
const onMove = (e: React.PointerEvent) => {
if (reduce || e.pointerType === "touch") return;
const r = e.currentTarget.getBoundingClientRect();
px.set(((e.clientX - r.left) / r.width) * 2 - 1);
py.set(((e.clientY - r.top) / r.height) * 2 - 1);
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault();
next();
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault();
prev();
}
};
const cloth = { backgroundColor: CLOTH, backgroundImage: weave };
const clothDark = { backgroundColor: "#a39a88", backgroundImage: weave };
const clothSide = { backgroundColor: "#b9b09d", backgroundImage: weave };
const current = shown > 0 ? prints[shown - 1] : null;
const btn = "grid h-10 w-10 place-items-center rounded-full border border-white/15 bg-white/5 text-neutral-200 transition hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white";
return (
<section className="relative overflow-hidden bg-[#0e0e10]">
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(58% 60% at 50% 44%, #3a3a3e 0%, #232326 40%, #121214 74%, #0b0b0d 100%)" }} />
<div
ref={wrapRef}
role="group"
aria-roledescription="carousel"
aria-label="Archive box of loose photographic prints. Use the right arrow key to stand the next print on the lid and the left arrow key to put it back."
onKeyDown={onKey}
onPointerMove={onMove}
onPointerEnter={() => (paused.current = true)}
onPointerLeave={() => {
paused.current = false;
px.set(0);
py.set(0);
}}
onFocus={() => (paused.current = true)}
onBlur={() => (paused.current = false)}
className="relative mx-auto w-full overflow-hidden"
style={{ height: SCENE_H + 78 }}
>
<div ref={sceneRef} className="absolute left-1/2 top-0 select-none" style={{ width: SCENE_W, height: SCENE_H, transformOrigin: "50% 0", transform: "translateX(-50%)", perspective: "2400px", perspectiveOrigin: "50% 45%" }}>
<motion.div className="absolute" style={{ left: SCENE_W / 2 - W / 2, top: 404, width: W, height: D, transformStyle: "preserve-3d", transformOrigin: `${W / 2}px 30px`, rotateX: rx, rotateZ: rz, scale: 0.92 }}>
{/* shadows on the table */}
<div aria-hidden="true" className="absolute rounded-[14px] bg-black" style={{ left: -34, top: -30, width: W + 96, height: D + 76, filter: "blur(26px)", opacity: 0.7 }} />
<div aria-hidden="true" className="absolute bg-black" style={{ left: -2, top: -2, width: W + 8, height: D + 8, filter: "blur(4px)", opacity: 0.8 }} />
{/* tray: floor, outer walls, inner walls, and the top of the boards */}
<div className="absolute" style={{ left: T, top: T, width: W - T * 2, height: D - T * 2, transform: "translateZ(2px)", background: `linear-gradient(180deg, #a9a498 0%, #c9c4b8 30%, ${LINING} 100%)` }} />
<WallY y={0} w={W} h={WH} bg={clothDark} />
<WallY y={D} w={W} h={WH} bg={cloth}>
<div className="absolute inset-0" style={{ background: "linear-gradient(180deg, rgba(255,255,255,0.22) 0, rgba(255,255,255,0) 5px, rgba(0,0,0,0) 50%, rgba(0,0,0,0.3) 100%)" }} />
{/* brass label holder */}
<div className="absolute left-1/2 top-[9px] h-[26px] w-[118px] -translate-x-1/2 rounded-[2px] p-[3px]" style={{ background: "linear-gradient(180deg, #f1dc9a 0%, #b8923c 34%, #7d5e1e 62%, #d7b862 100%)", boxShadow: "0 1px 2px rgba(0,0,0,0.5), inset 0 0 0 0.5px rgba(60,40,0,0.6)" }}>
<div className="flex h-full items-center justify-between bg-[#f4efe2] px-[6px] font-mono text-[6.5px] font-semibold uppercase tracking-[0.14em] text-neutral-800" style={{ boxShadow: "inset 0 1px 2px rgba(0,0,0,0.4)" }}>
<span>{cover.title}</span>
<span>{cover.box.slice(-2)}</span>
</div>
{[4, 110].map((l) => (
<span key={l} className="absolute top-1/2 h-[3px] w-[3px] -translate-y-1/2 rounded-full" style={{ left: l - 3.5, background: "radial-gradient(circle at 35% 30%, #fff3c4, #7d5e1e)" }} />
))}
</div>
</WallY>
{/* the right outer wall always faces away from the camera, so it is left out */}
<WallX x={0} d={D} h={WH} bg={clothSide} />
<WallY y={T} x={T} w={W - T * 2} h={WH - 2} z={2} bg={{ background: `linear-gradient(180deg, ${LINING}, #b7b2a6)` }} />
<WallY y={D - T} x={T} w={W - T * 2} h={WH - 2} z={2} bg={{ background: "#8f8a7e" }} />
<WallX x={T} y={T} d={D - T * 2} h={WH - 2} z={2} bg={{ background: "linear-gradient(90deg, #d9d4c8, #a5a094)" }} />
<WallX x={W - T} y={T} d={D - T * 2} h={WH - 2} z={2} bg={{ background: "linear-gradient(90deg, #e3ded2, #b4afa3)" }} />
{[
{ left: 0, top: 0, width: W, height: T },
{ left: 0, top: D - T, width: W, height: T },
{ left: 0, top: T, width: T, height: D - T * 2 },
{ left: W - T, top: T, width: T, height: D - T * 2 },
].map((r, i) => (
<div key={i} className="absolute" style={{ ...r, transform: `translateZ(${WH}px)`, backgroundColor: "#ddd5c4", backgroundImage: weave, boxShadow: "inset 0 0 0 0.5px rgba(0,0,0,0.18)" }} />
))}
{/* the shut lid keeps the inside dark */}
<motion.div aria-hidden="true" className="pointer-events-none absolute bg-black" style={{ left: T, top: T, width: W - T * 2, height: D - T * 2, transform: "translateZ(22px)", opacity: trayShade }} />
{/* the prints */}
{prints.map((p, i) => (
<Print key={p.title} i={i} up={i < shown} top={i === shown - 1} delay={shown === 0 ? (N - 1 - i) * 70 : 0} reduce={reduce} />
))}
{/* the lid, hinged along the far wall */}
<motion.div className="absolute left-0 top-0" style={{ width: W, height: D, transformStyle: "preserve-3d", transformOrigin: "50% 0%", z: WH + 0.5, rotateX: lid }}>
{/* lining */}
<div className="absolute inset-0" style={{ transform: "translateZ(0.5px)", background: `linear-gradient(180deg, #d5d0c4 0%, ${LINING} 30%, #f6f2e9 100%)`, boxShadow: "inset 0 0 0 9px #d9d1c0, inset 0 0 0 9.5px rgba(0,0,0,0.2), inset 0 0 40px rgba(0,0,0,0.12)" }} />
{/* shadow the standing prints throw on the lining */}
<div aria-hidden="true" className="absolute bg-black transition-opacity duration-700" style={{ left: (W - PW) / 2 - 6, top: 10, width: PW + 12, height: PL - 44, transform: "translateZ(0.2px)", filter: "blur(8px)", opacity: shown > 0 ? 0.16 : 0 }} />
{/* board edges */}
<WallY y={D} w={W} h={T} bg={cloth} />
<WallY y={0} w={W} h={T} bg={clothDark} />
<WallX x={0} d={D} h={T} bg={clothSide} />
{/* cloth cover with a tipped-in plate */}
<button
type="button"
onClick={toggle}
aria-expanded={open}
aria-label={open ? `Close ${cover.box}, ${cover.title}` : `Open ${cover.box}, ${cover.title}`}
className="absolute inset-0 block cursor-pointer outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-white"
style={{ transform: `translateZ(${T}px)`, ...cloth }}
>
<span className="absolute left-1/2 top-[78px] block h-[178px] w-[142px] -translate-x-1/2" style={{ background: "#b5ac9a", boxShadow: "inset 0 2px 3px rgba(0,0,0,0.45), inset 0 -1px 0 rgba(255,255,255,0.5), 0 1px 0 rgba(255,255,255,0.35)" }}>
<span className="absolute inset-[5px] block overflow-hidden" style={{ background: cover.tone }}>
<img src={cover.src} alt={cover.alt} draggable={false} className="h-full w-full object-cover" style={{ objectPosition: "50% 30%" }} />
</span>
</span>
<span className="absolute inset-x-0 top-[280px] block text-center text-[15px] font-semibold uppercase tracking-[0.34em] text-[#4b4436]" style={{ textShadow: "0 1px 0 rgba(255,255,255,0.45), 0 -1px 0 rgba(0,0,0,0.25)" }}>
{cover.title}
</span>
<span className="absolute inset-x-0 top-[306px] block text-center font-mono text-[8px] uppercase tracking-[0.3em] text-[#5c5444]" style={{ textShadow: "0 1px 0 rgba(255,255,255,0.4)" }}>
{cover.box} / {N} prints
</span>
<span aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(140deg, rgba(255,255,255,0.26) 0%, rgba(255,255,255,0) 40%, rgba(0,0,0,0.14) 100%)", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 0 18px rgba(0,0,0,0.12)" }} />
</button>
</motion.div>
</motion.div>
</div>
<p aria-live="polite" className="absolute bottom-5 left-5 font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-neutral-300 sm:bottom-8 sm:left-8">
<span className="tabular-nums text-white">{String(shown).padStart(2, "0")}</span>
<span className="mx-2 text-neutral-500">/</span>
<span className="tabular-nums">{String(N).padStart(2, "0")}</span>
<span className="block text-neutral-400">{current ? current.title : open ? "All prints in the box" : `${cover.box} is shut`}</span>
</p>
<div className="absolute bottom-4 right-4 flex gap-2 sm:bottom-7 sm:right-8">
<button type="button" onClick={prev} aria-label={shown > 0 ? "Put the front print back in the box" : "Close the lid"} className={btn}>
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 8l6 6 6-6" />
</svg>
</button>
<button type="button" onClick={next} aria-label={!open ? "Open the box" : shown >= N ? "Put every print back in the box" : "Stand the next print on the lid"} className={btn}>
<svg aria-hidden="true" width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12l6-6 6 6" />
</svg>
</button>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/linen-archive-box-photo-gallery-whose-lid-opens-into-an-easel-for-loose-prints.json
Each print follows one spring value that runs from 0 in the tray to 1 on the lid. The angle is that value times minus 80 degrees about the bottom edge, and the position of that edge is worked out from the angle so the top of the sheet climbs the lid while the foot skids back along the pile. The two can never disagree, so paper never passes through board. When the spring overshoots, the value is reflected, which makes the print rock forward off the lid and settle.
Each one gets a small fixed twist from the `jitter` list and a sideways shift from `nudge`. Loose paper never stacks square, and the slivers of earlier prints showing at the corners tell you how many are up there. Set both lists to zeros for a tidy pile.
No. It is two repeating one pixel line gradients at right angles over a flat colour, one slightly dark and one slightly light, which reads as a woven cloth at this size. The same `weave` string is reused on every outside surface and on the tops of the boards.
Each wall is two upright planes nine pixels apart, cloth outside and lining inside, with a flat strip across the top joining them. Two small helpers, `WallY` and `WallX`, stand a plane up on a line across or along the box, so adding a divider is one more call. The right outer wall is left out because this camera never sees it.
Yes, if you reshape the box. Swap the values of `W` and `D` and of `PW` and `PL`, then widen `SCENE_W` to suit. The hinge stays on the far edge, so a landscape box opens like a laptop and the prints lean the same way.
Related: Photo Proof Roll Carousel That Feeds over a Roller and Curls onto the Table, Vinyl Record Sleeve Gallery Where the Disc Slides out and Spins Beside Its Cover, Matchbox Card with a Drawer That Slides out and a Photo Print That Stands up from the Matches.
More galleries and carousels or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.