Image Effects
ContactSheet
A darkroom print of a whole roll: black film strips with sprocket holes and frame numbers on white paper. Hover a frame for a glass loupe, press it to circle it in red grease pencil. Arrow keys move between frames.
Download
- React
- Tailwind CSS v4
- motion
components/ui/contact-sheet.tsx
"use client";
import { useId, useRef, useState, type FocusEvent, type KeyboardEvent, type PointerEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
export type ContactSheetFrame = {
src: string;
alt: string;
/** Crop inside the frame, as CSS object-position. Two crops of one picture read as a burst. */
position?: string;
};
type ContactSheetProps = {
frames: ContactSheetFrame[];
/** Frames on each film strip. */
perRow?: number;
/** Frames circled at the start, by index. */
picked?: number[];
/** How many times the loupe enlarges. */
zoom?: number;
/** Printed on the film edge. */
edge?: string;
/** Written on the paper margin. */
title?: string;
/** Called with the picked indexes after each press. */
onPick?: (picked: number[]) => void;
className?: string;
};
type Lens = { i: number; w: number; h: number; x: number; y: number; fx: number; fy: number };
const HOLES = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='14'%3E%3Crect x='6' y='2' width='12' height='10' rx='2.6' fill='%23ece7da'/%3E%3C/svg%3E")`;
const LOOP = "M13 44C6 20 34 6 58 7c26 1 39 17 34 37-4 16-34 23-58 19C16 60 6 48 15 29";
const HAND = { fontFamily: '"Segoe Print", "Bradley Hand", "Comic Sans MS", cursive' };
function Holes() {
return <span aria-hidden className="block h-[0.9em]" style={{ backgroundImage: HOLES, backgroundSize: "1.2em 100%" }} />;
}
/* ContactSheet: a darkroom print of a whole roll. Hover a frame for a loupe, press it to circle it in red grease pencil. */
export function ContactSheet({ frames, perRow = 4, picked = [], zoom = 2.6, edge = "SAFETY FILM 400", title = "Roll 12", onPick, className = "" }: ContactSheetProps) {
const reduce = useReducedMotion();
const id = useId();
const root = useRef<HTMLDivElement>(null);
const [marks, setMarks] = useState(picked);
const [cursor, setCursor] = useState(0);
const [lens, setLens] = useState<Lens | null>(null);
const [lensOn, setLensOn] = useState(false);
const rows = Array.from({ length: Math.ceil(frames.length / perRow) }, (_, r) => r * perRow);
function aim(i: number, el: HTMLElement, cx: number, cy: number) {
const box = root.current!.getBoundingClientRect();
const f = el.getBoundingClientRect();
const edge = box.width * 0.18; // Half the loupe, so all of it stays on the sheet.
const x = Math.min(box.width - edge, Math.max(edge, cx - box.left));
const y = Math.min(box.height - edge, Math.max(edge, cy - box.top));
setLens({ i, w: f.width, h: f.height, x, y, fx: (cx - f.left) / f.width, fy: (cy - f.top) / f.height });
setLensOn(true);
}
function hover(i: number, e: PointerEvent<HTMLButtonElement>) {
if (e.pointerType === "mouse") aim(i, e.currentTarget, e.clientX, e.clientY);
}
function focused(i: number, e: FocusEvent<HTMLButtonElement>) {
setCursor(i);
if (!e.currentTarget.matches(":focus-visible")) return;
const f = e.currentTarget.getBoundingClientRect();
aim(i, e.currentTarget, f.left + f.width / 2, f.top + f.height / 2);
}
function toggle(i: number) {
const next = marks.includes(i) ? marks.filter((m) => m !== i) : [...marks, i];
setMarks(next);
onPick?.(next);
}
function arrows(i: number, e: KeyboardEvent<HTMLButtonElement>) {
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: perRow, ArrowUp: -perRow }[e.key];
if (!step) return;
e.preventDefault();
const to = i + step;
if (to < 0 || to >= frames.length) return;
root.current!.querySelectorAll<HTMLButtonElement>("[data-frame]")[to]?.focus();
}
return (
<div className={`@container w-[32rem] ${className}`}>
<div ref={root} role="group" aria-label={title} onPointerLeave={() => setLensOn(false)} className="relative overflow-hidden bg-[#ece7da] p-[1em] text-[2.4cqw] leading-none shadow-[0_1em_2em_-0.8em_rgba(0,0,0,0.7)]">
<svg aria-hidden className="absolute size-0">
<filter id={id}>
<feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="2" seed="4" />
<feDisplacementMap in="SourceGraphic" scale="3" />
</filter>
</svg>
<div className="flex flex-col gap-[0.5em]">
{rows.map((start, r) => (
<div key={start} className="bg-[#100e0c] px-[0.5em]">
<Holes />
<div aria-hidden className="grid h-[0.8em] items-center text-[#d99a2b]" style={{ gridTemplateColumns: `repeat(${perRow}, 1fr)` }}>
{Array.from({ length: perRow }, (_, c) => (
<span key={c} className="truncate pl-[0.8em] font-mono text-[0.58em]">{start + c + 1}{r % 2 ? "A" : ""}</span>
))}
</div>
<div className="grid gap-[0.34em]" style={{ gridTemplateColumns: `repeat(${perRow}, 1fr)` }}>
{Array.from({ length: perRow }, (_, c) => {
const i = start + c;
const frame = frames[i];
if (!frame) return <span key={c} aria-hidden className="block aspect-[3/2] bg-[#060505]" />;
const on = marks.includes(i);
return (
<button
key={c}
type="button"
data-frame
tabIndex={i === cursor ? 0 : -1}
aria-pressed={on}
aria-label={`Frame ${i + 1}: ${frame.alt}`}
onPointerEnter={(e) => hover(i, e)}
onPointerMove={(e) => hover(i, e)}
onFocus={(e) => focused(i, e)}
onBlur={() => setLensOn(false)}
onKeyDown={(e) => arrows(i, e)}
onClick={() => toggle(i)}
className={`relative block aspect-[3/2] cursor-zoom-in outline-none focus-visible:ring-[0.14em] focus-visible:ring-[#ffcb6b] ${on ? "z-10" : ""}`}
>
<span className="absolute inset-0 overflow-hidden">
<img src={frame.src} alt="" draggable={false} className="size-full object-cover contrast-[1.08]" style={{ objectPosition: frame.position }} />
</span>
{on && (
<svg aria-hidden viewBox="0 0 100 70" preserveAspectRatio="none" className="pointer-events-none absolute -inset-[7%] size-[114%] overflow-visible" style={{ transform: `rotate(${(i % 3) * 3 - 3}deg) scaleX(${i % 2 ? -1 : 1})` }}>
<motion.path
d={LOOP}
fill="none"
stroke="#e0241b"
strokeWidth="3.4"
strokeLinecap="round"
filter={`url(#${id})`}
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.42, ease: "easeInOut" }}
/>
</svg>
)}
</button>
);
})}
</div>
<div aria-hidden className="flex h-[0.8em] items-center justify-between overflow-hidden whitespace-nowrap font-mono text-[#d99a2b]">
<span className="pl-[0.8em] text-[0.5em] tracking-[0.2em]">{edge}</span>
<span className="pr-[0.8em] text-[0.5em] tracking-[0.2em]">{edge}</span>
</div>
<Holes />
</div>
))}
</div>
<div className="mt-[0.7em] flex items-baseline justify-between text-[1.35em] text-[#2b2723]" style={HAND}>
<span>{title}</span>
<span aria-live="polite" className="text-[#d3261d]">{marks.length} picked</span>
</div>
{lens && (
<div
aria-hidden
className={`pointer-events-none absolute z-20 size-[14em] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full bg-[#0a0908] shadow-[0_0_0_0.32em_#23211e,0_0_0_0.42em_#77726a,0_0.9em_1.6em_rgba(0,0,0,0.65)] transition-[opacity,scale] duration-150 ${lensOn ? "scale-100 opacity-100" : "scale-75 opacity-0"}`}
style={{ left: lens.x, top: lens.y }}
>
<img
src={frames[lens.i].src}
alt=""
className="absolute top-1/2 left-1/2 max-w-none object-cover contrast-[1.08]"
style={{ width: lens.w * zoom, height: lens.h * zoom, objectPosition: frames[lens.i].position, transform: `translate(${-lens.fx * lens.w * zoom}px, ${-lens.fy * lens.h * zoom}px)` }}
/>
<span className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_34%_28%,rgba(255,255,255,0.3),transparent_34%),radial-gradient(circle,transparent_62%,rgba(0,0,0,0.5))]" />
</div>
)}
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/contact-sheet.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/contact-sheet-demo.tsx
"use client"; import { ContactSheet } from "@/components/ui/contact-sheet"; const DUSK = { src: "/demo/dusk.jpg", alt: "Hazy hills at dusk" }; const DAWN = { src: "/demo/dawn.jpg", alt: "Teal hills at dawn" }; const EMBER = { src: "/demo/ember.jpg", alt: "Red hills under a burning sky" }; const MOSS = { src: "/demo/moss.jpg", alt: "Green hills in soft light" }; const TIDE = { src: "/demo/tide.jpg", alt: "Blue hills under a cold moon" }; const FRAMES = [ { ...DUSK, position: "30% 50%" }, { ...DUSK, position: "70% 50%" }, { ...DAWN, position: "50% 30%" }, { ...DAWN, position: "50% 66%" }, { ...EMBER, position: "50% 20%" }, { ...EMBER, position: "50% 52%" }, { ...EMBER, position: "50% 80%" }, { ...MOSS, position: "50% 40%" }, { ...MOSS, position: "50% 74%" }, { ...TIDE, position: "50% 24%" }, { ...TIDE, position: "50% 58%" }, { ...TIDE, position: "50% 88%" }, ]; export default function ContactSheetExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#2a2622] p-2"> <ContactSheet frames={FRAMES} picked={[5, 10]} title="Nilgiris, roll 12" className="w-[min(112cqh,94cqw,560px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| frames | ContactSheetFrame[] | โ | Each has src, alt and an optional position, a CSS object-position. Two crops of one picture read as a burst. |
| perRow | number | 4 | Frames on each film strip. |
| picked | number[] | [] | Frames circled at the start, by index. |
| zoom | number | 2.6 | How many times the loupe enlarges. |
| edge | string | "SAFETY FILM 400" | Printed on the film edge. |
| title | string | "Roll 12" | Written on the paper margin and read by screen readers. |
| onPick | (picked: number[]) => void | โ | Called with the picked indexes after each press. |
| className | string | "w-[32rem]" | Set the width here. Everything scales with it. |