"use client";
import * as React from "react";
export interface LightboxImage {
src: string;
/** Required alternative text. */
alt: string;
caption?: string;
/** Intrinsic size (used to fit the image before it loads). */
width?: number;
height?: number;
/** Smaller source for the grid and the thumbnail strip. Defaults to `src`. */
thumbSrc?: string;
}
export interface LightboxGalleryProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
images: LightboxImage[];
/** Grid columns on wide containers (two on narrow ones). */
columns?: number;
/** Grid gap in px. */
gap?: number;
/** Thumbnail crop, e.g. "1/1", "4/3", "3/4". */
aspectRatio?: string;
/** Thumbnail corner radius in px. */
rounded?: number;
/** Show the thumbnail strip inside the lightbox. */
thumbnails?: boolean;
/** Wrap from the last image to the first. */
loop?: boolean;
/** Largest zoom factor. */
maxZoom?: number;
/** Open / close animation length in ms. */
duration?: number;
/** Lightbox backdrop: always dark, or following the page theme. */
backdrop?: "dark" | "theme";
/** Accessible name of the gallery. */
label?: string;
onIndexChange?: (index: number, open: boolean) => void;
accent?: string;
}
interface Zoom {
s: number;
x: number;
y: number;
}
interface Rect {
left: number;
top: number;
width: number;
height: number;
}
const EASE = "cubic-bezier(0.2, 0.9, 0.1, 1)";
const TOP = 60;
const CAPTION = 48;
const STRIP = 84;
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,
);
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
/** Current translate of an element, read from its computed transform. */
function translateOf(el: Element) {
const t = getComputedStyle(el).transform;
if (!t || t === "none") return { x: 0, y: 0 };
const m = new DOMMatrixReadOnly(t);
return { x: m.m41, y: m.m42 };
}
const r2 = (n: number) => Math.round(n * 100) / 100;
/** Fits an image into the viewport area that the chrome leaves free. */
function fit(vp: { w: number; h: number }, dim: { w: number; h: number }, strip: boolean): Rect {
const side = vp.w < 640 ? 12 : 76;
const bottom = CAPTION + (strip ? STRIP : 12);
const aw = Math.max(40, vp.w - side * 2);
const ah = Math.max(40, vp.h - TOP - bottom);
const s = Math.min(aw / dim.w, ah / dim.h);
const width = dim.w * s;
const height = dim.h * s;
return { left: r2((vp.w - width) / 2), top: r2(TOP + (ah - height) / 2), width: r2(width), height: r2(height) };
}
/**
* A thumbnail grid that opens a full-screen lightbox in a native <dialog>. The image grows out of
* its thumbnail (FLIP plus a clip-path that undoes the crop) and shrinks back into the current one
* on close. Inside: swipe or arrow keys between images with neighbours preloaded, pinch / double-click
* / wheel zoom with panning, a caption, a "3 / 12" counter and a thumbnail strip. Reduced motion fades.
*/
export function LightboxGallery({
images,
columns = 4,
gap = 8,
aspectRatio = "1/1",
rounded = 10,
thumbnails = true,
loop = true,
maxZoom = 3,
duration = 380,
backdrop = "dark",
label = "Gallery",
onIndexChange,
accent = "#FF7448",
className = "",
style,
...props
}: LightboxGalleryProps) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const count = images.length;
const [open, setOpen] = React.useState(false);
const [index, setIndex] = React.useState(0);
const [vp, setVp] = React.useState({ w: 1280, h: 800 });
const [loaded, setLoaded] = React.useState<Record<number, { w: number; h: number }>>({});
const [zoomed, setZoomed] = React.useState(false);
const dialogRef = React.useRef<HTMLDialogElement>(null);
const backdropRef = React.useRef<HTMLDivElement>(null);
const chromeRef = React.useRef<HTMLDivElement>(null);
const slideRef = React.useRef<HTMLDivElement>(null);
const stripRef = React.useRef<HTMLDivElement>(null);
const closeRef = React.useRef<HTMLButtonElement>(null);
const frames = React.useRef(new Map<number, HTMLDivElement>());
const zoomEls = React.useRef(new Map<number, HTMLDivElement>());
const thumbs = React.useRef(new Map<number, HTMLButtonElement>());
const phase = React.useRef<"closed" | "opening" | "open" | "closing">("closed");
const busy = React.useRef(false);
const anims = React.useRef<Animation[]>([]);
const zoom = React.useRef<Zoom>({ s: 1, x: 0, y: 0 });
const pointers = React.useRef(new Map<number, { x: number; y: number }>());
const gesture = React.useRef<{
kind: "none" | "pending" | "pan" | "swipe" | "dismiss" | "pinch";
x0: number;
y0: number;
t0: number;
zx: number;
zy: number;
dist: number;
scale: number;
}>({ kind: "none", x0: 0, y0: 0, t0: 0, zx: 0, zy: 0, dist: 1, scale: 1 });
const lastTap = React.useRef({ t: 0, x: 0, y: 0 });
// A navigation requested while the track is still sliding runs right after it lands.
const queued = React.useRef<{ to: number; dir: 1 | -1 } | { step: 1 | -1 } | null>(null);
const dimOf = (i: number) => {
const img = images[i];
return loaded[i] ?? { w: img?.width ?? 4, h: img?.height ?? 3 };
};
const wrap = (i: number) => (loop ? (i + count) % count : i);
const canGo = (dir: 1 | -1) => count > 1 && (loop || (index + dir >= 0 && index + dir < count));
/* ------------------------------- zoom ------------------------------- */
const applyZoom = (animateFrom?: Zoom) => {
const el = zoomEls.current.get(index);
if (!el) return;
const z = zoom.current;
const to = `translate(${r2(z.x)}px, ${r2(z.y)}px) scale(${r2(z.s)})`;
el.style.transform = to;
if (animateFrom && !reduceMotion && typeof el.animate === "function") {
el.animate([{ transform: `translate(${animateFrom.x}px, ${animateFrom.y}px) scale(${animateFrom.s})` }, { transform: to }], { duration: 260, easing: EASE });
}
};
/** Keeps the zoomed image covering its own box. */
const clampZoom = (z: Zoom): Zoom => {
const frame = frames.current.get(index);
const w = frame?.offsetWidth ?? 0;
const h = frame?.offsetHeight ?? 0;
return { s: z.s, x: clamp(z.x, w - w * z.s, 0), y: clamp(z.y, h - h * z.s, 0) };
};
/** Zooms to `s` keeping the viewport point (cx, cy) fixed under the pointer. */
const zoomAt = (s: number, cx: number, cy: number, animate: boolean) => {
const frame = frames.current.get(index);
if (!frame) return;
const rect = frame.getBoundingClientRect();
const from = { ...zoom.current };
const next = clamp(s, 1, maxZoom);
const px = cx - rect.left;
const py = cy - rect.top;
const k = next / from.s;
zoom.current = clampZoom({ s: next, x: px - (px - from.x) * k, y: py - (py - from.y) * k });
applyZoom(animate ? from : undefined);
const isZoomed = zoom.current.s > 1.01;
if (isZoomed !== zoomed) setZoomed(isZoomed);
};
const resetZoom = (animate: boolean) => {
const from = { ...zoom.current };
zoom.current = { s: 1, x: 0, y: 0 };
applyZoom(animate && from.s > 1 ? from : undefined);
if (zoomed) setZoomed(false);
};
const toggleZoomAt = (cx: number, cy: number) => {
if (zoom.current.s > 1.01) resetZoom(true);
else zoomAt(Math.min(2.5, maxZoom), cx, cy, true);
};
/* ----------------------------- navigation ----------------------------- */
const goTo = (next: number, dir: 1 | -1) => {
if (busy.current) {
queued.current = { to: next, dir };
return;
}
if (next === index || next < 0 || next >= count) return;
const slide = slideRef.current;
resetZoom(false);
if (!slide || reduceMotion || typeof slide.animate !== "function") {
setIndex(next);
onIndexChange?.(next, true);
return;
}
busy.current = true;
const current = translateOf(slide).x;
const target = -dir * vp.w;
slide.style.transform = `translateX(${target}px)`;
const a = slide.animate([{ transform: `translateX(${current}px)` }, { transform: `translateX(${target}px)` }], { duration: 300, easing: EASE });
a.onfinish = () => {
busy.current = false;
setIndex(next);
onIndexChange?.(next, true);
};
};
const step = (dir: 1 | -1) => {
if (busy.current) {
queued.current = { step: dir };
return;
}
if (!canGo(dir)) return;
goTo(wrap(index + dir), dir);
};
const runQueued = React.useRef(() => {});
React.useLayoutEffect(() => {
runQueued.current = () => {
const q = queued.current;
queued.current = null;
if (!q || phase.current !== "open") return;
if ("step" in q) step(q.step);
else goTo(q.to, q.dir);
};
});
// After an index change the neighbours re-slot around the new image: reset the slide in the same frame.
React.useLayoutEffect(() => {
const slide = slideRef.current;
if (!slide) return;
slide.style.transform = "translateX(0px)";
zoom.current = { s: 1, x: 0, y: 0 };
const el = zoomEls.current.get(index);
if (el) el.style.transform = "";
if (reduceMotion && phase.current === "open") frames.current.get(index)?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 160 });
if (queued.current) requestAnimationFrame(() => runQueued.current());
}, [index, reduceMotion]);
// Keep the current thumbnail in the strip centred (scrollLeft, never scrollIntoView).
React.useEffect(() => {
const strip = stripRef.current;
if (!open || !strip) return;
const btn = strip.querySelector<HTMLElement>(`[data-strip="${index}"]`);
if (!btn) return;
strip.scrollTo({ left: btn.offsetLeft - strip.clientWidth / 2 + btn.offsetWidth / 2, behavior: reduceMotion ? "auto" : "smooth" });
}, [index, open, reduceMotion]);
// Preload the images two steps either side (the direct neighbours are already rendered).
React.useEffect(() => {
if (!open) return;
for (const d of [-2, 2]) {
const i = loop ? (index + d + count) % count : index + d;
if (i >= 0 && i < count) {
const img = new Image();
img.src = images[i].src;
}
}
}, [open, index, images, count, loop]);
/* ---------------------------- open / close ---------------------------- */
const thumbImg = (i: number) => thumbs.current.get(i)?.querySelector("img") ?? null;
/** Keyframe that lays the full-size frame exactly over thumbnail `i`, cropped like it. */
const thumbFrame = (i: number): Keyframe | null => {
const frame = frames.current.get(i);
const thumb = thumbImg(i);
if (!frame || !thumb) return null;
const t = thumb.getBoundingClientRect();
if (t.bottom < 0 || t.top > window.innerHeight || t.width === 0) return null;
const fw = frame.offsetWidth || 1;
const fh = frame.offsetHeight || 1;
const s = Math.max(t.width / fw, t.height / fh);
const ix = Math.max(0, (fw - t.width / s) / 2);
const iy = Math.max(0, (fh - t.height / s) / 2);
const dx = t.left + t.width / 2 - (frame.offsetLeft + fw / 2);
const dy = t.top + t.height / 2 - (frame.offsetTop + fh / 2);
return { transform: `translate(${r2(dx)}px, ${r2(dy)}px) scale(${r2(s * 10000) / 10000})`, clipPath: `inset(${r2(iy)}px ${r2(ix)}px round ${r2(rounded / s)}px)` };
};
const restFrame: Keyframe = { transform: "translate(0px, 0px) scale(1)", clipPath: "inset(0px 0px round 4px)" };
const openAt = (i: number) => {
if (phase.current !== "closed") return;
phase.current = "opening";
setVp({ w: document.documentElement.clientWidth, h: window.innerHeight });
setIndex(i);
setOpen(true);
onIndexChange?.(i, true);
};
React.useLayoutEffect(() => {
const dialog = dialogRef.current;
if (!open || !dialog || dialog.open) return;
dialog.showModal();
closeRef.current?.focus({ preventScroll: true });
const frame = frames.current.get(index);
const thumb = thumbImg(index);
const from = reduceMotion ? null : thumbFrame(index);
const timing = { duration, easing: EASE };
anims.current.forEach((a) => a.cancel());
anims.current = [];
if (frame && from) {
if (thumb) thumb.style.visibility = "hidden";
const a = frame.animate([from, restFrame], timing);
a.onfinish = () => {
if (thumb) thumb.style.visibility = "";
if (phase.current === "opening") phase.current = "open";
};
anims.current.push(a);
} else {
phase.current = "open";
frame?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180 });
}
backdropRef.current?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: reduceMotion ? 180 : duration * 0.8, easing: "ease-out" });
chromeRef.current?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: reduceMotion ? 180 : duration * 0.6, delay: reduceMotion ? 0 : duration * 0.4, fill: "backwards" });
// Runs once per open; index / sizes at that moment are what we animate from.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const finishClose = () => {
anims.current.forEach((a) => a.cancel());
anims.current = [];
thumbs.current.forEach((b) => {
const img = b.querySelector("img");
if (img) img.style.visibility = "";
});
phase.current = "closed";
if (backdropRef.current) backdropRef.current.style.opacity = "";
if (slideRef.current) slideRef.current.style.transform = "translateX(0px)";
if (dialogRef.current?.open) dialogRef.current.close();
setOpen(false);
setZoomed(false);
zoom.current = { s: 1, x: 0, y: 0 };
onIndexChange?.(index, false);
// Back to the thumbnail of the image that was showing.
thumbs.current.get(index)?.focus({ preventScroll: true });
};
const close = () => {
if (phase.current === "closed" || phase.current === "closing") return;
phase.current = "closing";
queued.current = null;
anims.current.forEach((a) => a.cancel());
resetZoom(false);
const frame = frames.current.get(index);
const to = reduceMotion ? null : thumbFrame(index);
const slide = slideRef.current;
const offsetY = slide ? translateOf(slide).y : 0;
const timing = { duration: reduceMotion ? 160 : duration * 0.85, easing: EASE, fill: "forwards" as const };
const out: Animation[] = [];
if (chromeRef.current) out.push(chromeRef.current.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, fill: "forwards" }));
if (backdropRef.current) out.push(backdropRef.current.animate([{ opacity: getComputedStyle(backdropRef.current).opacity }, { opacity: 0 }], timing));
if (frame && to) {
const thumb = thumbImg(index);
if (thumb) thumb.style.visibility = "hidden";
// A swipe-down may have moved the slide: fold that offset into the start keyframe.
if (slide) slide.style.transform = "translateX(0px)";
out.push(frame.animate([{ ...restFrame, transform: `translate(0px, ${r2(offsetY)}px) scale(1)` }, to], timing));
} else if (frame) {
out.push(frame.animate([{ opacity: 1 }, { opacity: 0, transform: reduceMotion ? "none" : "scale(0.96)" }], timing));
}
anims.current = out;
if (!out.length) finishClose();
else out[out.length - 1].onfinish = finishClose;
};
// Resize: re-fit everything. Non-passive wheel on the stage: zoom / pan instead of page scroll.
React.useEffect(() => {
if (!open) return;
const onResize = () => setVp({ w: document.documentElement.clientWidth, h: window.innerHeight });
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [open]);
const wheelRef = React.useRef<(event: WheelEvent) => void>(() => {});
React.useEffect(() => {
wheelRef.current = (event: WheelEvent) => {
event.preventDefault();
if (phase.current !== "open") return;
if (event.ctrlKey || event.metaKey) {
zoomAt(zoom.current.s * Math.exp(-event.deltaY * 0.01), event.clientX, event.clientY, false);
} else if (zoom.current.s > 1.01) {
zoom.current = clampZoom({ ...zoom.current, x: zoom.current.x - event.deltaX, y: zoom.current.y - event.deltaY });
applyZoom();
}
};
});
React.useEffect(() => {
const dialog = dialogRef.current;
if (!open || !dialog) return;
const onWheel = (event: WheelEvent) => {
if ((event.target as HTMLElement).closest("[data-lb-strip]")) return;
wheelRef.current(event);
};
dialog.addEventListener("wheel", onWheel, { passive: false });
return () => dialog.removeEventListener("wheel", onWheel);
}, [open]);
React.useEffect(
() => () => {
anims.current.forEach((a) => a.cancel());
if (dialogRef.current?.open) dialogRef.current.close();
},
[],
);
/* ------------------------------ gestures ------------------------------ */
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || phase.current !== "open" || busy.current) return;
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
event.currentTarget.setPointerCapture(event.pointerId);
const g = gesture.current;
if (pointers.current.size === 2) {
const [a, b] = [...pointers.current.values()];
if (slideRef.current) slideRef.current.style.transform = "translateX(0px)";
gesture.current = { ...g, kind: "pinch", dist: Math.hypot(a.x - b.x, a.y - b.y) || 1, scale: zoom.current.s };
} else if (pointers.current.size === 1) {
gesture.current = { kind: "pending", x0: event.clientX, y0: event.clientY, t0: performance.now(), zx: zoom.current.x, zy: zoom.current.y, dist: 1, scale: zoom.current.s };
}
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.has(event.pointerId)) return;
pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
const g = gesture.current;
const slide = slideRef.current;
if (g.kind === "pinch" && pointers.current.size >= 2) {
const [a, b] = [...pointers.current.values()];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
zoomAt(g.scale * (dist / g.dist), (a.x + b.x) / 2, (a.y + b.y) / 2, false);
return;
}
const dx = event.clientX - g.x0;
const dy = event.clientY - g.y0;
if (g.kind === "pending" && Math.hypot(dx, dy) > 8) {
g.kind = zoom.current.s > 1.01 ? "pan" : Math.abs(dx) > Math.abs(dy) ? "swipe" : dy > 0 ? "dismiss" : "none";
}
if (g.kind === "pan") {
zoom.current = clampZoom({ s: zoom.current.s, x: g.zx + dx, y: g.zy + dy });
applyZoom();
} else if (g.kind === "swipe" && slide) {
// Rubber-band at the ends when not looping.
const blocked = (dx > 0 && !canGo(-1)) || (dx < 0 && !canGo(1));
slide.style.transform = `translateX(${r2(blocked ? dx * 0.25 : dx)}px)`;
} else if (g.kind === "dismiss" && slide) {
slide.style.transform = `translate(0px, ${r2(Math.max(0, dy))}px)`;
if (backdropRef.current) backdropRef.current.style.opacity = String(r2(1 - Math.min(0.7, dy / 400)));
}
};
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!pointers.current.has(event.pointerId)) return;
pointers.current.delete(event.pointerId);
const g = gesture.current;
const slide = slideRef.current;
if (g.kind === "pinch") {
if (pointers.current.size === 1) {
// One finger left: continue as a pan from here.
const [p] = [...pointers.current.values()];
gesture.current = { ...g, kind: "pan", x0: p.x, y0: p.y, zx: zoom.current.x, zy: zoom.current.y };
} else if (pointers.current.size === 0) {
gesture.current = { ...g, kind: "none" };
if (zoom.current.s < 1.05) resetZoom(true);
}
return;
}
if (pointers.current.size > 0) return;
const dx = event.clientX - g.x0;
const dy = event.clientY - g.y0;
const dt = Math.max(1, performance.now() - g.t0);
gesture.current = { ...g, kind: "none" };
if (g.kind === "swipe" && slide) {
const dir: 1 | -1 = dx < 0 ? 1 : -1;
if ((Math.abs(dx) > vp.w * 0.18 || Math.abs(dx) / dt > 0.5) && canGo(dir)) {
step(dir);
} else {
const a = slide.animate([{ transform: slide.style.transform }, { transform: "translateX(0px)" }], { duration: 220, easing: EASE });
slide.style.transform = "translateX(0px)";
anims.current.push(a);
}
} else if (g.kind === "dismiss" && slide) {
if (dy > 120 || dy / dt > 0.6) close();
else {
slide.animate([{ transform: slide.style.transform }, { transform: "translate(0px, 0px)" }], { duration: 220, easing: EASE });
slide.style.transform = "translateX(0px)";
if (backdropRef.current) backdropRef.current.style.opacity = "";
}
} else if (g.kind === "pending") {
const frame = frames.current.get(index);
const r = frame?.getBoundingClientRect();
const onImage = !!r && event.clientX >= r.left && event.clientX <= r.right && event.clientY >= r.top && event.clientY <= r.bottom;
if (event.pointerType !== "mouse") {
// Double-tap toggles zoom; a single tap on the backdrop closes.
const now = performance.now();
const t = lastTap.current;
if (now - t.t < 300 && Math.hypot(event.clientX - t.x, event.clientY - t.y) < 30) {
lastTap.current = { t: 0, x: 0, y: 0 };
if (onImage) toggleZoomAt(event.clientX, event.clientY);
return;
}
lastTap.current = { t: now, x: event.clientX, y: event.clientY };
}
if (!onImage && zoom.current.s <= 1.01) close();
}
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDialogElement>) => {
if (event.altKey || event.ctrlKey || event.metaKey || phase.current !== "open") return;
const z = zoom.current;
const panBy = (x: number, y: number) => {
const from = { ...z };
zoom.current = clampZoom({ ...z, x: z.x + x, y: z.y + y });
applyZoom(from);
};
const centre = () => {
const r = frames.current.get(index)?.getBoundingClientRect();
return r ? [r.left + r.width / 2, r.top + r.height / 2] : [vp.w / 2, vp.h / 2];
};
switch (event.key) {
case "ArrowRight":
if (z.s > 1.01) panBy(-80, 0);
else step(1);
break;
case "ArrowLeft":
if (z.s > 1.01) panBy(80, 0);
else step(-1);
break;
case "ArrowUp":
if (z.s <= 1.01) return;
panBy(0, 80);
break;
case "ArrowDown":
if (z.s <= 1.01) return;
panBy(0, -80);
break;
case "Home":
goTo(0, -1);
break;
case "End":
goTo(count - 1, 1);
break;
case "+":
case "=": {
const [cx, cy] = centre();
zoomAt(z.s * 1.5, cx, cy, true);
break;
}
case "-": {
const [cx, cy] = centre();
if (z.s / 1.5 <= 1.01) resetZoom(true);
else zoomAt(z.s / 1.5, cx, cy, true);
break;
}
case "0":
resetZoom(true);
break;
default:
return;
}
event.preventDefault();
};
/* -------------------------------- render ------------------------------- */
const dark = backdrop === "dark";
const chromeText = dark ? "text-zinc-100" : "text-zinc-800 dark:text-zinc-100";
const chromeButton = `grid size-10 place-items-center rounded-full outline-hidden transition-colors focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--lb-accent)] disabled:opacity-30 [&_svg]:size-[18px] ${
dark ? "bg-white/[0.06] text-zinc-100 hover:bg-white/15" : "bg-zinc-900/[0.05] text-zinc-800 hover:bg-zinc-900/10 dark:bg-white/[0.06] dark:text-zinc-100 dark:hover:bg-white/15"
}`;
const current = images[index];
// Current image plus its neighbours, each in its own viewport-wide slot.
const slots: [number, number][] = [];
if (open && count) {
slots.push([index, 0]);
const next = loop ? (index + 1) % count : index + 1;
const prev = loop ? (index - 1 + count) % count : index - 1;
if (next !== index && next < count) slots.push([next, 1]);
if (prev !== index && prev >= 0 && prev !== next) slots.push([prev, -1]);
}
return (
<div className={`w-full ${className}`} style={{ ["--lb-accent" as string]: accent, ...style }} {...props}>
<ul
aria-label={label}
className="m-0 grid list-none grid-cols-2 p-0 sm:grid-cols-[repeat(var(--lb-cols),minmax(0,1fr))]"
style={{ gap, ["--lb-cols" as string]: Math.max(1, Math.round(columns)) }}
>
{images.map((img, i) => (
<li key={i} className="min-w-0">
<button
ref={(el) => {
if (!el) return;
const map = thumbs.current;
map.set(i, el);
return () => {
if (map.get(i) === el) map.delete(i);
};
}}
type="button"
aria-haspopup="dialog"
onClick={() => openAt(i)}
className="group/thumb relative block w-full cursor-zoom-in overflow-hidden bg-zinc-900/[0.04] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--lb-accent)] dark:bg-white/[0.04]"
style={{ borderRadius: rounded, aspectRatio: aspectRatio.replace("/", " / ") }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}
<img
src={img.thumbSrc ?? img.src}
alt={img.alt}
width={img.width}
height={img.height}
loading="lazy"
decoding="async"
draggable={false}
className="block size-full object-cover transition-transform duration-500 ease-[cubic-bezier(0.2,0.9,0.1,1)] [@media(hover:hover)]:group-hover/thumb:scale-[1.04]"
/>
<span className="sr-only">{`, image ${i + 1} of ${count}, open viewer`}</span>
</button>
</li>
))}
</ul>
<dialog
ref={dialogRef}
aria-label={`${label} viewer`}
onCancel={(event) => {
event.preventDefault();
close();
}}
onClose={() => {
if (phase.current !== "closed") finishClose();
}}
onKeyDown={onKeyDown}
className="fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-left outline-hidden backdrop:bg-transparent"
style={{ ["--lb-accent" as string]: accent }}
>
<div ref={backdropRef} aria-hidden="true" className={`absolute inset-0 ${dark ? "bg-[#0b0d0f]/95" : "bg-[#F5F3EE]/95 dark:bg-[#0b0d0f]/95"}`} />
{/* Stage: gestures land here; images sit in viewport-wide slots on a sliding track. */}
<div
className={`absolute inset-0 touch-none select-none ${zoomed ? "cursor-grab active:cursor-grabbing" : ""}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={(event) => {
const r = frames.current.get(index)?.getBoundingClientRect();
if (r && event.clientX >= r.left && event.clientX <= r.right && event.clientY >= r.top && event.clientY <= r.bottom) toggleZoomAt(event.clientX, event.clientY);
}}
>
<div ref={slideRef} className="absolute inset-0 will-change-transform">
{slots.map(([i, slot]) => {
const img = images[i];
const box = fit(vp, dimOf(i), thumbnails);
return (
<div
key={i}
ref={(el) => {
if (!el) return;
const map = frames.current;
map.set(i, el);
return () => {
if (map.get(i) === el) map.delete(i);
};
}}
aria-hidden={slot !== 0 || undefined}
className={`absolute origin-center ${slot === 0 ? "" : "pointer-events-none"}`}
style={{ left: r2(box.left + slot * vp.w), top: box.top, width: box.width, height: box.height }}
>
<div
ref={(el) => {
if (!el) return;
const map = zoomEls.current;
map.set(i, el);
return () => {
if (map.get(i) === el) map.delete(i);
};
}}
className="size-full origin-top-left"
>
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}
<img
src={img.src}
alt={slot === 0 ? img.alt : ""}
draggable={false}
decoding="async"
onLoad={(event) => {
const { naturalWidth: w, naturalHeight: h } = event.currentTarget;
if (w && h && (!img.width || !img.height)) setLoaded((prev) => (prev[i] ? prev : { ...prev, [i]: { w, h } }));
}}
className="block size-full rounded-[4px] shadow-[0_30px_80px_-30px_rgb(0_0_0/0.6)]"
/>
</div>
</div>
);
})}
</div>
</div>
{/* Chrome: counter, zoom, close, arrows, caption and the thumbnail strip. */}
<div ref={chromeRef} className={`pointer-events-none absolute inset-0 ${chromeText}`}>
<div className="absolute inset-x-0 top-0 flex h-[60px] items-center justify-between gap-3 px-3 sm:px-5">
<p className="font-mono text-[11px] uppercase tabular-nums tracking-[0.14em] opacity-80" aria-hidden="true">
{index + 1} <span className="opacity-50">/</span> {count}
</p>
<div className="pointer-events-auto flex items-center gap-1.5">
<button
type="button"
className={chromeButton}
aria-label={zoomed ? "Zoom out" : "Zoom in"}
onClick={() => {
const r = frames.current.get(index)?.getBoundingClientRect();
toggleZoomAt(r ? r.left + r.width / 2 : vp.w / 2, r ? r.top + r.height / 2 : vp.h / 2);
}}
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M12.5 12.5 17 17M6.2 8.5h4.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
{zoomed ? null : <path d="M8.5 6.2v4.6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />}
</svg>
</button>
<button ref={closeRef} type="button" className={chromeButton} aria-label="Close viewer" onClick={close}>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
</div>
</div>
{count > 1 ? (
<>
<button
type="button"
className={`${chromeButton} pointer-events-auto absolute left-3 top-1/2 -translate-y-1/2 max-sm:hidden ${zoomed ? "opacity-0" : ""}`}
aria-label="Previous image"
disabled={!canGo(-1)}
onClick={() => step(-1)}
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M12 4.5 6.5 10l5.5 5.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className={`${chromeButton} pointer-events-auto absolute right-3 top-1/2 -translate-y-1/2 max-sm:hidden ${zoomed ? "opacity-0" : ""}`}
aria-label="Next image"
disabled={!canGo(1)}
onClick={() => step(1)}
>
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path d="M8 4.5 13.5 10 8 15.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</>
) : null}
<div className={`absolute inset-x-0 bottom-0 transition-opacity duration-200 ${zoomed ? "opacity-0" : ""}`}>
<p className="mx-auto flex h-12 max-w-[min(92vw,720px)] items-center justify-center px-4 text-center text-[13.5px] leading-snug tracking-[-0.005em] opacity-90">
<span className="line-clamp-2">{current?.caption ?? ""}</span>
</p>
{thumbnails && count > 1 ? (
<div
ref={stripRef}
data-lb-strip=""
className={`pointer-events-auto mx-auto flex h-[84px] max-w-full items-center gap-2 overflow-x-auto overscroll-contain px-[50vw] pb-3 [scrollbar-width:none] ${zoomed ? "pointer-events-none" : ""}`}
>
{images.map((img, i) => (
<button
key={i}
type="button"
data-strip={i}
aria-label={`Image ${i + 1}: ${img.alt}`}
aria-current={i === index ? "true" : undefined}
tabIndex={zoomed ? -1 : 0}
onClick={() => goTo(i, i > index ? 1 : -1)}
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-md outline-hidden transition-[opacity,scale] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--lb-accent)] ${
i === index ? "scale-105 opacity-100 ring-2 ring-[var(--lb-accent)]" : "opacity-45 hover:opacity-80"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}
<img src={img.thumbSrc ?? img.src} alt="" draggable={false} loading="lazy" className="block size-full object-cover" />
</button>
))}
</div>
) : null}
</div>
</div>
<p className="sr-only" aria-live="polite" aria-atomic="true" id={`${uid}-status`}>
{open && current
? [`Image ${index + 1} of ${count}`, current.caption, zoomed ? "Zoomed in" : ""]
.filter(Boolean)
.map((part) => (/[.!?]$/.test(part as string) ? part : `${part}.`))
.join(" ")
: ""}
</p>
</dialog>
</div>
);
}