"use client";
import * as React from "react";
export interface ZoomImageProps extends Omit<React.ComponentPropsWithoutRef<"figure">, "children"> {
src: string;
/** Required alternative text (used for the thumbnail, the zoomed image and the dialog name). */
alt: string;
/** Higher-resolution source for the zoomed view. Defaults to `src`. */
zoomSrc?: string;
/** Intrinsic size; reserves layout space and is the fallback when the natural size is unknown. */
width?: number;
height?: number;
/** Caption shown in the zoomed view (and under the thumbnail when `showCaption`). */
caption?: string;
showCaption?: boolean;
/** Crop the thumbnail to an aspect ratio ("4/3", "16/9"…); "auto" keeps the natural ratio. */
aspectRatio?: string;
/** Space kept around the zoomed image, in px. */
margin?: number;
/** Zoom transition length in ms. */
duration?: number;
/** Thumbnail corner radius in px. */
rounded?: number;
/** Backdrop tone: the page tone (light / dark aware) or always dark. */
backdrop?: "theme" | "dark";
/** Accessible name of the close button. */
closeLabel?: string;
onZoomChange?: (zoomed: boolean) => void;
}
const EASE = "cubic-bezier(0.2, 0.9, 0.1, 1)";
const ZOOMED_RADIUS = 6;
const CAPTION_GAP = 14;
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* Click an image to zoom it from its exact on-page position to fit the viewport. The zoomed copy
* lives in a native modal <dialog>; a FLIP transform plus an animated clip-path makes it grow out
* of the (possibly cropped) thumbnail and shrink back into it. Closes on click, Escape or scroll.
*/
export function ZoomImage({
src,
alt,
zoomSrc,
width,
height,
caption,
showCaption = true,
aspectRatio = "auto",
margin = 40,
duration = 360,
rounded = 12,
backdrop = "theme",
closeLabel = "Close",
onZoomChange,
className = "",
style,
...props
}: ZoomImageProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const captionId = `${uid}-caption`;
const [zoomed, setZoomed] = React.useState(false);
const thumbRef = React.useRef<HTMLImageElement>(null);
const dialogRef = React.useRef<HTMLDialogElement>(null);
const zoomRef = React.useRef<HTMLImageElement>(null);
const backdropRef = React.useRef<HTMLDivElement>(null);
const chromeRef = React.useRef<HTMLDivElement>(null);
const captionRef = React.useRef<HTMLParagraphElement>(null);
const phase = React.useRef<"closed" | "opening" | "open" | "closing">("closed");
const anims = React.useRef<Animation[]>([]);
/** Sizes and centres the zoomed image (and caption) for the current viewport. */
const layout = () => {
const thumb = thumbRef.current;
const img = zoomRef.current;
if (!thumb || !img) return;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const rect = thumb.getBoundingClientRect();
const nw = thumb.naturalWidth || width || rect.width || 1;
const nh = thumb.naturalHeight || height || rect.height || 1;
const capEl = captionRef.current;
const capH = capEl ? capEl.offsetHeight + CAPTION_GAP : 0;
const m = Math.max(0, Math.min(margin, vw / 4, vh / 4));
const scale = Math.max(0.01, Math.min((vw - m * 2) / nw, (vh - m * 2 - capH) / nh));
const w = nw * scale;
const h = nh * scale;
const left = (vw - w) / 2;
const top = (vh - h - capH) / 2;
Object.assign(img.style, { width: `${w}px`, height: `${h}px`, left: `${left}px`, top: `${top}px` });
if (capEl) capEl.style.top = `${top + h + CAPTION_GAP}px`;
};
/** Keyframes that place the zoomed image exactly over the thumbnail (cropped like it). */
const thumbFrame = (): Keyframe => {
const thumb = thumbRef.current!.getBoundingClientRect();
const img = zoomRef.current!;
const fw = img.offsetWidth || 1;
const fh = img.offsetHeight || 1;
const fx = img.offsetLeft + fw / 2;
const fy = img.offsetTop + fh / 2;
// Cover-fit scale: the zoomed image covers the thumbnail box, the overflow is clipped away.
const s = Math.max(thumb.width / fw, thumb.height / fh);
const ix = Math.max(0, (fw - thumb.width / s) / 2);
const iy = Math.max(0, (fh - thumb.height / s) / 2);
const dx = thumb.left + thumb.width / 2 - fx;
const dy = thumb.top + thumb.height / 2 - fy;
return {
transform: `translate(${dx}px, ${dy}px) scale(${s})`,
clipPath: `inset(${iy}px ${ix}px round ${rounded / s}px)`,
};
};
const restFrame: Keyframe = { transform: "translate(0px, 0px) scale(1)", clipPath: `inset(0px 0px round ${ZOOMED_RADIUS}px)` };
const finishClose = () => {
anims.current.forEach((a) => a.cancel());
anims.current = [];
phase.current = "closed";
// Reveal the thumbnail in the same frame the copy disappears, so nothing flickers.
if (thumbRef.current) thumbRef.current.style.visibility = "";
if (dialogRef.current?.open) dialogRef.current.close(); // returns focus to the trigger
setZoomed(false);
onZoomChange?.(false);
};
const open = () => {
const dialog = dialogRef.current;
const thumb = thumbRef.current;
const img = zoomRef.current;
if (!dialog || !thumb || !img || phase.current !== "closed") return;
phase.current = "opening";
setZoomed(true);
onZoomChange?.(true);
dialog.showModal();
layout();
thumb.style.visibility = "hidden";
if (reduceMotion || duration <= 0 || typeof img.animate !== "function") {
phase.current = "open";
return;
}
const timing = { duration, easing: EASE, fill: "both" as const };
const zoom = img.animate([thumbFrame(), restFrame], timing);
anims.current = [
zoom,
backdropRef.current!.animate([{ opacity: 0 }, { opacity: 1 }], timing),
chromeRef.current!.animate([{ opacity: 0 }, { opacity: 1 }], { ...timing, delay: duration * 0.5, duration: duration * 0.6 }),
];
zoom.onfinish = () => {
if (phase.current === "opening") phase.current = "open";
};
};
const close = () => {
const img = zoomRef.current;
if (!img || phase.current === "closed" || phase.current === "closing") return;
const wasOpening = phase.current === "opening";
phase.current = "closing";
if (reduceMotion || duration <= 0 || typeof img.animate !== "function") {
finishClose();
return;
}
if (wasOpening && anims.current.length) {
// Interrupted mid-zoom: play the same animations backwards from where they are.
anims.current.forEach((a) => a.reverse());
anims.current[0].onfinish = finishClose;
return;
}
anims.current.forEach((a) => a.cancel());
const timing = { duration: duration * 0.85, easing: EASE, fill: "both" as const };
const zoom = img.animate([restFrame, thumbFrame()], timing);
anims.current = [
zoom,
backdropRef.current!.animate([{ opacity: 1 }, { opacity: 0 }], timing),
chromeRef.current!.animate([{ opacity: 1 }, { opacity: 0 }], { ...timing, duration: duration * 0.3 }),
];
zoom.onfinish = finishClose;
};
// Latest handlers for the window listeners below (assigned after commit, never during render).
const closeRef = React.useRef(close);
const layoutRef = React.useRef(layout);
React.useEffect(() => {
closeRef.current = close;
layoutRef.current = layout;
});
// While zoomed: scrolling the page closes, resizing re-fits.
React.useEffect(() => {
if (!zoomed) return;
const startY = window.scrollY;
const onScroll = () => {
if (Math.abs(window.scrollY - startY) > 8) closeRef.current();
};
const onResize = () => {
if (phase.current === "open") layoutRef.current();
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
};
}, [zoomed]);
// Never leave the page inert if the component unmounts while zoomed.
React.useEffect(
() => () => {
anims.current.forEach((a) => a.cancel());
if (dialogRef.current?.open) dialogRef.current.close();
},
[],
);
const thumbStyle: React.CSSProperties = {
borderRadius: rounded,
aspectRatio: aspectRatio !== "auto" ? aspectRatio.replace("/", " / ") : width && height ? `${width} / ${height}` : undefined,
};
return (
<figure className={`m-0 ${className}`} style={style} {...props}>
<button
type="button"
aria-haspopup="dialog"
aria-expanded={zoomed}
onClick={open}
className="group relative block w-full cursor-zoom-in overflow-hidden text-left outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-zinc-900 dark:focus-visible:outline-zinc-100"
style={{ borderRadius: rounded }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}
<img
ref={thumbRef}
src={src}
alt={alt}
width={width}
height={height}
draggable={false}
className="block h-auto w-full object-cover"
style={thumbStyle}
/>
<span
aria-hidden="true"
className="pointer-events-none absolute right-2.5 top-2.5 grid size-7 place-items-center rounded-full bg-zinc-950/55 text-white opacity-0 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 [@media(hover:none)]:opacity-100"
>
<svg viewBox="0 0 16 16" fill="none" className="size-3.5">
<path d="M9.5 2.5h4v4M6.5 13.5h-4v-4M13.5 2.5 9 7M2.5 13.5 7 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="sr-only">, enlarge image</span>
</button>
{caption && showCaption ? (
<figcaption className="mt-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{caption}</figcaption>
) : null}
<dialog
ref={dialogRef}
aria-label={caption ? undefined : alt}
aria-labelledby={caption ? captionId : undefined}
onCancel={(event) => {
// Escape: animate out instead of letting the browser close the dialog abruptly.
event.preventDefault();
close();
}}
onClose={() => {
// The browser can force-close (e.g. a second Escape); keep our state in step.
if (phase.current !== "closed") finishClose();
}}
onClick={close}
onWheel={close}
onTouchMove={close}
className="fixed inset-0 m-0 h-full max-h-none w-full max-w-none cursor-zoom-out overflow-hidden border-0 bg-transparent p-0 outline-none backdrop:bg-transparent"
>
<div
ref={backdropRef}
aria-hidden="true"
className={`absolute inset-0 ${backdrop === "dark" ? "bg-black/85" : "bg-[#F5F3EE]/92 dark:bg-[#0b0d0f]/92"}`}
/>
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}
<img
ref={zoomRef}
src={zoomSrc ?? src}
alt={alt}
draggable={false}
className="absolute max-w-none origin-center select-none shadow-[0_30px_80px_-30px_rgb(0_0_0/0.45)] will-change-transform"
style={{ clipPath: `inset(0px 0px round ${ZOOMED_RADIUS}px)` }}
/>
<div ref={chromeRef}>
{caption ? (
<p
ref={captionRef}
id={captionId}
className={`absolute left-1/2 w-max max-w-[min(92vw,640px)] -translate-x-1/2 px-4 text-center text-sm leading-snug tracking-tight ${backdrop === "dark" ? "text-zinc-300" : "text-zinc-700 dark:text-zinc-300"}`}
>
{caption}
</p>
) : null}
<button
type="button"
aria-label={closeLabel}
className={`absolute right-4 top-4 inline-flex h-8 items-center gap-2 rounded-full border px-3 font-mono text-[10px] uppercase tracking-[0.14em] outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 ${backdrop === "dark" ? "border-white/20 text-zinc-200 hover:bg-white/10 focus-visible:outline-white" : "border-zinc-900/15 text-zinc-700 hover:bg-zinc-900/5 focus-visible:outline-zinc-900 dark:border-white/20 dark:text-zinc-200 dark:hover:bg-white/10 dark:focus-visible:outline-white"}`}
>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Esc
</button>
</div>
</dialog>
</figure>
);
}