<script module lang="ts">
import { tv } from "tailwind-variants";
export const lightboxChrome = tv({
slots: {
veil: "absolute inset-0 bg-black/80",
frame: "absolute inset-0 cursor-default touch-none overflow-hidden outline-none select-none",
flight: "absolute inset-0 flex items-center justify-center p-4 sm:p-14",
image: "size-auto max-h-full max-w-full",
bar: "pointer-events-none absolute inset-0 flex items-start gap-2 p-3 sm:p-4",
badge: "flex min-h-9.5 items-center rounded-md bg-background-default px-3 typography-paragraph-small text-foreground-default edge-ring-inset",
controls: "pointer-events-auto ml-auto flex items-center gap-2",
button: "pointer-events-auto min-h-9 w-9 px-0"
},
variants: {
zoomed: {
true: { image: "cursor-grab active:cursor-grabbing" },
false: { image: "cursor-zoom-in" }
}
},
defaultVariants: {
zoomed: false
}
});
</script>
<script lang="ts">
import { onDestroy, tick, untrack } from "svelte";
import type { Attachment } from "svelte/attachments";
import Button from "#lib/components/ui/Button.svelte";
import ChevronLeft from "$icon/chevron-left";
import ChevronRight from "$icon/chevron-right";
import X from "$icon/x";
import ZoomIn from "$icon/zoom-in";
import ZoomOut from "$icon/zoom-out";
import { CELL, HOME, Motion, VEIL } from "#lib/motion/value.svelte.ts";
import type { LightboxImage } from "#lib/lightbox.svelte.ts";
import ButtonGroup from "#lib/components/ui/ButtonGroup.svelte";
interface Props {
open: boolean;
images: LightboxImage[];
index?: number;
maxScale?: number;
onClose: () => void;
onExited?: () => void;
}
let { open, images, index = $bindable(0), maxScale = 4, onClose, onExited }: Props = $props();
const TOGGLE = 2.5;
const KEY_ZOOM = 1.6;
const KEY_PAN = 56;
const WHEEL_RATE = 140;
const SLOP = 8;
const NEAR_HOME = 1.02;
const SNAP_HOME = 1.05;
const RADIUS_FALLBACK = 6;
const ORIGIN_RADIUS = 9;
const ANNOUNCE = 220;
const restingRadius = (): number => {
const root = getComputedStyle(document.documentElement);
const raw = root.getPropertyValue("--radius-md").trim();
const value = Number.parseFloat(raw);
if (!Number.isFinite(value)) return RADIUS_FALLBACK;
return raw.endsWith("rem") ? value * Number.parseFloat(root.fontSize) : value;
};
const id = $props.id();
const hintId = `${id}-hint`;
let panel = $state<HTMLDialogElement>();
let frame = $state<HTMLElement>();
let content = $state<HTMLImageElement>();
let staged = $state(false);
let closing = $state(false);
let announced = $state(1);
let generation = 0;
const scale = new Motion(1);
const x = new Motion(0);
const y = new Motion(0);
const flightX = new Motion(0);
const flightY = new Motion(0);
const flightScale = new Motion(1);
const flightOpacity = new Motion(0);
const flightRadius = new Motion(RADIUS_FALLBACK);
const veil = new Motion(0);
const bar = new Motion(0);
const top = $derived(Math.max(1.1, maxScale));
const current = $derived(images[index]);
const zoomed = $derived(scale.current > SNAP_HOME);
const caption = $derived(current?.alt || `Image ${index + 1} of ${images.length}`);
const classes = $derived(lightboxChrome({ zoomed }));
const reduced = (): boolean =>
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const clamp = (value: number, lo: number, hi: number): number =>
Math.min(hi, Math.max(lo, value));
const limit = (s: number): { mx: number; my: number } => {
if (!frame || !content) return { mx: 0, my: 0 };
return {
mx: Math.max(0, (content.offsetWidth * s - frame.clientWidth) / 2),
my: Math.max(0, (content.offsetHeight * s - frame.clientHeight) / 2)
};
};
const place = (s: number, nx: number, ny: number): void => {
const { mx, my } = limit(s);
scale.set(s);
x.set(clamp(nx, -mx, mx));
y.set(clamp(ny, -my, my));
};
const glide = (s: number, nx: number, ny: number, spring = CELL): void => {
const { mx, my } = limit(s);
const tx = clamp(nx, -mx, mx);
const ty = clamp(ny, -my, my);
if (reduced()) {
scale.set(s);
x.set(tx);
y.set(ty);
return;
}
scale.animate(s, spring);
x.animate(tx, spring);
y.animate(ty, spring);
};
const home = (): void => glide(1, 0, 0, HOME);
const zoomAt = (next: number, cx: number, cy: number, animated: boolean): void => {
if (!frame) return;
const box = frame.getBoundingClientRect();
const px = cx - (box.left + box.width / 2);
const py = cy - (box.top + box.height / 2);
const from = scale.current;
const ax = (px - x.current) / from;
const ay = (py - y.current) / from;
const s = clamp(next, 1, top);
const nx = px - ax * s;
const ny = py - ay * s;
if (animated) glide(s, nx, ny, s <= 1 ? HOME : CELL);
else place(s, nx, ny);
};
const toggleZoom = (): void => {
if (!frame) return;
if (zoomed) {
home();
return;
}
const box = frame.getBoundingClientRect();
zoomAt(Math.min(TOGGLE, top), box.left + box.width / 2, box.top + box.height / 2, true);
};
const step = (delta: number): void => {
if (images.length < 2) return;
index = (index + delta + images.length) % images.length;
};
interface Drag {
id: number;
fromX: number;
fromY: number;
x: number;
y: number;
}
let drag: Drag | null = null;
let onContent = false;
const finish = (): void => {
if (scale.current < SNAP_HOME) home();
};
const onPointerDown = (event: PointerEvent): void => {
if (event.pointerType === "mouse" && event.button !== 0) return;
onContent = content ? content.contains(event.target as Node) : false;
if (event.currentTarget instanceof HTMLElement)
event.currentTarget.setPointerCapture(event.pointerId);
drag = {
id: event.pointerId,
fromX: event.clientX,
fromY: event.clientY,
x: x.current,
y: y.current
};
};
const onPointerMove = (event: PointerEvent): void => {
if (!drag || drag.id !== event.pointerId || scale.current <= 1) return;
place(
scale.current,
drag.x + (event.clientX - drag.fromX),
drag.y + (event.clientY - drag.fromY)
);
};
const onPointerUp = (event: PointerEvent): void => {
if (!drag || drag.id !== event.pointerId) return;
const held = drag;
drag = null;
const moved = Math.hypot(event.clientX - held.fromX, event.clientY - held.fromY);
if (moved < SLOP) {
if (!onContent && scale.current <= NEAR_HOME) {
onClose();
return;
}
if (onContent) {
zoomAt(
scale.current > SNAP_HOME ? 1 : Math.min(TOGGLE, top),
event.clientX,
event.clientY,
true
);
return;
}
}
finish();
};
const onPointerCancel = (event: PointerEvent): void => {
if (!drag || drag.id !== event.pointerId) return;
drag = null;
finish();
};
const onKeyDown = (event: KeyboardEvent): void => {
if (!frame) return;
const box = frame.getBoundingClientRect();
const cx = box.left + box.width / 2;
const cy = box.top + box.height / 2;
const from = scale.current;
if (event.key === "+" || event.key === "=") {
event.preventDefault();
zoomAt(from * KEY_ZOOM, cx, cy, true);
return;
}
if (event.key === "-" || event.key === "_") {
event.preventDefault();
zoomAt(from / KEY_ZOOM, cx, cy, true);
return;
}
if (event.key === "0") {
event.preventDefault();
home();
return;
}
if (event.key === "Escape") {
event.preventDefault();
if (from > NEAR_HOME) home();
else onClose();
return;
}
if (!event.key.startsWith("Arrow")) return;
if (from > NEAR_HOME) {
event.preventDefault();
const dx =
event.key === "ArrowLeft" ? KEY_PAN : event.key === "ArrowRight" ? -KEY_PAN : 0;
const dy = event.key === "ArrowUp" ? KEY_PAN : event.key === "ArrowDown" ? -KEY_PAN : 0;
glide(from, x.current + dx, y.current + dy);
return;
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
step(event.key === "ArrowLeft" ? -1 : 1);
}
};
const wheelZoom: Attachment<HTMLElement> = (node) => {
const onWheel = (event: WheelEvent): void => {
event.preventDefault();
zoomAt(
scale.current * Math.exp(-event.deltaY / WHEEL_RATE),
event.clientX,
event.clientY,
false
);
};
node.addEventListener("wheel", onWheel, { passive: false });
return () => node.removeEventListener("wheel", onWheel);
};
const toBody: Attachment<HTMLElement> = (node) => {
document.body.appendChild(node);
return () => node.remove();
};
interface Landing {
dx: number;
dy: number;
scale: number;
opacity: number;
radius: number;
}
const landing = (): Landing => {
const origin = images[index]?.origin;
if (frame && content && origin && content.offsetWidth > 0) {
const box = frame.getBoundingClientRect();
const seat = origin.getBoundingClientRect();
if (seat.width > 0) {
const s = seat.width / content.offsetWidth;
const radius =
Number.parseFloat(getComputedStyle(origin).borderTopLeftRadius) ||
ORIGIN_RADIUS;
return {
dx: seat.left + seat.width / 2 - (box.left + box.width / 2),
dy: seat.top + seat.height / 2 - (box.top + box.height / 2),
scale: s,
opacity: 1,
radius: radius / s
};
}
}
return { dx: 0, dy: 10, scale: 0.97, opacity: 0, radius: restingRadius() };
};
const settle = (): void => {
flightX.set(0);
flightY.set(0);
flightScale.set(1);
flightOpacity.set(1);
flightRadius.set(restingRadius());
veil.set(1);
bar.set(1);
};
const enter = async (): Promise<void> => {
const mine = ++generation;
staged = true;
closing = false;
await tick();
if (mine !== generation) return;
if (!panel?.open) panel?.showModal();
frame?.focus({ preventScroll: true });
scale.set(1);
x.set(0);
y.set(0);
const image = content;
if (image && !image.complete)
await new Promise((resolve) => image.addEventListener("load", resolve, { once: true }));
await tick();
if (mine !== generation) return;
if (reduced()) {
settle();
return;
}
const to = landing();
flightX.set(to.dx);
flightY.set(to.dy);
flightScale.set(to.scale);
flightOpacity.set(to.opacity);
flightRadius.set(to.radius);
flightX.animate(0, HOME);
flightY.animate(0, HOME);
flightScale.animate(1, HOME);
flightOpacity.animate(1, HOME);
flightRadius.animate(restingRadius(), HOME);
veil.animate(1, VEIL);
bar.animate(1, VEIL);
};
const leave = (): void => {
if (!staged) return;
generation += 1;
closing = true;
const done = (): void => {
panel?.close();
staged = false;
closing = false;
onExited?.();
};
if (reduced()) {
done();
return;
}
const to = landing();
let pending = 4;
const arrived = (): void => {
pending -= 1;
if (pending === 0) done();
};
x.animate(0, HOME);
y.animate(0, HOME);
scale.animate(1, HOME);
flightRadius.animate(to.radius, HOME);
veil.animate(0, VEIL);
bar.animate(0, VEIL);
flightX.animate(to.dx, HOME, arrived);
flightY.animate(to.dy, HOME, arrived);
flightScale.animate(to.scale, HOME, arrived);
flightOpacity.animate(to.opacity, HOME, arrived);
};
$effect(() => {
if (open) untrack(() => void enter());
else untrack(leave);
});
$effect(() => {
void index;
untrack(() => {
scale.set(1);
x.set(0);
y.set(0);
});
});
$effect(() => {
const reading = scale.current;
const timer = setTimeout(() => (announced = reading), ANNOUNCE);
return () => clearTimeout(timer);
});
const motions = [
scale,
x,
y,
flightX,
flightY,
flightScale,
flightOpacity,
flightRadius,
veil,
bar
];
onDestroy(() => {
for (const motion of motions) motion.stop();
});
$effect(() => {
const bail = (): void => {
drag = null;
};
window.addEventListener("blur", bail);
return () => window.removeEventListener("blur", bail);
});
</script>
{#if staged && current}
<dialog
bind:this={panel}
closedby="none"
class="lightbox-panel fixed inset-0 z-50 size-full max-h-[none] max-w-[none] border-0 bg-transparent p-0 text-foreground-default outline-none"
aria-label={caption}
data-closing={closing ? "" : undefined}
onkeydown={onKeyDown}
onclose={onClose}
{@attach toBody}
>
<div aria-hidden="true" class={classes.veil()} style="opacity: {veil.current}"></div>
<div
bind:this={frame}
tabindex="-1"
role="group"
aria-label={caption}
aria-describedby={hintId}
class={classes.frame()}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
onlostpointercapture={onPointerCancel}
{@attach wheelZoom}
>
<div
class={classes.flight()}
style="translate: {flightX.current}px {flightY.current}px; scale: {flightScale.current}; opacity: {flightOpacity.current}"
>
<img
bind:this={content}
src={current.src}
alt={current.alt}
width={current.width}
height={current.height}
draggable="false"
class="{classes.image()} {current.class}"
style="translate: {x.current}px {y.current}px; scale: {scale.current}; border-radius: {flightRadius.current}px"
/>
</div>
</div>
<div class={classes.bar()} style="opacity: {bar.current}">
{#if images.length > 1}
<p class={classes.badge()}>
{index + 1} / {images.length}
</p>
<ButtonGroup>
<Button
class={classes.button()}
icon={ChevronLeft}
aria-label="Previous image"
onclick={() => step(-1)}
/>
<Button
class={classes.button()}
icon={ChevronRight}
aria-label="Next image"
onclick={() => step(1)}
/>
</ButtonGroup>
{/if}
<div class={classes.controls()}>
<Button
class={classes.button()}
icon={zoomed ? ZoomOut : ZoomIn}
aria-label={zoomed ? "Zoom out" : "Zoom in"}
onclick={toggleZoom}
/>
<Button class={classes.button()} icon={X} aria-label="Close" onclick={onClose} />
</div>
</div>
<p id={hintId} class="sr-only">
Click the image to switch between fit and close-up, scroll to zoom toward the pointer,
or press plus and minus. Drag or use the arrow keys to pan. Press zero to return to the
starting frame; Escape returns home first, then closes. At the starting frame the left
and right arrows step through the images.
</p>
<p role="status" class="sr-only">Zoom {announced.toFixed(1)} times</p>
</dialog>
{/if}
<style>
:global(html:has(.lightbox-panel:modal:not([data-closing]))) {
overflow: hidden;
}
.lightbox-panel::backdrop {
background: transparent;
}
</style>