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