import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
createPhotoGalleryModel,
hasMeaningfulScroll,
reducePhotoGalleryState,
type PhotoGalleryEvent as GalleryEvent,
type PhotoGalleryState,
} from "./model.mjs";
import {
PHOTO_GALLERY_LAYOUT_TRANSITION,
PHOTO_GALLERY_STACK_RETURN_DISSOLVE,
PHOTO_GALLERY_STACK_RETURN_LAYOUT_TRANSITION,
} from "./motion";
import "./PhotoGallery.css";
export type PhotoGalleryPhoto = {
id: string;
label?: string;
previewSrc: string;
src: string;
alt: string;
caption: ReactNode;
focalPoint?: { x: number; y: number };
};
export type PhotoGalleryNavigationUpdate = {
mode: "default" | "gallery";
phase: "opening" | "closing" | "settled";
};
export type PhotoGalleryProps = {
photos: readonly PhotoGalleryPhoto[];
previewIds: readonly [string, string, string];
triggerLabel: string;
dialogLabel: string;
navigationTarget?: HTMLElement | null;
pageContentTarget?: HTMLElement | null;
onNavigationChange?: (update: PhotoGalleryNavigationUpdate) => void;
labels?: {
open?: string;
close?: string;
back?: string;
};
};
const PREVIEW_CLOSE_DELAY_MS = 180;
const PREVIEW_GAP_PX = 2;
const STACK_WIDTH = 150;
const STACK_HEIGHT = 142;
const VIEWPORT_PADDING = 16;
const PHOTO_LAYOUT_TRANSITION = {
...PHOTO_GALLERY_LAYOUT_TRANSITION,
opacity: { duration: 0.14, ease: "easeOut" as const },
} as const;
const STACK_RETURN_LAYOUT_TRANSITION = {
...PHOTO_GALLERY_STACK_RETURN_LAYOUT_TRANSITION,
opacity: { duration: 0.14, ease: "easeOut" as const },
} as const;
const GALLERY_CARD_ROTATIONS = [-3, 4, 2, -2, -4, 3] as const;
const preloadedImages = new Set<string>();
type StackPosition = {
left: number;
top: number;
side: "above" | "below";
};
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function preloadImage(src: string) {
if (typeof Image === "undefined" || preloadedImages.has(src)) return;
preloadedImages.add(src);
const image = new Image();
image.decoding = "async";
image.src = src;
}
function useDecodedImage(src: string) {
const [isDecoded, setIsDecoded] = useState(false);
useEffect(() => {
let cancelled = false;
let decodeFailed = false;
let hasLoaded = false;
setIsDecoded(false);
if (typeof Image === "undefined") return;
const image = new Image();
const markDecoded = () => {
if (!cancelled) setIsDecoded(true);
};
const handleLoad = () => {
hasLoaded = true;
if (typeof image.decode !== "function" || decodeFailed) markDecoded();
};
image.decoding = "async";
image.addEventListener("load", handleLoad);
image.src = src;
if (typeof image.decode === "function") {
image.decode().then(markDecoded).catch(() => {
decodeFailed = true;
if (hasLoaded || (image.complete && image.naturalWidth > 0)) markDecoded();
});
}
return () => {
cancelled = true;
image.removeEventListener("load", handleLoad);
};
}, [src]);
return isDecoded;
}
function PhotoStack({
photos,
previewPhotos,
layoutScope,
stackId,
actionLabel,
onActivate,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
onFan,
onUnfan,
onGalleryIntent,
isFanned,
isPinned,
triggerRef,
stackRef,
reduceMotion,
isReturning = false,
isParked = false,
onReturnComplete,
}: {
photos: readonly PhotoGalleryPhoto[];
previewPhotos: readonly PhotoGalleryPhoto[];
layoutScope: string;
stackId?: string;
actionLabel: string;
onActivate: () => void;
onPointerEnter: () => void;
onPointerLeave: () => void;
onFocus: () => void;
onBlur: () => void;
onFan: () => void;
onUnfan: () => void;
onGalleryIntent: () => void;
isFanned: boolean;
isPinned: boolean;
triggerRef: React.RefObject<HTMLButtonElement | null>;
stackRef: React.RefObject<HTMLButtonElement | null>;
reduceMotion: boolean;
isReturning?: boolean;
isParked?: boolean;
onReturnComplete?: () => void;
}) {
const stackPhotos = photos
.map((photo: PhotoGalleryPhoto, galleryIndex: number) => ({
photo,
galleryIndex,
previewIndex: previewPhotos.findIndex(({ id }) => id === photo.id),
}))
.sort((a, b) => {
const aOrder = a.previewIndex >= 0 ? a.previewIndex : previewPhotos.length + a.galleryIndex;
const bOrder = b.previewIndex >= 0 ? b.previewIndex : previewPhotos.length + b.galleryIndex;
return aOrder - bOrder;
});
function handlePointerEnter() {
onGalleryIntent();
onPointerEnter();
}
function handlePhotoPointerEnter(event: ReactPointerEvent<HTMLSpanElement>) {
if (event.pointerType === "mouse") onFan();
}
function handlePhotoPointerLeave(event: ReactPointerEvent<HTMLSpanElement>) {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Element)) return;
if (nextTarget.closest(".photo-stack-gallery__stack__card")) return;
if (
stackRef.current?.contains(nextTarget) ||
triggerRef.current?.contains(nextTarget)
) {
onUnfan();
}
}
function handlePointerLeave() {
if (isPinned) onUnfan();
onPointerLeave();
}
function handleFocus() {
onGalleryIntent();
onFan();
onFocus();
}
function handleBlur() {
if (isPinned) onUnfan();
onBlur();
}
return (
<motion.button
ref={stackRef}
id={isParked || isReturning ? undefined : stackId}
type="button"
className="photo-stack-gallery__stack"
data-fanned={isFanned}
data-returning={isReturning}
aria-label={actionLabel}
aria-hidden={isReturning || isParked || undefined}
disabled={isReturning || isParked}
onClick={onActivate}
onPointerDown={onGalleryIntent}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onFocus={handleFocus}
onBlur={handleBlur}
whileTap={reduceMotion ? undefined : { scale: 0.97 }}
>
{stackPhotos.map(({ photo, previewIndex }) => {
const shouldRenderPreview = previewIndex >= 0 || isParked || isReturning;
return (
<motion.span
key={photo.id}
layoutId={isParked ? undefined : `${layoutScope}-photo-${photo.id}`}
layoutCrossfade={false}
className="photo-stack-gallery__stack__slot"
data-concealed={previewIndex < 0}
initial={false}
animate={{ opacity: previewIndex < 0 ? 0 : 1 }}
transition={
isReturning ? STACK_RETURN_LAYOUT_TRANSITION : PHOTO_LAYOUT_TRANSITION
}
onLayoutAnimationComplete={
isReturning && photo.id === previewPhotos[1]?.id
? onReturnComplete
: undefined
}
>
<span
className="photo-stack-gallery__stack__card"
onPointerEnter={handlePhotoPointerEnter}
onPointerLeave={handlePhotoPointerLeave}
>
{shouldRenderPreview ? (
<img
src={photo.previewSrc}
alt=""
width={480}
height={640}
decoding="async"
draggable={false}
/>
) : null}
</span>
</motion.span>
);
})}
</motion.button>
);
}
function GalleryNavigationControl({
buttonRef,
navigationId,
mode,
closeLabel,
backLabel,
onActivate,
disabled = false,
}: {
buttonRef: React.RefObject<HTMLButtonElement | null>;
navigationId: string;
mode: "close" | "back";
closeLabel: string;
backLabel: string;
onActivate: () => void;
disabled?: boolean;
}) {
return (
<button
ref={buttonRef}
id={navigationId}
type="button"
className="photo-stack-gallery__gallery__close"
data-mode={mode}
aria-label={mode === "back" ? backLabel : closeLabel}
onClick={onActivate}
disabled={disabled}
>
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">
<path d="M5 5L15 15M15 5L5 15" />
</svg>
</button>
);
}
function GalleryOverlay({
photos,
layoutScope,
reduceMotion,
onSelect,
onPhotoIntent,
selectedPhotoId,
isDetailContext,
returnPhotoId,
onGalleryReturnComplete,
isStackReturning,
}: {
photos: readonly PhotoGalleryPhoto[];
layoutScope: string;
reduceMotion: boolean;
onSelect: (photoId: string) => void;
onPhotoIntent: (src: string) => void;
selectedPhotoId?: string;
isDetailContext: boolean;
returnPhotoId?: string;
onGalleryReturnComplete?: () => void;
isStackReturning: boolean;
}) {
return (
<motion.div
className="photo-stack-gallery__gallery"
data-returning={Boolean(returnPhotoId)}
data-stack-returning={isStackReturning}
>
{!isStackReturning ? (
<div className="photo-stack-gallery__gallery__stage">
{photos.map((photo: PhotoGalleryPhoto, index: number) => {
const isSelectedPhoto = selectedPhotoId === photo.id;
const isContextPhoto = isDetailContext && !isSelectedPhoto;
const galleryOpacity = isContextPhoto ? 0.16 : 1;
return (
<motion.button
key={photo.id}
type="button"
className="photo-stack-gallery__gallery__slot"
data-selected={isSelectedPhoto || undefined}
aria-label={`View ${photo.label ?? photo.alt}`}
layoutId={`${layoutScope}-photo-${photo.id}`}
layoutCrossfade={false}
style={{
"--card-rotation": `${GALLERY_CARD_ROTATIONS[index]}deg`,
} as CSSProperties}
onClick={() => onSelect(photo.id)}
onPointerEnter={() => onPhotoIntent(photo.src)}
onFocus={() => onPhotoIntent(photo.src)}
onPointerDown={() => onPhotoIntent(photo.src)}
disabled={isDetailContext}
initial={false}
animate={{
opacity: galleryOpacity,
filter: isContextPhoto ? "blur(8px)" : "blur(0px)",
}}
transition={{
...PHOTO_LAYOUT_TRANSITION,
opacity: { duration: reduceMotion ? 0 : 0.14, ease: "easeOut" },
filter: { duration: reduceMotion ? 0 : 0.14, ease: "easeOut" },
}}
whileTap={reduceMotion ? undefined : { scale: 0.98 }}
onLayoutAnimationComplete={
returnPhotoId === photo.id ? onGalleryReturnComplete : undefined
}
>
<motion.span
className="photo-stack-gallery__gallery__card"
initial={false}
animate={{ opacity: 1 }}
transition={{
duration: reduceMotion ? 0 : 0.14,
ease: "easeOut",
}}
>
<img
src={photo.previewSrc}
alt={photo.alt}
width={480}
height={640}
decoding="async"
draggable={false}
/>
</motion.span>
</motion.button>
);
})}
</div>
) : null}
</motion.div>
);
}
function PhotoDetail({
photo: selectedPhoto,
layoutScope,
rotation,
reduceMotion,
}: {
photo: PhotoGalleryPhoto;
layoutScope: string;
rotation: number;
reduceMotion: boolean;
}) {
const isFullImageDecoded = useDecodedImage(selectedPhoto.src);
return (
<motion.div className="photo-stack-gallery__detail" variants={{ exit: {} }} exit="exit">
<div className="photo-stack-gallery__detail__content">
<motion.div
key={selectedPhoto.id}
className="photo-stack-gallery__detail__media"
layoutId={`${layoutScope}-photo-${selectedPhoto.id}`}
layoutCrossfade={false}
initial={reduceMotion ? false : { rotate: rotation }}
animate={{ rotate: 0 }}
exit={reduceMotion ? undefined : { rotate: rotation }}
transition={PHOTO_LAYOUT_TRANSITION}
>
<img
className="photo-stack-gallery__detail__preview-image"
src={selectedPhoto.previewSrc}
alt={selectedPhoto.alt}
width={480}
height={640}
decoding="async"
draggable={false}
/>
{isFullImageDecoded ? (
<motion.img
className="photo-stack-gallery__detail__full-image"
src={selectedPhoto.src}
alt=""
decoding="async"
draggable={false}
style={{
objectPosition: `${selectedPhoto.focalPoint?.x ?? 50}% ${selectedPhoto.focalPoint?.y ?? 50}%`,
}}
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
variants={{
exit: {
opacity: 0,
transition: {
duration: reduceMotion ? 0 : 0.08,
ease: "easeOut",
},
},
}}
transition={{
duration: reduceMotion ? 0 : 0.14,
ease: "easeOut",
}}
/>
) : null}
</motion.div>
<motion.div
className="photo-stack-gallery__detail__meta"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: 4,
transition: { duration: reduceMotion ? 0 : 0.1, ease: "easeOut" },
}}
transition={{
duration: reduceMotion ? 0 : 0.22,
delay: reduceMotion ? 0 : 0.12,
}}
>
<div className="photo-stack-gallery__detail__copy">
<p className="photo-stack-gallery__detail__caption">
{selectedPhoto.caption}
</p>
</div>
</motion.div>
</div>
</motion.div>
);
}
export default function PhotoGallery({
photos: photoInput,
previewIds,
triggerLabel,
dialogLabel,
navigationTarget,
pageContentTarget,
onNavigationChange,
labels,
}: PhotoGalleryProps) {
const generatedId = useId().replace(/:/g, "");
const layoutScope = `photo-stack-gallery-${generatedId}`;
const stackId = `${layoutScope}-stack`;
const dialogId = `${layoutScope}-dialog`;
const navigationId = `${layoutScope}-navigation`;
const model = useMemo(
() => createPhotoGalleryModel(photoInput, previewIds),
[photoInput, previewIds]
);
const { photos, previewPhotos, galleryPhotos, photoIds } = model;
const openLabel = labels?.open ?? `Open ${dialogLabel}`;
const closeLabel = labels?.close ?? `Close ${dialogLabel}`;
const backLabel = labels?.back ?? `Back to ${dialogLabel}`;
const [state, setState] = useState<PhotoGalleryState>({ view: "rest" });
const [isMounted, setIsMounted] = useState(false);
const [isFanned, setIsFanned] = useState(false);
const [isStackReturnSettled, setIsStackReturnSettled] = useState(false);
const [position, setPosition] = useState<StackPosition>({ left: 0, top: 0, side: "above" });
const triggerWrapRef = useRef<HTMLSpanElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const stackRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const galleryCloseRef = useRef<HTMLButtonElement>(null);
const previousNavigationModeRef = useRef<"default" | "gallery">("default");
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressFocusPreviewRef = useRef(false);
const focusStackOnPinRef = useRef(false);
const reduceMotion = useReducedMotion() ?? false;
const dispatch = useCallback(
(event: GalleryEvent) => {
setState((current) => reducePhotoGalleryState(current, event, photoIds));
},
[photoIds]
);
const preloadGalleryPreviews = useCallback(() => {
for (const photo of photos) preloadImage(photo.previewSrc);
}, [photos]);
const preloadFullPhoto = useCallback((src: string) => {
preloadImage(src);
}, []);
const clearCloseTimer = useCallback(() => {
if (!closeTimerRef.current) return;
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}, []);
const restoreTriggerFocus = useCallback(() => {
suppressFocusPreviewRef.current = true;
triggerRef.current?.focus();
requestAnimationFrame(() => {
suppressFocusPreviewRef.current = false;
});
}, []);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const roomAbove = rect.top >= STACK_HEIGHT + PREVIEW_GAP_PX + VIEWPORT_PADDING;
const side = roomAbove ? "above" : "below";
const left = clamp(
rect.left + rect.width / 2 - STACK_WIDTH / 2,
VIEWPORT_PADDING,
window.innerWidth - STACK_WIDTH - VIEWPORT_PADDING
);
const top = roomAbove
? rect.top - STACK_HEIGHT - PREVIEW_GAP_PX
: rect.bottom + PREVIEW_GAP_PX;
setPosition({ left: Math.round(left), top: Math.round(top), side });
}, []);
const openTemporaryPreview = useCallback(() => {
clearCloseTimer();
updatePosition();
dispatch({ type: "HOVER_START" });
}, [clearCloseTimer, dispatch, updatePosition]);
const scheduleTemporaryClose = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = setTimeout(() => {
dispatch({ type: "HOVER_END" });
closeTimerRef.current = null;
}, PREVIEW_CLOSE_DELAY_MS);
}, [clearCloseTimer, dispatch]);
const closeTemporaryPreview = useCallback(() => {
clearCloseTimer();
dispatch({ type: "HOVER_END" });
}, [clearCloseTimer, dispatch]);
useEffect(() => {
setIsMounted(true);
return clearCloseTimer;
}, [clearCloseTimer]);
useEffect(() => {
if (state.view !== "stack-return") setIsStackReturnSettled(false);
}, [state.view]);
useEffect(() => {
if (state.view === "preview" && state.pinned) preloadGalleryPreviews();
}, [preloadGalleryPreviews, state]);
useEffect(() => {
if (!reduceMotion) return;
if (state.view === "gallery-return") {
dispatch({ type: "GALLERY_RETURN_COMPLETE" });
} else if (state.view === "stack-return") {
dispatch({ type: "STACK_RETURN_COMPLETE" });
}
}, [dispatch, reduceMotion, state.view]);
useEffect(() => {
if (state.view !== "preview") return;
const scrollOrigin = { x: window.scrollX, y: window.scrollY };
function handleOutsidePress(event: PointerEvent) {
const target = event.target as Node;
if (triggerWrapRef.current?.contains(target) || stackRef.current?.contains(target)) return;
dispatch({ type: "CLOSE" });
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
dispatch({ type: "CLOSE" });
restoreTriggerFocus();
}
}
function handleViewportChange() {
updatePosition();
}
function handlePreviewScroll() {
const currentScroll = { x: window.scrollX, y: window.scrollY };
if (!hasMeaningfulScroll(scrollOrigin, currentScroll)) return;
clearCloseTimer();
dispatch({ type: "CLOSE" });
}
document.addEventListener("pointerdown", handleOutsidePress);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handlePreviewScroll, { capture: true, passive: true });
return () => {
document.removeEventListener("pointerdown", handleOutsidePress);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handlePreviewScroll, true);
};
}, [clearCloseTimer, dispatch, restoreTriggerFocus, state.view, updatePosition]);
const isFullScreen =
state.view === "gallery" ||
state.view === "detail" ||
state.view === "gallery-return" ||
state.view === "stack-return";
useEffect(() => {
if (!isFullScreen) return;
const scrollY = window.scrollY;
const pageMain =
pageContentTarget === undefined
? (document.querySelector("main") as HTMLElement | null)
: pageContentTarget;
const previousMainInert = pageMain?.inert ?? false;
const previousBodyPosition = document.body.style.position;
const previousBodyTop = document.body.style.top;
const previousBodyWidth = document.body.style.width;
document.body.style.position = "fixed";
document.body.style.top = `-${scrollY}px`;
document.body.style.width = "100%";
if (pageMain) pageMain.inert = true;
requestAnimationFrame(() => galleryCloseRef.current?.focus());
return () => {
document.body.style.position = previousBodyPosition;
document.body.style.top = previousBodyTop;
document.body.style.width = previousBodyWidth;
if (pageMain) pageMain.inert = previousMainInert;
window.scrollTo(0, scrollY);
restoreTriggerFocus();
};
}, [isFullScreen, pageContentTarget, restoreTriggerFocus]);
useLayoutEffect(() => {
const mode =
isFullScreen && state.view !== "stack-return" ? "gallery" : "default";
const previousMode = previousNavigationModeRef.current;
const phase =
state.view === "stack-return"
? "closing"
: mode === "gallery" && previousMode !== "gallery"
? "opening"
: "settled";
onNavigationChange?.({ mode, phase });
previousNavigationModeRef.current = mode;
}, [isFullScreen, onNavigationChange, state.view]);
useEffect(() => {
if (!isFullScreen) return;
if (state.view === "stack-return") return;
requestAnimationFrame(() => galleryCloseRef.current?.focus());
}, [isFullScreen, state.view]);
useEffect(() => {
if (!isFullScreen) return;
function handleFullScreenKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
dispatch({ type: "BACK" });
return;
}
if (event.key === "Tab") {
const dialogControls = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], [tabindex]:not([tabindex="-1"])'
) ?? []
).filter((control) => {
if (control instanceof HTMLButtonElement && control.disabled) return false;
return !control.closest("[inert]");
});
const menuControl = galleryCloseRef.current;
const controls = [
...dialogControls,
...(menuControl && !menuControl.disabled ? [menuControl] : []),
];
if (!controls.length) return;
const currentIndex = controls.indexOf(document.activeElement as HTMLElement);
if (currentIndex < 0) return;
const direction = event.shiftKey ? -1 : 1;
const nextIndex = (currentIndex + direction + controls.length) % controls.length;
event.preventDefault();
controls[nextIndex].focus();
}
}
document.addEventListener("keydown", handleFullScreenKeyDown);
return () => document.removeEventListener("keydown", handleFullScreenKeyDown);
}, [dispatch, isFullScreen, state.view]);
function handleTriggerPointerEnter(event: ReactPointerEvent<HTMLButtonElement>) {
if (event.pointerType === "mouse") {
setIsFanned(false);
openTemporaryPreview();
}
}
function handleTriggerClick(event: ReactMouseEvent<HTMLButtonElement>) {
clearCloseTimer();
updatePosition();
focusStackOnPinRef.current =
event.detail === 0 &&
(state.view === "rest" ||
(state.view === "preview" && !state.pinned));
dispatch({ type: "TRIGGER_ACTIVATE" });
}
function handleTriggerFocus() {
if (suppressFocusPreviewRef.current) return;
openTemporaryPreview();
}
useLayoutEffect(() => {
if (!focusStackOnPinRef.current) return;
if (state.view !== "preview" || !state.pinned) return;
focusStackOnPinRef.current = false;
clearCloseTimer();
stackRef.current?.focus();
}, [clearCloseTimer, state]);
const positionStyle = {
"--photo-stack-gallery-stack-left": `${position.left}px`,
"--photo-stack-gallery-stack-top": `${position.top}px`,
} as CSSProperties;
const isStackReturning = state.view === "stack-return";
const stackReturnOpacityTransition = reduceMotion
? { duration: 0 }
: PHOTO_GALLERY_STACK_RETURN_DISSOLVE;
const isCompactStackParked = isFullScreen && !isStackReturning;
const showCompactStack =
state.view === "preview" || (isFullScreen && !isStackReturnSettled);
const resolvedNavigationTarget = isMounted ? navigationTarget : null;
const preview = (
<AnimatePresence
onExitComplete={() => {
setIsFanned(false);
if (state.view === "stack-return") {
dispatch({ type: "STACK_RETURN_COMPLETE" });
}
}}
>
{showCompactStack ? (
<motion.span
key={`${layoutScope}-compact-preview`}
className={`photo-stack-gallery__stack-position photo-stack-gallery__stack-position--${position.side}`}
data-returning={isStackReturning}
data-parked={isCompactStackParked}
style={positionStyle}
initial={
reduceMotion || isStackReturning
? false
: { opacity: 0, scale: 0.96, filter: "blur(4px)" }
}
animate={{
opacity: isStackReturning ? 0 : 1,
scale: 1,
filter: "blur(0px)",
}}
exit={
reduceMotion || isStackReturning
? { opacity: 0, transition: { duration: 0 } }
: {
opacity: 0,
scale: 0.96,
filter: "blur(4px)",
transition: {
duration: 0.18,
ease: [0.25, 0.46, 0.45, 0.94],
},
}
}
transition={{
duration: reduceMotion ? 0 : 0.22,
ease: [0.25, 0.46, 0.45, 0.94],
opacity: isStackReturning
? stackReturnOpacityTransition
: {
duration: reduceMotion ? 0 : 0.22,
ease: [0.25, 0.46, 0.45, 0.94],
},
}}
>
<PhotoStack
photos={galleryPhotos}
previewPhotos={previewPhotos}
layoutScope={layoutScope}
stackId={stackId}
actionLabel={openLabel}
stackRef={stackRef}
triggerRef={triggerRef}
reduceMotion={reduceMotion}
isFanned={isFanned}
isPinned={state.view === "preview" && state.pinned}
isReturning={isStackReturning}
isParked={isCompactStackParked}
onReturnComplete={() => setIsStackReturnSettled(true)}
onFan={() => setIsFanned(true)}
onUnfan={() => setIsFanned(false)}
onGalleryIntent={preloadGalleryPreviews}
onActivate={() => dispatch({ type: "STACK_ACTIVATE" })}
onPointerEnter={clearCloseTimer}
onPointerLeave={closeTemporaryPreview}
onFocus={clearCloseTimer}
onBlur={closeTemporaryPreview}
/>
</motion.span>
) : null}
</AnimatePresence>
);
const fullScreenExperience = isFullScreen ? (
<div
ref={dialogRef}
id={dialogId}
className="photo-stack-gallery__dialog"
data-photo-stack-gallery-dialog
role="dialog"
aria-modal="true"
aria-label={dialogLabel}
aria-owns={navigationId}
>
<AnimatePresence>
{isFullScreen ? (
<motion.div
key="photo-stack-gallery__gallery-surface"
className="photo-stack-gallery__gallery__surface"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: state.view === "stack-return" ? 0 : 1 }}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0 : state.view === "stack-return" ? 0.18 : 0.22,
ease: "easeOut",
}}
/>
) : null}
</AnimatePresence>
<AnimatePresence mode="sync">
{state.view === "gallery" ||
state.view === "detail" ||
state.view === "gallery-return" ||
state.view === "stack-return" ? (
<GalleryOverlay
photos={galleryPhotos}
layoutScope={layoutScope}
key="photo-stack-gallery__gallery"
reduceMotion={reduceMotion}
selectedPhotoId={
state.view === "detail" || state.view === "gallery-return"
? state.photoId
: undefined
}
isDetailContext={state.view === "detail"}
returnPhotoId={state.view === "gallery-return" ? state.photoId : undefined}
onGalleryReturnComplete={() =>
dispatch({ type: "GALLERY_RETURN_COMPLETE" })
}
isStackReturning={state.view === "stack-return"}
onPhotoIntent={preloadFullPhoto}
onSelect={(photoId) => dispatch({ type: "SELECT_PHOTO", photoId })}
/>
) : null}
</AnimatePresence>
<AnimatePresence mode="sync">
{state.view === "detail" ? (
<PhotoDetail
key="photo-stack-gallery__detail"
photo={photos.find(({ id }) => id === state.photoId) ?? photos[0]}
layoutScope={layoutScope}
rotation={
GALLERY_CARD_ROTATIONS[
Math.max(0, galleryPhotos.findIndex(({ id }) => id === state.photoId))
]
}
reduceMotion={reduceMotion}
/>
) : null}
</AnimatePresence>
</div>
) : null;
return (
<LayoutGroup id={layoutScope}>
<span ref={triggerWrapRef} className="photo-stack-gallery">
<button
ref={triggerRef}
type="button"
className="inline-trigger photo-stack-gallery__trigger"
data-decoration="wave"
aria-expanded={state.view !== "rest"}
aria-controls={dialogId}
onPointerEnter={handleTriggerPointerEnter}
onPointerLeave={scheduleTemporaryClose}
onFocus={handleTriggerFocus}
onBlur={scheduleTemporaryClose}
onClick={handleTriggerClick}
>
{triggerLabel}
</button>
{isMounted ? createPortal(preview, document.body) : null}
{isMounted ? createPortal(fullScreenExperience, document.body) : null}
{isMounted && isFullScreen ? (
resolvedNavigationTarget ? (
createPortal(
<GalleryNavigationControl
buttonRef={galleryCloseRef}
navigationId={navigationId}
mode={state.view === "detail" ? "back" : "close"}
closeLabel={closeLabel}
backLabel={backLabel}
onActivate={() =>
dispatch({ type: state.view === "detail" ? "BACK" : "CLOSE" })
}
disabled={state.view === "stack-return"}
/>,
resolvedNavigationTarget
)
) : (
createPortal(
<div className="photo-stack-gallery__navigation-fallback">
<GalleryNavigationControl
buttonRef={galleryCloseRef}
navigationId={navigationId}
mode={state.view === "detail" ? "back" : "close"}
closeLabel={closeLabel}
backLabel={backLabel}
onActivate={() =>
dispatch({ type: state.view === "detail" ? "BACK" : "CLOSE" })
}
disabled={state.view === "stack-return"}
/>
</div>,
document.body
)
)
) : null}
</span>
</LayoutGroup>
);
}
.photo-stack-gallery {
display: inline;
color: inherit;
}
.photo-stack-gallery__trigger {
position: relative;
}
.photo-stack-gallery__catalog {
display: grid;
justify-items: center;
gap: 4px;
width: 150px;
color: inherit;
}
.photo-stack-gallery__catalog__stack {
display: block;
width: 150px;
height: 142px;
}
.photo-stack-gallery__catalog__label {
display: inline-block;
font-size: 1rem;
line-height: 1.25;
white-space: nowrap;
}
/* Text decorations can paint outside an inline control's box. This invisible
hit slop keeps the wave and its surrounding line area inside the trigger. */
.photo-stack-gallery__trigger::before {
content: "";
position: absolute;
inset: -8px -4px;
}
.photo-stack-gallery__stack-position {
position: fixed;
z-index: 80;
left: var(--photo-stack-gallery-stack-left);
top: var(--photo-stack-gallery-stack-top);
display: block;
width: 150px;
height: 142px;
transform-origin: center bottom;
}
.photo-stack-gallery__stack-position[data-returning="true"] {
z-index: 92;
pointer-events: none;
}
.photo-stack-gallery__stack-position[data-parked="true"] {
pointer-events: none;
}
.photo-stack-gallery__stack {
appearance: none;
position: relative;
display: block;
width: 100%;
height: 100%;
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
transform-origin: center bottom;
}
/* The compact stack is portalled away from its inline trigger. Extend its
pointer hit area through the placement gap so moving toward the photos never
crosses page-owned pixels and accidentally starts the close timer. */
.photo-stack-gallery__stack::after {
content: "";
position: absolute;
right: 0;
bottom: -12px;
left: 0;
height: 12px;
}
.photo-stack-gallery__stack:focus-visible {
outline: 2px solid color-mix(in srgb, var(--foreground) 32%, transparent);
outline-offset: 6px;
border-radius: 24px;
}
.photo-stack-gallery__stack__slot {
position: absolute;
inset: 28px auto auto 38px;
display: block;
width: 74px;
aspect-ratio: 3 / 4;
}
.photo-stack-gallery__stack__slot[data-concealed="true"] {
z-index: 0;
opacity: 0;
pointer-events: none;
}
.photo-stack-gallery__stack__card {
position: relative;
display: block;
width: 100%;
height: 100%;
overflow: clip;
border-radius: 12px;
background: var(--hover-bg, rgba(127, 127, 127, 0.12));
box-shadow: var(--elevated-media-shadow, 0 8px 24px rgba(0, 0, 0, 0.16));
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.photo-stack-gallery__stack__card::after {
content: "";
position: absolute;
inset: 0;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: inherit;
pointer-events: none;
}
.photo-stack-gallery__stack__card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
}
.photo-stack-gallery__stack__slot:nth-child(1) {
z-index: 1;
}
.photo-stack-gallery__stack__slot:nth-child(1) .photo-stack-gallery__stack__card {
transform: translate(-19px, 1px) rotate(-9deg);
}
.photo-stack-gallery__stack__slot:nth-child(2) {
z-index: 3;
}
.photo-stack-gallery__stack__slot:nth-child(2) .photo-stack-gallery__stack__card {
transform: translateY(8px) rotate(1deg);
}
.photo-stack-gallery__stack__slot:nth-child(3) {
z-index: 2;
}
.photo-stack-gallery__stack__slot:nth-child(3) .photo-stack-gallery__stack__card {
transform: translate(20px, 2px) rotate(9deg);
}
.photo-stack-gallery__stack[data-fanned="true"]
.photo-stack-gallery__stack__slot:nth-child(1)
.photo-stack-gallery__stack__card {
transform: translate(-29px, -4px) rotate(-12deg);
}
.photo-stack-gallery__stack[data-fanned="true"]
.photo-stack-gallery__stack__slot:nth-child(2)
.photo-stack-gallery__stack__card {
transform: translateY(2px) rotate(0deg);
}
.photo-stack-gallery__stack[data-fanned="true"]
.photo-stack-gallery__stack__slot:nth-child(3)
.photo-stack-gallery__stack__card {
transform: translate(30px, -3px) rotate(12deg);
}
.photo-stack-gallery__gallery {
position: fixed;
z-index: 90;
inset: 0;
overflow: clip;
background: transparent;
color: var(--foreground, #111);
isolation: isolate;
}
.photo-stack-gallery__dialog {
position: fixed;
inset: 0;
}
.photo-stack-gallery__gallery__surface {
position: fixed;
z-index: 89;
inset: 0;
background: var(--background, #fff);
}
.photo-stack-gallery__detail {
position: fixed;
z-index: 91;
inset: 0;
overflow: clip;
color: var(--foreground);
pointer-events: none;
}
.photo-stack-gallery__detail__media,
.photo-stack-gallery__detail__meta {
pointer-events: auto;
}
.photo-stack-gallery__gallery__stage {
--gallery-card-width: clamp(104px, 31vw, 154px);
position: absolute;
top: max(24px, env(safe-area-inset-top));
right: 20px;
bottom: calc(32px + var(--menu-bar-height, 64px) + 28px);
left: 20px;
max-width: 520px;
margin-inline: auto;
}
.photo-stack-gallery__gallery__slot {
appearance: none;
position: absolute;
display: block;
width: var(--gallery-card-width);
aspect-ratio: 3 / 4;
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.photo-stack-gallery__gallery__card,
.photo-stack-gallery__detail__media {
position: relative;
display: block;
width: 100%;
height: 100%;
overflow: clip;
border-radius: var(--radius-4xl, 32px);
background: var(--hover-bg, rgba(127, 127, 127, 0.12));
box-shadow: var(--elevated-media-shadow, 0 8px 24px rgba(0, 0, 0, 0.16));
}
.photo-stack-gallery__gallery__card {
transform: rotate(var(--card-rotation));
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.photo-stack-gallery__gallery__slot[data-selected="true"] {
z-index: 2;
}
.photo-stack-gallery__gallery__card::after,
.photo-stack-gallery__detail__media::after {
content: "";
position: absolute;
z-index: 2;
inset: 0;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: inherit;
pointer-events: none;
}
.photo-stack-gallery__gallery__card img,
.photo-stack-gallery__detail__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
}
.photo-stack-gallery__detail__preview-image,
.photo-stack-gallery__detail__full-image {
position: absolute;
inset: 0;
}
.photo-stack-gallery__detail__full-image {
z-index: 1;
}
.photo-stack-gallery__gallery__slot:nth-child(1) {
top: 1%;
left: 4%;
}
.photo-stack-gallery__gallery__slot:nth-child(2) {
top: 6%;
right: 3%;
}
.photo-stack-gallery__gallery__slot:nth-child(3) {
top: 35%;
left: 1%;
}
.photo-stack-gallery__gallery__slot:nth-child(4) {
top: 40%;
right: 4%;
}
.photo-stack-gallery__gallery__slot:nth-child(5) {
bottom: 1%;
left: 4%;
}
.photo-stack-gallery__gallery__slot:nth-child(6) {
right: 3%;
bottom: 0;
}
.photo-stack-gallery__gallery__slot:focus-visible {
outline: 2px solid color-mix(in srgb, var(--foreground) 56%, transparent);
outline-offset: 7px;
border-radius: var(--radius-4xl, 32px);
}
.photo-stack-gallery__gallery__close {
appearance: none;
display: grid;
width: 100%;
height: 100%;
padding: 0;
place-items: center;
border: 0;
border-radius: var(--menu-shell-radius, 999px);
background: transparent;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* The close control is portaled into a host-owned navigation surface. Marking
that surface keeps the press response identical across site and Playground
integrations without coupling this reusable component to either menu ID. */
[data-photo-gallery-navigation-host]:has(.photo-stack-gallery__gallery__close) {
transform-origin: center;
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-photo-gallery-navigation-host]:has(.photo-stack-gallery__gallery__close:active) {
transform: scale(0.9);
transition-duration: 100ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
[data-photo-gallery-navigation-host]:has(.photo-stack-gallery__gallery__close) {
transition: none;
transform: none;
}
}
.photo-stack-gallery__gallery__close:disabled {
pointer-events: none;
}
.photo-stack-gallery__gallery__close svg {
width: var(--menu-icon-size, 20px);
height: var(--menu-icon-size, 20px);
}
.photo-stack-gallery__gallery__close path {
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
}
.photo-stack-gallery__gallery__close:focus-visible {
outline: 2px solid color-mix(in srgb, var(--foreground) 42%, transparent);
outline-offset: 4px;
}
.photo-stack-gallery__navigation-fallback {
position: fixed;
z-index: 100;
right: 50%;
bottom: max(24px, env(safe-area-inset-bottom));
width: 56px;
height: 56px;
translate: 50% 0;
border-radius: 999px;
background: var(--menu-surface, #111);
color: var(--menu-ink, #fff);
box-shadow: var(--elevated-media-shadow, 0 8px 24px rgba(0, 0, 0, 0.16));
}
.photo-stack-gallery__detail__content {
--photo-stack-gallery__detail-frame-width: min(84vw, calc(62dvh * 0.75), 500px);
position: absolute;
inset: max(20px, env(safe-area-inset-top)) 20px
calc(32px + var(--menu-bar-height, 64px) + 24px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 22px;
}
.photo-stack-gallery__detail__media {
width: var(--photo-stack-gallery__detail-frame-width);
height: auto;
aspect-ratio: 3 / 4;
border-radius: var(--radius-5xl, 48px);
}
.photo-stack-gallery__detail__meta {
display: flex;
box-sizing: border-box;
width: var(--photo-stack-gallery__detail-frame-width);
padding-inline: 40px;
flex-direction: column;
align-items: center;
}
.photo-stack-gallery__detail__copy {
width: 100%;
text-align: center;
}
.photo-stack-gallery__detail__copy p {
margin: 0;
}
.photo-stack-gallery__detail__caption {
margin: 0;
color: color-mix(in srgb, var(--foreground) 62%, transparent);
font-size: 0.875rem;
font-weight: 400;
line-height: 1.4;
}
.photo-stack-gallery__detail__link {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--subtle);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
transition: text-decoration-color 200ms ease;
}
.photo-stack-gallery__detail__link:hover {
text-decoration-color: var(--muted);
}
@media (hover: hover) {
.photo-stack-gallery__gallery__slot:hover .photo-stack-gallery__gallery__card {
transform: translateY(-5px) rotate(var(--card-rotation)) scale(1.025);
}
.photo-stack-gallery__gallery__close:hover {
background: color-mix(in srgb, var(--menu-surface) 94%, var(--menu-ink));
}
}
@media (min-width: 700px) {
.photo-stack-gallery__gallery__stage {
--gallery-card-width: clamp(136px, 14vw, 208px);
top: max(40px, env(safe-area-inset-top));
right: 48px;
bottom: calc(32px + var(--menu-bar-height) + 40px);
left: 48px;
max-width: 1120px;
}
.photo-stack-gallery__gallery__slot:nth-child(1) {
top: 6%;
left: 3%;
}
.photo-stack-gallery__gallery__slot:nth-child(2) {
top: 1%;
right: auto;
left: calc(50% - var(--gallery-card-width) / 2);
}
.photo-stack-gallery__gallery__slot:nth-child(3) {
top: 8%;
right: 3%;
left: auto;
}
.photo-stack-gallery__gallery__slot:nth-child(4) {
top: 52%;
right: auto;
left: 17%;
}
.photo-stack-gallery__gallery__slot:nth-child(5) {
top: 58%;
right: auto;
bottom: auto;
left: calc(50% - var(--gallery-card-width) / 2);
}
.photo-stack-gallery__gallery__slot:nth-child(6) {
top: 50%;
right: 17%;
bottom: auto;
}
.photo-stack-gallery__detail__content {
--photo-stack-gallery__detail-frame-width: min(44vw, calc(64dvh * 0.75), 500px);
inset-inline: 48px;
gap: 24px;
}
}
@media (max-height: 640px) {
.photo-stack-gallery__detail__content {
--photo-stack-gallery__detail-frame-width: min(
84vw,
calc((100dvh - 260px) * 0.75),
500px
);
gap: 14px;
}
.photo-stack-gallery__detail__meta {
width: min(100%, 360px);
padding-inline: 24px;
}
}
@media (max-height: 640px) and (orientation: landscape) {
.photo-stack-gallery__detail__content {
--photo-stack-gallery__detail-frame-width: min(
44vw,
calc((100dvh - 240px) * 0.75),
500px
);
}
}
@media (prefers-reduced-motion: reduce) {
.photo-stack-gallery__stack__card,
.photo-stack-gallery__gallery__card {
transition: none;
}
}
function invariant(condition, message) {
if (!condition) throw new Error(`PhotoGallery: ${message}`);
}
function isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}
export function createPhotoGalleryModel(photos, previewIds) {
invariant(Array.isArray(photos) && photos.length === 6, "provide exactly six photos.");
invariant(
Array.isArray(previewIds) &&
previewIds.length === 3 &&
new Set(previewIds).size === 3,
"provide exactly three unique preview IDs."
);
const photoIds = photos.map(({ id }) => id);
invariant(photoIds.every(isNonEmptyString), "every photo needs a non-empty ID.");
invariant(new Set(photoIds).size === photos.length, "provide unique photo IDs.");
for (const photo of photos) {
invariant(isNonEmptyString(photo.previewSrc), `photo “${photo.id}” needs a preview source.`);
invariant(isNonEmptyString(photo.src), `photo “${photo.id}” needs a full image source.`);
invariant(isNonEmptyString(photo.alt), `photo “${photo.id}” needs non-empty alt text.`);
invariant(photo.caption !== undefined && photo.caption !== null, `photo “${photo.id}” needs a caption.`);
}
const byId = new Map(photos.map((photo) => [photo.id, photo]));
for (const previewId of previewIds) {
invariant(byId.has(previewId), `missing preview photo “${previewId}”.`);
}
const previewPhotos = previewIds.map((id) => byId.get(id));
const previewIdSet = new Set(previewIds);
const galleryPhotos = [
...previewPhotos,
...photos.filter(({ id }) => !previewIdSet.has(id)),
];
return {
photos,
previewPhotos,
galleryPhotos,
photoIds: new Set(photoIds),
};
}
export function hasMeaningfulScroll(origin, current, threshold = 8) {
return (
Math.abs(current.x - origin.x) >= threshold ||
Math.abs(current.y - origin.y) >= threshold
);
}
export function reducePhotoGalleryState(state, event, validPhotoIds) {
switch (event.type) {
case "HOVER_START":
return state.view === "rest" ? { view: "preview", pinned: false } : state;
case "HOVER_END":
return state.view === "preview" && !state.pinned ? { view: "rest" } : state;
case "TRIGGER_ACTIVATE":
if (state.view === "rest") return { view: "preview", pinned: true };
if (state.view === "preview") {
return state.pinned ? { view: "rest" } : { view: "preview", pinned: true };
}
return state;
case "STACK_ACTIVATE":
return state.view === "preview" ? { view: "gallery" } : state;
case "SELECT_PHOTO":
return (state.view === "gallery" || state.view === "gallery-return") &&
validPhotoIds.has(event.photoId)
? { view: "detail", photoId: event.photoId }
: state;
case "BACK":
case "CLOSE":
if (state.view === "detail") {
return { view: "gallery-return", photoId: state.photoId };
}
if (state.view === "gallery" || state.view === "gallery-return") {
return { view: "stack-return" };
}
return state.view === "preview" ? { view: "rest" } : state;
case "GALLERY_RETURN_COMPLETE":
return state.view === "gallery-return" ? { view: "gallery" } : state;
case "STACK_RETURN_COMPLETE":
return state.view === "stack-return" ? { view: "rest" } : state;
default:
return state;
}
}
import { useCallback, useEffect, useState } from "react";
import {
PhotoGallery,
type PhotoGalleryNavigationUpdate,
type PhotoGalleryPhoto,
} from "../components/photo-gallery";
const photos: readonly PhotoGalleryPhoto[] = [
{
id: "ocean",
label: "Ocean",
previewSrc: "/playground/photo-stack-gallery/preview/ocean.webp",
src: "/playground/photo-stack-gallery/full/ocean.webp",
alt: "Sunlight catching the surface of restless ocean water",
caption: "Sunlight breaking across restless water.",
},
{
id: "trees",
label: "Trees",
previewSrc: "/playground/photo-stack-gallery/preview/trees.webp",
src: "/playground/photo-stack-gallery/full/trees.webp",
alt: "Green tree canopy framing a clear blue summer sky",
caption: "A clear summer sky, framed by the canopy.",
},
{
id: "building",
label: "Building",
previewSrc: "/playground/photo-stack-gallery/preview/building.webp",
src: "/playground/photo-stack-gallery/full/building.webp",
alt: "Classical stone columns beneath a dramatic clouded sky",
caption: "Old columns reaching into a stormy sky.",
focalPoint: { x: 58, y: 50 },
},
{
id: "beach",
label: "Beach",
previewSrc: "/playground/photo-stack-gallery/preview/beach.webp",
src: "/playground/photo-stack-gallery/full/beach.webp",
alt: "Muted evening light over a rocky ocean shoreline",
caption: "Soft light settling over a rocky shoreline.",
},
{
id: "vineyard",
label: "Vineyard",
previewSrc: "/playground/photo-stack-gallery/preview/vineyard.webp",
src: "/playground/photo-stack-gallery/full/vineyard.webp",
alt: "Rows of bare orchard trees disappearing into dense fog",
caption: "Rows of bare trees fading into the morning fog.",
focalPoint: { x: 54, y: 50 },
},
{
id: "swamp",
label: "Wetland",
previewSrc: "/playground/photo-stack-gallery/preview/swamp.webp",
src: "/playground/photo-stack-gallery/full/swamp.webp",
alt: "Still water winding between bare trees at the edge of a wood",
caption: "Still water winding through the edge of the woods.",
},
];
const previewIds = ["trees", "building", "ocean"] as const;
function PhotoGalleryCatalogPreview({ active }: { active: boolean }) {
const previewPhotos = previewIds.map((id) => {
const photo = photos.find((candidate) => candidate.id === id);
if (!photo) throw new Error(`Missing catalog preview photo “${id}”.`);
return photo;
});
return (
<div className="photo-stack-gallery__catalog" data-photo-stack-thumbnail>
<span className="photo-stack-gallery__catalog__stack">
<span
className="photo-stack-gallery__stack"
data-fanned={active}
aria-hidden="true"
>
{previewPhotos.map((photo) => (
<span className="photo-stack-gallery__stack__slot" key={photo.id}>
<span
className="photo-stack-gallery__stack__card"
data-photo-stack-thumbnail-card
>
<img src={photo.previewSrc} alt="" draggable="false" />
</span>
</span>
))}
</span>
</span>
<span
className="inline-trigger photo-stack-gallery__catalog__label"
data-decoration="wave"
>
Hover me
</span>
</div>
);
}
export default function PhotoGalleryDemo({
preview = false,
previewActive = false,
}: {
preview?: boolean;
previewActive?: boolean;
}) {
const [navigationTarget, setNavigationTarget] = useState<HTMLElement | null>(null);
const [pageContentTarget, setPageContentTarget] = useState<HTMLElement | null>(null);
useEffect(() => {
setNavigationTarget(document.getElementById("playground-gallery-layer"));
setPageContentTarget(document.getElementById("panel-preview"));
}, []);
const handleNavigationChange = useCallback(
(detail: PhotoGalleryNavigationUpdate) => {
document.dispatchEvent(
new CustomEvent("playground-menu:set-mode", { detail })
);
},
[]
);
if (preview) {
return <PhotoGalleryCatalogPreview active={previewActive} />;
}
return (
<div className="photo-stack-gallery-demo">
<PhotoGallery
photos={photos}
previewIds={previewIds}
triggerLabel="Hover me"
dialogLabel="Photo gallery"
navigationTarget={navigationTarget}
pageContentTarget={pageContentTarget}
onNavigationChange={handleNavigationChange}
labels={{
open: "Open photo gallery",
close: "Close photo gallery",
back: "Back to photo gallery",
}}
/>
</div>
);
}
# Photo gallery
A six-photo React gallery that starts as an inline trigger, reveals a three-card preview stack, and expands into a full-screen gallery with individual detail views.
## Install
```sh
npm install motion react react-dom
```
Copy this folder into your project and import the component from its barrel:
```tsx
import {
PhotoGallery,
type PhotoGalleryPhoto,
} from "./photo-gallery";
```
The component imports `PhotoGallery.css` itself. Place the component inside a React client boundary when using a framework that renders components on the server.
## Data contract
The gallery currently requires exactly six photos and exactly three preview IDs. Each preview ID must match one of the six photo IDs.
```tsx
const photos: readonly PhotoGalleryPhoto[] = [
{
id: "ocean",
previewSrc: "/images/ocean-preview.webp",
src: "/images/ocean.webp",
alt: "Sunlight catching the ocean surface",
caption: "Sunlight breaking across restless water.",
focalPoint: { x: 50, y: 50 },
},
// Five more photos…
];
const previewIds = ["trees", "building", "ocean"] as const;
```
Use lightweight preview images for the stack and gallery, then higher-quality images for the detail view. The included demo generates previews at 480 × 640 and full images at 960 × 1280. Keep the same crop and aspect ratio across both versions so the shared-layout transition does not jump.
## Basic usage
```tsx
<PhotoGallery
photos={photos}
previewIds={previewIds}
triggerLabel="Click me"
dialogLabel="Photo gallery"
/>
```
`triggerLabel` is the visible control. `dialogLabel` names the full-screen dialog for assistive technology. Optional `labels` customize the open, close, and back accessible labels.
## Integrating host navigation
The component can coordinate with an existing page shell without owning it:
- `navigationTarget` is the host element that should remain interactive while the gallery is open, such as a menu layer that morphs into the close control.
- `pageContentTarget` is the page region made inert while the dialog is active.
- `onNavigationChange` reports `default` or `gallery` mode and the opening, closing, or settled phase so the host can animate its own navigation.
Both targets are optional. If supplied, pass stable DOM elements and keep them mounted for the duration of the transition.
## Behavior and accessibility
- Hover or keyboard focus reveals the compact stack temporarily.
- Activating the trigger pins the stack; activating the stack opens the gallery.
- Each gallery photo opens a detail view. The contextual control returns to the gallery, then closes it.
- Escape follows the same progressive path. Focus returns to the trigger after the gallery fully closes.
- The dialog locks page scrolling and makes the configured page content inert while it is active.
- Keyboard users can reach the trigger, stack, every gallery card, and the contextual control.
- Reduced motion is respected through `useReducedMotion`; spatial choreography is skipped when the user requests reduced motion.
## Styling
The CSS uses inherited font settings and the host's `--foreground`, `--background`, `--hover-bg`, `--elevated-media-shadow`, and menu geometry tokens, with fallbacks where appropriate. Adapt those tokens or the namespaced selectors in `PhotoGallery.css`; keep preview and detail geometry aligned if you change card sizes or radii because the layout animation interpolates between them.
The component is intentionally strict about photo count and preview count. If you need a different gallery size, update the model validation, gallery layout, rotations, and transition tests together rather than bypassing the constraints.