"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export interface ExpandableProjectCardProps extends Omit<React.ComponentPropsWithoutRef<"div">, "title"> {
title: string;
category: string;
year: string | number;
description: string;
/** Image URL (or data URI). Falls back to an accent gradient when omitted. */
image?: string;
/** Alternative text for the image. Leave empty when the image is purely decorative. */
imageAlt?: string;
tags?: string[];
/** Optional project link shown in the expanded view. */
href?: string;
linkLabel?: string;
/** Accent for the placeholder, link and focus ring. */
accent?: string;
/** Spring used for the shared-layout expansion. */
spring?: { stiffness: number; damping: number; mass: number };
/** Corner radius in px. */
rounded?: number;
/** Image aspect ratio (shared by card and dialog so the image never distorts). */
aspect?: "16/10" | "4/3" | "1/1";
/** Blur the page behind the dialog. */
backdropBlur?: boolean;
/** Accessible name of the close button. */
closeLabel?: string;
onOpenChange?: (open: boolean) => void;
}
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** SSR-safe media query (false on the server and on first hydration). */
function useMediaQuery(query: string) {
return React.useSyncExternalStore(
(onChange) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
() => window.matchMedia(query).matches,
() => false,
);
}
function Media({ image, alt, zoom }: { image?: string; alt: string; zoom?: boolean }) {
const zoomClass = zoom
? "transition-transform duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.04] motion-reduce:transition-none motion-reduce:group-hover:scale-100"
: "";
if (!image) {
return (
<div
aria-hidden="true"
className={`absolute inset-0 ${zoomClass}`}
style={{
background:
"radial-gradient(120% 90% at 85% 10%, color-mix(in oklab, var(--epc-accent) 85%, white) 0%, transparent 55%), linear-gradient(160deg, #1b1f23, #0b0d0f)",
}}
/>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component
<img src={image} alt={alt} draggable={false} className={`absolute inset-0 size-full object-cover ${zoomClass}`} />
);
}
/**
* A project card that expands into a modal dialog through a shared-layout animation.
* The dialog is a native modal <dialog> (top layer, background inert) with Escape to close,
* a focus trap, body scroll lock and focus returned to the card.
*/
export function ExpandableProjectCard({
title,
category,
year,
description,
image,
imageAlt = "",
tags = [],
href,
linkLabel = "View project",
accent = "#FF7448",
spring = { stiffness: 300, damping: 32, mass: 0.9 },
rounded = 18,
aspect = "16/10",
backdropBlur = true,
closeLabel = "Close",
onOpenChange,
className = "",
style,
...props
}: ExpandableProjectCardProps) {
const reduceMotion = useReducedMotion();
const isSmall = useMediaQuery("(max-width: 639px)");
const [open, setOpen] = React.useState(false);
// Scrolling is enabled only once the morph has landed: transformed children would otherwise
// inflate the scroll area and flash scrollbars mid-animation.
const [settled, setSettled] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const dialogRef = React.useRef<HTMLDialogElement | null>(null);
const uid = React.useId();
const ids = {
card: `${uid}-card`,
media: `${uid}-media`,
title: `${uid}-title`,
meta: `${uid}-meta`,
heading: `${uid}-heading`,
description: `${uid}-description`,
};
const layoutTransition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
const fadeTransition = reduceMotion ? { duration: 0 } : { duration: 0.22 };
const aspectRatio = aspect.replace("/", " / ");
const accentVar = { ["--epc-accent" as string]: accent };
const setOpenState = (next: boolean) => {
setOpen(next);
setSettled(false);
onOpenChange?.(next);
};
const close = () => {
if (open) setOpenState(false);
};
// Body scroll lock while open, compensating for the scrollbar so the page does not shift.
React.useEffect(() => {
if (!open) return;
const html = document.documentElement;
const body = document.body;
const scrollbar = window.innerWidth - html.clientWidth;
const prev = { overflow: html.style.overflow, paddingRight: body.style.paddingRight };
html.style.overflow = "hidden";
if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`;
return () => {
html.style.overflow = prev.overflow;
body.style.paddingRight = prev.paddingRight;
};
}, [open]);
/** Opens the native dialog as a modal once it is in the DOM, then focuses its first control. */
const mountDialog = (dialog: HTMLDialogElement | null) => {
dialogRef.current = dialog;
if (!dialog || dialog.open) return;
dialog.showModal();
dialog.querySelector<HTMLElement>("[data-autofocus]")?.focus({ preventScroll: true });
};
const handleDialogKeyDown = (event: React.KeyboardEvent<HTMLDialogElement>) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
// Keep Tab cycling inside the dialog instead of escaping to the browser chrome.
const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(FOCUSABLE));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const panelRadius = isSmall
? { borderTopLeftRadius: rounded, borderTopRightRadius: rounded, borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }
: { borderRadius: rounded };
return (
<div className={`relative ${className}`} style={{ ...accentVar, ...style }} {...props}>
<motion.button
ref={triggerRef}
type="button"
layoutId={ids.card}
transition={layoutTransition}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpenState(true)}
className="group relative block w-full overflow-hidden border border-zinc-900/10 bg-white text-left text-zinc-900 transition-[border-color] duration-200 hover:border-zinc-900/25 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--epc-accent)] dark:border-white/10 dark:bg-[#15181b] dark:text-zinc-100 dark:hover:border-white/25"
style={{ borderRadius: rounded }}
>
<motion.div layoutId={ids.media} transition={layoutTransition} className="relative w-full overflow-hidden" style={{ aspectRatio }}>
<Media image={image} alt={imageAlt} zoom />
</motion.div>
<div className="flex items-end justify-between gap-4 p-4">
<div className="min-w-0">
<motion.p
layoutId={ids.meta}
layout="position"
transition={layoutTransition}
className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400"
>
{category} · {year}
</motion.p>
<motion.h3 layoutId={ids.title} layout="position" transition={layoutTransition} className="mt-1 truncate text-xl font-medium tracking-tight">
{title}
</motion.h3>
</div>
<motion.span
layout="position"
aria-hidden="true"
className="grid size-8 shrink-0 place-items-center rounded-full border border-zinc-900/10 transition-colors group-hover:border-transparent group-hover:bg-[var(--epc-accent)] group-hover:text-zinc-950 dark:border-white/15"
>
<svg viewBox="0 0 16 16" fill="none" className="size-3.5">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</motion.span>
</div>
<span className="sr-only">— open project details</span>
</motion.button>
<AnimatePresence
onExitComplete={() => {
// Leave the top layer first: while the dialog is modal the rest of the page is inert.
if (dialogRef.current?.open) dialogRef.current.close();
triggerRef.current?.focus({ preventScroll: true });
}}
>
{open && (
<dialog
ref={mountDialog}
aria-modal="true"
aria-labelledby={ids.heading}
aria-describedby={ids.description}
onKeyDown={handleDialogKeyDown}
onCancel={(event) => {
// Escape: animate out instead of letting the browser close the dialog abruptly.
event.preventDefault();
close();
}}
onClose={close}
className="fixed inset-0 m-0 h-full max-h-none w-full max-w-none overflow-hidden border-0 bg-transparent p-0 text-zinc-900 backdrop:bg-transparent dark:text-zinc-100"
style={accentVar}
>
<motion.div
aria-hidden="true"
className={`absolute inset-0 bg-zinc-950/45 dark:bg-black/60 ${backdropBlur ? "backdrop-blur-[6px]" : ""}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeTransition}
onClick={close}
/>
<div className="pointer-events-none absolute inset-0 flex items-end justify-center sm:items-center sm:p-6">
<motion.div
layoutId={ids.card}
transition={layoutTransition}
onLayoutAnimationComplete={() => {
if (open) setSettled(true);
}}
className="pointer-events-auto relative flex h-[calc(100dvh-12px)] w-full flex-col overflow-hidden bg-white shadow-[0_40px_80px_-30px_rgb(0_0_0/0.5)] dark:bg-[#15181b] sm:h-auto sm:max-h-[min(88dvh,820px)] sm:max-w-2xl sm:border sm:border-zinc-900/10 sm:dark:border-white/10"
style={panelRadius}
>
<motion.button
type="button"
data-autofocus
layout="position"
aria-label={closeLabel}
onClick={close}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1, transition: reduceMotion ? { duration: 0 } : { delay: 0.15, duration: 0.2 } }}
exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.1 } }}
className="absolute right-3 top-3 z-10 grid size-9 place-items-center rounded-full bg-zinc-950/70 text-white backdrop-blur-sm transition-colors hover:bg-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--epc-accent)]"
>
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className="size-3.5">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</motion.button>
<motion.div
layoutScroll
className={`min-h-0 flex-1 overscroll-contain ${settled || reduceMotion ? "overflow-y-auto" : "overflow-hidden"}`}
>
<motion.div layoutId={ids.media} transition={layoutTransition} className="relative w-full overflow-hidden" style={{ aspectRatio }}>
<Media image={image} alt={imageAlt} />
</motion.div>
<div className="p-6 sm:p-8">
<motion.p
layoutId={ids.meta}
layout="position"
transition={layoutTransition}
className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400"
>
{category} · {year}
</motion.p>
<motion.h2
id={ids.heading}
layoutId={ids.title}
layout="position"
transition={layoutTransition}
className="mt-1 text-xl font-medium tracking-tight"
>
{title}
</motion.h2>
<motion.div
layout="position"
initial={{ opacity: 0, y: reduceMotion ? 0 : 10 }}
animate={{ opacity: 1, y: 0, transition: reduceMotion ? { duration: 0 } : { delay: 0.12, duration: 0.35, ease: [0.22, 1, 0.36, 1] } }}
exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.1 } }}
>
<p id={ids.description} className="mt-4 max-w-prose text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-300">
{description}
</p>
{tags.length > 0 && (
<ul aria-label="Tags" className="mt-6 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<li
key={tag}
className="rounded-full border border-zinc-900/10 px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-600 dark:border-white/10 dark:text-zinc-300"
>
{tag}
</li>
))}
</ul>
)}
{href && (
<a
href={href}
target="_blank"
rel="noreferrer"
className="mt-8 inline-flex h-10 items-center gap-2 rounded-full bg-[var(--epc-accent)] px-5 text-sm font-medium text-zinc-950 transition hover:brightness-110 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--epc-accent)]"
>
{linkLabel}
<svg aria-hidden="true" viewBox="0 0 16 16" fill="none" className="size-3.5">
<path d="M5 11l6-6M6 5h5v5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="sr-only">(opens in a new tab)</span>
</a>
)}
</motion.div>
</div>
</motion.div>
</motion.div>
</div>
</dialog>
)}
</AnimatePresence>
</div>
);
}