The whole section is the lit top of a picture editor's light box, with a brushed frame round the edge and the soft bars of the tubes showing through the acrylic. Six mounted colour slides lie on it, each a little crooked, each printed with a lab name, a red frame number, a date and a title. On top sits an eight times loupe: clear base, knurled black barrel, polished bezel and a glass that really enlarges what is beneath it, the film and the small print on the mount alike. Left alone, the loupe wanders from slide to slide like a hand that is choosing. Move a mouse over the table and it follows the pointer with a little lag, while the neck cord swings after it. The grid is three across on wide screens and two across on phones.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
/** The mounted transparencies lying on the table, in reading order. `rot`, `dx` and `dy` are how carelessly each was put down. */
const slides = [
{ title: "Lime float", no: "07", date: "JUL 14", src: "https://images.pexels.com/photos/756560/pexels-photo-756560.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "Woman in a red swimsuit and pink sunglasses lying on a lime green pool float", tone: "#12a79a", rot: -3.2, dx: 2, dy: -3 },
{ title: "Silk, surface", no: "11", date: "JUL 14", src: "https://images.pexels.com/photos/8539547/pexels-photo-8539547.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "Woman floating on her back in a pool with a printed dress spreading round her", tone: "#3f9fb5", rot: 2.1, dx: -3, dy: 4 },
{ title: "Lane rope", no: "16", date: "JUL 15", src: "https://images.pexels.com/photos/9635148/pexels-photo-9635148.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "Swimmer in a gauzy orange dress underwater beside a red lane rope", tone: "#1d6f8c", rot: -1.4, dx: 4, dy: 1 },
{ title: "Pink heels", no: "22", date: "JUL 15", src: "https://images.pexels.com/photos/1507351/pexels-photo-1507351.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "Legs in pink high heels stretched out above a bright blue pool", tone: "#49b3d6", rot: 3.6, dx: -2, dy: -2 },
{ title: "Violet, deep end", no: "29", date: "JUL 16", src: "https://images.pexels.com/photos/5978235/pexels-photo-5978235.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "Woman in a purple dress with arms spread wide, waist deep in a blue pool", tone: "#2a62c4", rot: -2.6, dx: 1, dy: 3 },
{ title: "Wet print", no: "34", date: "JUL 16", src: "https://images.pexels.com/photos/8539557/pexels-photo-8539557.jpeg?auto=compress&cs=tinysrgb&w=640", alt: "Woman with curly hair in a floral dress half under the water, looking at the camera", tone: "#7fa9b3", rot: 1.7, dx: -4, dy: -1 },
];
const N = slides.length;
// Design pixels; the table top is scaled to fit its box.
const CELL = 206; // grid pitch
const PAD = 26;
const MOUNT = 172; // a slide mount is square
const WIN_W = 128; // the film window
const WIN_H = 86;
const LENS = 152; // diameter of the glass
const BARREL = 192; // diameter of the black barrel
const SKIRT = 240; // diameter of the clear base
const ZOOM = 2.3;
const HOLD_MS = 2600;
const TOUR = [0, 1, 4, 5, 2, 3]; // the order the loupe wanders in when left alone
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
function layout(cols: number) {
const rows = Math.ceil(N / cols);
return {
w: cols * CELL + PAD * 2,
h: rows * CELL + PAD * 2,
at: (i: number) => ({ x: PAD + (i % cols) * CELL + CELL / 2 + slides[i].dx, y: PAD + Math.floor(i / cols) * CELL + CELL / 2 + slides[i].dy }),
};
}
/** One slide: plastic mount, film window and the lab's printing. `real` slides are buttons with a true image; the copy under the glass is decoration. */
function Mount({ i, x, y, real, onPick }: { i: number; x: number; y: number; real: boolean; onPick?: (i: number) => void }) {
const s = slides[i];
const body = (
<>
{/* moulded recess round the window */}
<span className="absolute rounded-[5px]" style={{ left: (MOUNT - WIN_W) / 2 - 7, top: (MOUNT - WIN_H) / 2 - 7, width: WIN_W + 14, height: WIN_H + 14, boxShadow: "inset 0 1px 1px rgba(0,0,0,0.16), inset 0 -1px 0 rgba(255,255,255,0.9)", background: "#dfe0dc" }} />
<span className="absolute overflow-hidden rounded-[2px]" style={{ left: (MOUNT - WIN_W) / 2, top: (MOUNT - WIN_H) / 2, width: WIN_W, height: WIN_H, background: s.tone }}>
{real ? (
<img src={s.src} alt={s.alt} width={WIN_W} height={WIN_H} loading="eager" decoding="async" draggable={false} className="h-full w-full object-cover" style={{ filter: "saturate(1.22) contrast(1.06) brightness(1.06)" }} />
) : (
<span className="block h-full w-full bg-cover bg-center" style={{ backgroundImage: `url("${s.src}")`, filter: "saturate(1.22) contrast(1.06) brightness(1.06)" }} />
)}
<span className="absolute inset-0" style={{ boxShadow: "inset 0 2px 3px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(0,0,0,0.35)" }} />
</span>
<span className="absolute left-[13px] top-[11px] font-mono text-[6.5px] font-semibold uppercase leading-[1.45] tracking-[0.2em] text-neutral-500">
Halden colour lab
<br />
<span className="font-normal">Transparency 24 x 36</span>
</span>
<span className="absolute right-[13px] top-[10px] font-mono text-[11px] font-bold tabular-nums leading-none text-[#c8321e]">{s.no}</span>
<span className="absolute bottom-[11px] left-[13px] font-mono text-[6.5px] uppercase leading-[1.45] tracking-[0.2em] text-neutral-600">
<span className="font-semibold text-neutral-800">{s.title}</span>
<br />
{s.date}
</span>
{/* the photographer's dot: bottom left means this way up */}
<span className="absolute bottom-[12px] right-[13px] h-[9px] w-[9px] rounded-full" style={{ background: "radial-gradient(circle at 35% 30%, #ff8a70, #d2341c 60%, #9c2010)" }} />
</>
);
const style = {
left: x - MOUNT / 2,
top: y - MOUNT / 2,
width: MOUNT,
height: MOUNT,
transform: `rotate(${s.rot}deg)`,
background: "linear-gradient(160deg, #f1f1ee 0%, #e4e5e1 55%, #d6d7d3 100%)",
boxShadow: "0 0 0 0.5px rgba(40,46,52,0.45), inset 0 1px 0 rgba(255,255,255,0.95), inset 0 -2px 2px rgba(0,0,0,0.08), 0 0 26px 7px rgba(255,255,255,0.95), 0 14px 22px -8px rgba(30,44,60,0.28)",
};
if (!real) return <span aria-hidden="true" className="absolute block rounded-[7px]" style={style}>{body}</span>;
return (
<button type="button" onClick={() => onPick?.(i)} onFocus={() => onPick?.(i)} aria-label={`Put the loupe on slide ${s.no}, ${s.title}`} className="absolute block cursor-pointer rounded-[7px] text-left outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-4 focus-visible:ring-offset-white" style={style}>
{body}
</button>
);
}
export default function LightTableLoupe() {
const reduce = useReducedMotion();
const [cols, setCols] = useState(3);
const [active, setActive] = useState(0);
const L = useMemo(() => layout(cols), [cols]);
const wrapRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
const loupeRef = useRef<HTMLDivElement>(null);
const underRef = useRef<HTMLDivElement>(null);
const cordRef = useRef<SVGPathElement>(null);
const cordShadowRef = useRef<SVGPathElement>(null);
const glareRef = useRef<HTMLDivElement>(null);
const sim = useRef({ x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, kx: 0, ky: 0, kvx: 0, kvy: 0, scale: 1, follow: false, drag: false, nextAuto: 0, tour: 0, active: 0, init: false });
const pick = useCallback(
(i: number) => {
const s = sim.current;
const p = L.at(i);
s.tx = p.x;
s.ty = p.y;
s.follow = false;
s.tour = Math.max(0, TOUR.indexOf(i));
s.nextAuto = performance.now() + HOLD_MS * 2.5;
},
[L],
);
useEffect(() => {
const wrap = wrapRef.current;
const scene = sceneRef.current;
const loupe = loupeRef.current;
const under = underRef.current;
if (!wrap || !scene || !loupe || !under) return;
const s = sim.current;
let raf = 0;
let visible = true;
let last = performance.now();
const t0 = last;
const measure = () => {
const w = wrap.clientWidth;
const want = w < 620 ? 2 : 3;
if (want !== cols) {
setCols(want);
return;
}
const gutter = w < 620 ? 22 : 44;
const sc = clamp((w - gutter * 2) / L.w, 0.5, 1.28);
s.scale = sc;
wrap.style.height = `${Math.round(L.h * sc + gutter * 2 + 40)}px`;
scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
scene.style.top = `${gutter}px`;
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrap);
const home = L.at(TOUR[s.tour]);
if (!s.init) {
s.init = true;
s.x = reduce ? home.x : home.x + 230;
s.y = reduce ? home.y : home.y + 150;
s.kx = s.x + 200;
s.ky = s.y + 200;
}
s.tx = home.x;
s.ty = home.y;
s.nextAuto = last + 2100;
const anchor = { x: L.w + 300, y: L.h * 0.62 };
const paint = () => {
loupe.style.transform = `translate3d(${(s.x - SKIRT / 2).toFixed(2)}px,${(s.y - SKIRT / 2).toFixed(2)}px,0)`;
under.style.transform = `translate3d(${(LENS / 2 - ZOOM * s.x).toFixed(2)}px,${(LENS / 2 - ZOOM * s.y).toFixed(2)}px,0) scale(${ZOOM})`;
// the neck cord leaves the barrel at the lower right and sags to the edge of the table
const lx = s.x + (BARREL / 2 - 4) * 0.74;
const ly = s.y + (BARREL / 2 - 4) * 0.67;
const d = `M${lx.toFixed(1)} ${ly.toFixed(1)} Q${s.kx.toFixed(1)} ${s.ky.toFixed(1)} ${anchor.x} ${anchor.y}`;
cordRef.current?.setAttribute("d", d);
cordShadowRef.current?.setAttribute("d", d);
if (glareRef.current) glareRef.current.style.transform = `translate3d(${(-s.vx * 0.012).toFixed(2)}px,${(-s.vy * 0.012).toFixed(2)}px,0)`;
let best = 0;
let bd = Infinity;
for (let i = 0; i < N; i++) {
const p = L.at(i);
const dd = (p.x - s.x) ** 2 + (p.y - s.y) ** 2;
if (dd < bd) {
bd = dd;
best = i;
}
}
if (best !== s.active) {
s.active = best;
setActive(best);
}
};
const tick = (now: number) => {
const dt = Math.min(0.033, Math.max(0.001, (now - last) / 1000));
last = now;
const t = (now - t0) / 1000;
if (!reduce && !s.follow && !s.drag && now > s.nextAuto) {
s.tour = (s.tour + 1) % N;
const p = L.at(TOUR[s.tour]);
s.tx = p.x;
s.ty = p.y;
s.nextAuto = now + HOLD_MS;
}
if (reduce) {
s.x = s.tx;
s.y = s.ty;
s.vx = s.vy = 0;
} else {
// a hand is never quite still
const hx = s.follow || s.drag ? 0 : Math.sin(t * 0.9) * 5 + Math.sin(t * 2.3) * 1.5;
const hy = s.follow || s.drag ? 0 : Math.cos(t * 0.7) * 4;
const k = s.follow || s.drag ? 210 : 62;
const c = s.follow || s.drag ? 26 : 13.5;
s.vx += (k * (s.tx + hx - s.x) - c * s.vx) * dt;
s.vy += (k * (s.ty + hy - s.y) - c * s.vy) * dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
}
// the cord's belly trails behind the loupe
const lx = s.x + BARREL * 0.36;
const ly = s.y + BARREL * 0.33;
const mx = (lx + anchor.x) / 2 - 30;
const my = (ly + anchor.y) / 2 + 120;
if (reduce) {
s.kx = mx;
s.ky = my;
} else {
s.kvx += (30 * (mx - s.kx) - 5.2 * s.kvx) * dt;
s.kvy += (30 * (my - s.ky) - 5.2 * s.kvy) * dt;
s.kx += s.kvx * dt;
s.ky += s.kvy * dt;
}
paint();
if (visible) raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(([e]) => {
const was = visible;
visible = e.isIntersecting;
if (visible && !was) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
});
io.observe(wrap);
raf = requestAnimationFrame(tick);
const toScene = (e: PointerEvent) => {
const r = scene.getBoundingClientRect();
return { x: clamp((e.clientX - r.left) / s.scale, 0, L.w), y: clamp((e.clientY - r.top) / s.scale, 0, L.h) };
};
const move = (e: PointerEvent) => {
if (e.pointerType === "mouse" || s.drag) {
const p = toScene(e);
s.follow = true;
s.tx = p.x;
s.ty = p.y;
}
};
const leave = () => {
if (s.drag) return;
s.follow = false;
const p = L.at(s.active);
s.tx = p.x;
s.ty = p.y;
s.tour = Math.max(0, TOUR.indexOf(s.active));
s.nextAuto = performance.now() + HOLD_MS;
};
const down = (e: PointerEvent) => {
if (e.pointerType === "mouse") return;
s.drag = true;
loupe.setPointerCapture(e.pointerId);
};
const up = () => {
if (!s.drag) return;
s.drag = false;
leave();
};
wrap.addEventListener("pointermove", move);
wrap.addEventListener("pointerleave", leave);
loupe.addEventListener("pointerdown", down);
loupe.addEventListener("pointerup", up);
loupe.addEventListener("pointercancel", up);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
wrap.removeEventListener("pointermove", move);
wrap.removeEventListener("pointerleave", leave);
loupe.removeEventListener("pointerdown", down);
loupe.removeEventListener("pointerup", up);
loupe.removeEventListener("pointercancel", up);
};
}, [reduce, cols, L]);
const stepBy = (dir: number) => {
const at = TOUR.indexOf(sim.current.active);
pick(TOUR[(at + dir + N) % N]);
};
const btn = "grid h-10 w-10 place-items-center rounded-full border border-neutral-900/15 bg-white/70 text-neutral-900 transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900";
const ring = (d: number) => ({ left: (SKIRT - d) / 2, top: (SKIRT - d) / 2, width: d, height: d });
return (
<section className="relative overflow-hidden bg-[#f1f4f5]">
{/* the lit acrylic: a hot centre, cooler corners and the faint bars of the tubes underneath */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(70% 78% at 50% 46%, #ffffff 0%, #f8fafb 46%, #e3e9ec 100%)" }} />
<div aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-70" style={{ background: "repeating-linear-gradient(180deg, rgba(255,255,255,0) 0px, rgba(255,255,255,0) 58px, rgba(255,255,255,0.9) 96px, rgba(255,255,255,0) 134px, rgba(255,255,255,0) 150px)" }} />
{/* brushed metal frame of the light box */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 z-20" style={{ border: "13px solid", borderImage: "linear-gradient(135deg, #eceef0 0%, #a4a9ae 22%, #dfe2e4 45%, #8b9096 70%, #d3d6d9 100%) 1", boxShadow: "inset 0 0 0 1px rgba(30,36,42,0.55), inset 0 3px 10px 2px rgba(50,62,74,0.28)" }} />
<div ref={wrapRef} className="relative mx-auto w-full max-w-[1100px]" style={{ height: 660 }}>
<div ref={sceneRef} className="absolute left-1/2 select-none" style={{ top: 44, width: L.w, height: L.h, transformOrigin: "50% 0", transform: "translateX(-50%)" }}>
{slides.map((_, i) => {
const p = L.at(i);
return <Mount key={i} i={i} x={p.x} y={p.y} real onPick={pick} />;
})}
{/* neck cord */}
<svg aria-hidden="true" className="pointer-events-none absolute left-0 top-0 overflow-visible" width={L.w} height={L.h}>
<path ref={cordShadowRef} fill="none" stroke="rgba(30,44,60,0.22)" strokeWidth="5" strokeLinecap="round" style={{ filter: "blur(3px)", transform: "translate(4px, 7px)" }} />
<path ref={cordRef} fill="none" stroke="#1a1a1d" strokeWidth="2.8" strokeLinecap="round" />
</svg>
{/* the loupe */}
<div ref={loupeRef} className="absolute left-0 top-0 touch-none" style={{ width: SKIRT, height: SKIRT, willChange: "transform", cursor: "grab" }}>
<div aria-hidden="true" className="absolute rounded-full bg-[#1e2c3c]" style={{ ...ring(BARREL + 6), transform: "translate(13px, 20px)", filter: "blur(16px)", opacity: 0.42 }} />
{/* clear acrylic skirt that stands the lens off the film */}
<div
aria-hidden="true"
className="absolute rounded-full"
style={{ ...ring(SKIRT), background: "radial-gradient(circle, rgba(255,255,255,0) 62%, rgba(255,255,255,0.35) 78%, rgba(190,204,214,0.28) 92%, rgba(120,136,150,0.35) 100%)", boxShadow: "inset 0 0 0 1px rgba(70,84,98,0.5), inset 0 0 0 3px rgba(255,255,255,0.75), inset 0 0 0 4px rgba(70,84,98,0.18), inset 0 -8px 14px -6px rgba(60,74,88,0.3), 0 2px 5px rgba(30,44,60,0.18)" }}
/>
{/* knurled black barrel */}
<div aria-hidden="true" className="absolute rounded-full" style={{ ...ring(BARREL), background: "repeating-conic-gradient(from 0deg, #0b0b0c 0deg, #0b0b0c 1.6deg, #2a2b2e 1.6deg, #2a2b2e 3deg)", boxShadow: "0 1px 0 rgba(255,255,255,0.25), 0 6px 10px rgba(10,16,24,0.35)" }} />
<div aria-hidden="true" className="absolute rounded-full" style={{ ...ring(BARREL), background: "conic-gradient(from 200deg, rgba(255,255,255,0) 0deg, rgba(255,255,255,0.34) 40deg, rgba(255,255,255,0) 86deg, rgba(0,0,0,0.35) 170deg, rgba(255,255,255,0.12) 250deg, rgba(255,255,255,0) 360deg)" }} />
<div aria-hidden="true" className="absolute rounded-full" style={{ ...ring(BARREL - 16), background: "radial-gradient(circle, #232427 0%, #18191b 72%, #0a0a0b 100%)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.2), 0 0 0 1px #000" }} />
{/* polished bezel */}
<div aria-hidden="true" className="absolute rounded-full" style={{ ...ring(LENS + 10), background: "conic-gradient(from 210deg, #f4f5f6, #8e9297 50deg, #2f3134 110deg, #b9bcc0 170deg, #ffffff 215deg, #70747a 270deg, #26282b 320deg, #f4f5f6 360deg)", boxShadow: "0 0 0 1px rgba(0,0,0,0.8)" }} />
{/* glass */}
<div className="absolute overflow-hidden rounded-full bg-white" style={ring(LENS)}>
<div ref={underRef} aria-hidden="true" className="absolute left-0 top-0" style={{ width: L.w, height: L.h, transformOrigin: "0 0", willChange: "transform" }}>
{slides.map((_, i) => {
const p = L.at(i);
return <Mount key={i} i={i} x={p.x} y={p.y} real={false} />;
})}
</div>
<div aria-hidden="true" className="absolute inset-0 rounded-full" style={{ boxShadow: "inset 0 0 0 1.5px rgba(0,0,0,0.75), inset 0 0 22px 4px rgba(8,14,22,0.42), inset 3px 4px 10px rgba(255,90,60,0.14), inset -3px -4px 10px rgba(60,140,255,0.16)" }} />
<div ref={glareRef} aria-hidden="true" className="absolute inset-0 rounded-full" style={{ background: "radial-gradient(42% 30% at 30% 20%, rgba(255,255,255,0.78) 0%, rgba(255,255,255,0.18) 55%, rgba(255,255,255,0) 100%), radial-gradient(30% 16% at 70% 86%, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0) 100%)" }} />
</div>
</div>
</div>
<p aria-live="polite" className="absolute bottom-6 left-7 z-30 font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-neutral-600 sm:bottom-8 sm:left-10">
<span className="tabular-nums text-neutral-950">Slide {slides[active].no}</span>
<span className="mx-2 text-neutral-400">/</span>
<span>{slides[active].date}</span>
<span className="block text-neutral-950">{slides[active].title}</span>
</p>
<div className="absolute bottom-5 right-6 z-30 flex gap-2 sm:bottom-7 sm:right-10">
<button type="button" onClick={() => stepBy(-1)} aria-label="Move the loupe to the previous slide" 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="M12 4l-6 6 6 6" />
</svg>
</button>
<button type="button" onClick={() => stepBy(1)} aria-label="Move the loupe to the next slide" 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="M8 4l6 6-6 6" />
</svg>
</button>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/light-table-slide-gallery-with-a-glass-loupe-that-glides-between-mounted-transparencies.json
Inside the round glass is a second, hidden copy of every slide on the table. That copy is scaled by `ZOOM` and shifted each frame so the point under the centre of the loupe stays in the centre of the glass. Because it is the same markup, the mount lettering is enlarged along with the photograph.
No. The slide on the table uses an `img` and the copy under the glass uses the same address as a CSS background, so the browser fetches each file once and reuses it.
The window on the table is only 128 pixels wide, but under the loupe that same picture is drawn at more than twice the size, and the whole table is scaled up again on wide screens. 640 keeps the enlarged view sharp on a dense display.
A single SVG curve from the barrel to a fixed point past the corner of the table. Its middle control point is on a loose spring, so when the loupe moves the belly of the cord arrives late and swings past before it rests.
Add three more entries and extend `TOUR` to list all nine indexes. The grid adds a third row by itself and the section grows to hold it. On phones that becomes five rows of two, so consider whether the page can spare the height.
Related: Photo Proof Roll Carousel That Feeds over a Roller and Curls onto the Table, Linen Archive Box Photo Gallery Whose Lid Opens into an Easel for Loose Prints, Magazine Stack Gallery That Fans out Across the Table and Lifts the Chosen Issue to Face You.
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.