{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "project-reveal",
  "type": "registry:ui",
  "title": "Project Reveal",
  "description": "A compact project list that smoothly expands each row into an image-led detail card with configurable GSAP spring motion.",
  "dependencies": [
    "gsap",
    "@gsap/react",
    "@radix-ui/react-dialog",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/project-reveal.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { ArrowUpRight, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport interface ProjectRevealItem {\n  id: string;\n  title: string;\n  category: string;\n  year: string | number;\n  image: string;\n  imageAlt?: string;\n  imagePosition?: string;\n  description: string;\n  href?: string;\n  content?: React.ReactNode;\n}\n\nexport interface ProjectRevealProps {\n  items: ProjectRevealItem[];\n  stiffness?: number;\n  damping?: number;\n  mass?: number;\n  speed?: number;\n  closeSpeed?: number;\n  borderRadius?: number;\n  thumbnailSize?: number;\n  modalWidth?: number;\n  imageRatio?: number;\n  overlayOpacity?: number;\n  hoverDuration?: number;\n  hoverClassName?: string;\n  showYear?: boolean;\n  closeOnOutsideClick?: boolean;\n  actionLabel?: string;\n  ariaLabel?: string;\n  className?: string;\n  rowClassName?: string;\n  modalClassName?: string;\n  onOpenChange?: (item: ProjectRevealItem | null) => void;\n}\n\nconst bounded = (value: number, fallback: number, min: number, max: number) =>\n  Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;\n\nfunction createSpring(stiffness: number, damping: number, mass: number) {\n  const frequency = Math.sqrt(stiffness / mass);\n  const decay = damping / (2 * mass);\n  const discriminant = decay * decay - frequency * frequency;\n  const rate = decay - Math.sqrt(Math.max(0, discriminant));\n  const duration = Math.min(3, Math.max(0.25, 8 / rate));\n  const response = (time: number) => {\n    if (Math.abs(discriminant) < 0.0001) {\n      return 1 - Math.exp(-decay * time) * (1 + decay * time);\n    }\n    const omega = Math.sqrt(Math.abs(discriminant));\n    if (discriminant < 0) {\n      return (\n        1 -\n        Math.exp(-decay * time) *\n          (Math.cos(omega * time) + (decay / omega) * Math.sin(omega * time))\n      );\n    }\n    const slow = -decay + omega;\n    const fast = -decay - omega;\n    return (\n      1 -\n      (fast * Math.exp(slow * time) - slow * Math.exp(fast * time)) /\n        (fast - slow)\n    );\n  };\n  const end = response(duration);\n  return {\n    duration,\n    ease: (progress: number) => response(progress * duration) / end,\n  };\n}\n\ntype Selection = { item: ProjectRevealItem; source: HTMLButtonElement };\n\nfunction ProjectRevealPanel({\n  selection,\n  open,\n  onExitComplete,\n  overlayRef,\n  stiffness = 300,\n  damping = 32,\n  mass = 1,\n  speed = 1,\n  closeSpeed = 1.15,\n  borderRadius = 12,\n  modalWidth = 840,\n  imageRatio = 0.455,\n  showYear = true,\n  closeOnOutsideClick = true,\n  actionLabel = \"View project\",\n  modalClassName,\n}: ProjectRevealProps & {\n  selection: Selection;\n  open: boolean;\n  onExitComplete: () => void;\n  overlayRef: React.RefObject<HTMLDivElement | null>;\n}) {\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const imageRef = React.useRef<HTMLDivElement>(null);\n  const titleRef = React.useRef<HTMLHeadingElement>(null);\n  const categoryRef = React.useRef<HTMLSpanElement>(null);\n  const yearRef = React.useRef<HTMLSpanElement>(null);\n  const detailRef = React.useRef<HTMLDivElement>(null);\n  const closeRef = React.useRef<HTMLButtonElement>(null);\n  const timelineRef = React.useRef<gsap.core.Timeline | null>(null);\n  const initializedRef = React.useRef(false);\n  const reducedMotionRef = React.useRef(false);\n  const transitionRef = React.useRef<(() => void) | null>(null);\n  const { item, source } = selection;\n\n  useGSAP(\n    () => {\n      const media = gsap.matchMedia();\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          standard: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          reducedMotionRef.current = Boolean(context.conditions?.reduced);\n          transitionRef.current?.();\n        },\n      );\n      const resize = () => transitionRef.current?.();\n      window.addEventListener(\"resize\", resize);\n      window.visualViewport?.addEventListener(\"resize\", resize);\n      return () => {\n        media.revert();\n        window.removeEventListener(\"resize\", resize);\n        window.visualViewport?.removeEventListener(\"resize\", resize);\n        transitionRef.current = null;\n        timelineRef.current?.kill();\n        initializedRef.current = false;\n      };\n    },\n    { scope: panelRef },\n  );\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const transition = contextSafe!(() => {\n        const panel = panelRef.current;\n        const image = imageRef.current;\n        const title = titleRef.current;\n        const category = categoryRef.current;\n        const detail = detailRef.current;\n        const overlay = overlayRef.current;\n        if (!panel || !image || !title || !category || !detail || !overlay)\n          return;\n\n        const row = source.getBoundingClientRect();\n        const thumbnail = source\n          .querySelector(\"[data-project-image]\")!\n          .getBoundingClientRect();\n        const rowTitle = source.querySelector(\"[data-project-title]\")!;\n        const titleBounds = rowTitle.getBoundingClientRect();\n        const categoryBounds = source\n          .querySelector(\"[data-project-category]\")!\n          .getBoundingClientRect();\n        const yearBounds = source\n          .querySelector(\"[data-project-year]\")\n          ?.getBoundingClientRect();\n        const viewport = window.visualViewport;\n        const viewportWidth = viewport?.width ?? window.innerWidth;\n        const viewportHeight = viewport?.height ?? window.innerHeight;\n        const width = Math.min(\n          bounded(modalWidth, 840, 320, 1400),\n          viewportWidth - 32,\n        );\n        const stacked = width < 560;\n        const height = Math.min(\n          stacked ? 640 : width * 0.6,\n          viewportHeight - 48,\n        );\n        const padding = stacked ? 24 : 32;\n        const imageWidth = stacked\n          ? width\n          : width * bounded(imageRatio, 0.455, 0.3, 0.6);\n        const imageHeight = stacked\n          ? Math.min(width * 0.8, height * 0.46)\n          : height;\n        const contentX = stacked ? padding : imageWidth + padding;\n        const contentY = stacked ? imageHeight + padding : padding;\n        const contentWidth = width - contentX - padding;\n        const titleSize = stacked ? 28 : 36;\n        const rowTitleSize = parseFloat(getComputedStyle(rowTitle).fontSize);\n        const x = (viewport?.offsetLeft ?? 0) + (viewportWidth - width) / 2;\n        const y = (viewport?.offsetTop ?? 0) + (viewportHeight - height) / 2;\n        const spring = createSpring(\n          bounded(stiffness, 300, 80, 800),\n          bounded(damping, 32, 12, 80),\n          bounded(mass, 1, 0.25, 3),\n        );\n        const duration = reducedMotionRef.current\n          ? 0\n          : spring.duration /\n            (bounded(speed, 1, 0.25, 3) *\n              (open ? 1 : bounded(closeSpeed, 1.15, 0.5, 2)));\n        const local = (bounds: DOMRect) => ({\n          x: bounds.left - row.left,\n          y: bounds.top - row.top,\n        });\n        const rowYear =\n          yearBounds && yearRef.current\n            ? {\n                x: yearBounds.right - row.left - yearRef.current.offsetWidth,\n                y: yearBounds.top - row.top,\n              }\n            : null;\n\n        timelineRef.current?.kill();\n        gsap.set(title, { fontSize: titleSize });\n        if (!initializedRef.current) {\n          gsap.set(panel, {\n            x: row.left,\n            y: row.top,\n            width: row.width,\n            height: row.height,\n            opacity: 1,\n            borderRadius: 4,\n          });\n          gsap.set(image, {\n            ...local(thumbnail),\n            width: thumbnail.width,\n            height: thumbnail.height,\n            borderRadius: 2,\n          });\n          gsap.set(title, {\n            ...local(titleBounds),\n            scale: rowTitleSize / titleSize,\n          });\n          gsap.set(category, local(categoryBounds));\n          if (yearRef.current && rowYear) gsap.set(yearRef.current, rowYear);\n          gsap.set([detail, overlay], { autoAlpha: 0 });\n          gsap.set(closeRef.current, { opacity: 0 });\n          initializedRef.current = true;\n        }\n        const detailY = contentY + titleSize * 1.15 + 48;\n        gsap.set(detail, {\n          left: contentX,\n          top: detailY,\n          width: contentWidth,\n          height: Math.max(0, height - detailY - padding),\n        });\n        gsap.set(title, { maxWidth: open ? contentWidth - 8 : \"none\" });\n        gsap.set(panel, { opacity: 1, willChange: \"transform\" });\n        const timeline = gsap.timeline({\n          defaults: { duration, ease: spring.ease, overwrite: \"auto\" },\n          onComplete: contextSafe!(() => {\n            gsap.set(panel, { clearProps: \"willChange\" });\n            if (!open) onExitComplete();\n          }),\n        });\n        timeline\n          .to(\n            panel,\n            open\n              ? {\n                  x,\n                  y,\n                  width,\n                  height,\n                  borderRadius: bounded(borderRadius, 12, 0, 32),\n                }\n              : {\n                  x: row.left,\n                  y: row.top,\n                  width: row.width,\n                  height: row.height,\n                  borderRadius: 4,\n                },\n            0,\n          )\n          .to(\n            image,\n            open\n              ? {\n                  x: 0,\n                  y: 0,\n                  width: imageWidth,\n                  height: imageHeight,\n                  borderRadius: 0,\n                }\n              : {\n                  ...local(thumbnail),\n                  width: thumbnail.width,\n                  height: thumbnail.height,\n                  borderRadius: 2,\n                },\n            0,\n          )\n          .to(\n            title,\n            open\n              ? { x: contentX, y: contentY, scale: 1 }\n              : {\n                  ...local(titleBounds),\n                  scale: rowTitleSize / titleSize,\n                },\n            0,\n          )\n          .to(\n            category,\n            open\n              ? { x: contentX, y: contentY + titleSize * 1.15 + 10 }\n              : local(categoryBounds),\n            0,\n          )\n          .to(\n            overlay,\n            {\n              autoAlpha: open ? 1 : 0,\n              duration: duration * 0.7,\n              ease: \"power2.inOut\",\n            },\n            0,\n          )\n          .to(\n            detail,\n            {\n              autoAlpha: open ? 1 : 0,\n              y: open ? 0 : 8,\n              duration: duration * (open ? 0.45 : 0.2),\n              ease: \"power2.out\",\n            },\n            open ? duration * 0.22 : 0,\n          )\n          .to(\n            closeRef.current,\n            {\n              opacity: open ? 1 : 0,\n              duration: duration * 0.25,\n              ease: \"power2.out\",\n            },\n            open ? duration * 0.35 : 0,\n          );\n        if (yearRef.current && rowYear) {\n          timeline.to(\n            yearRef.current,\n            open\n              ? {\n                  x: contentX + category.offsetWidth + 16,\n                  y: contentY + titleSize * 1.15 + 10,\n                }\n              : rowYear,\n            0,\n          );\n        }\n        timelineRef.current = timeline;\n      });\n      transitionRef.current = transition;\n      transition();\n    },\n    {\n      scope: panelRef,\n      dependencies: [\n        open,\n        stiffness,\n        damping,\n        mass,\n        speed,\n        closeSpeed,\n        borderRadius,\n        modalWidth,\n        imageRatio,\n        showYear,\n        onExitComplete,\n        source,\n      ],\n    },\n  );\n\n  return (\n    <Dialog.Content\n      ref={panelRef}\n      onOpenAutoFocus={(event) => {\n        event.preventDefault();\n        closeRef.current?.focus({ preventScroll: true });\n      }}\n      onCloseAutoFocus={(event) => {\n        event.preventDefault();\n        source.focus({ preventScroll: true });\n      }}\n      onInteractOutside={(event) => {\n        if (!closeOnOutsideClick || !open) event.preventDefault();\n      }}\n      onEscapeKeyDown={(event) => {\n        if (!open) event.preventDefault();\n      }}\n      className={cn(\n        \"fixed left-0 top-0 z-50 overflow-hidden bg-white font-sans text-zinc-950 shadow-[0_24px_100px_-24px_rgba(0,0,0,0.3)] outline-none dark:bg-[#18181b] dark:text-zinc-50\",\n        modalClassName,\n      )}\n      style={{ opacity: 0 }}\n      data-project-panel\n      data-state={open ? \"opening\" : \"closing\"}\n    >\n      <div\n        ref={imageRef}\n        className=\"absolute left-0 top-0 overflow-hidden bg-zinc-200 dark:bg-zinc-800\"\n      >\n        <img\n          src={item.image}\n          alt={item.imageAlt ?? item.title}\n          draggable={false}\n          className=\"h-full w-full object-cover\"\n          style={{ objectPosition: item.imagePosition }}\n        />\n      </div>\n      <Dialog.Title\n        ref={titleRef}\n        className=\"absolute left-0 top-0 origin-top-left truncate font-normal leading-[1.15] tracking-[-0.045em]\"\n      >\n        {item.title}\n      </Dialog.Title>\n      <span\n        ref={categoryRef}\n        className=\"absolute left-0 top-0 text-[11px] leading-4 text-zinc-500 dark:text-zinc-400\"\n      >\n        {item.category}\n      </span>\n      {showYear && (\n        <span\n          ref={yearRef}\n          className=\"absolute left-0 top-0 text-[11px] leading-4 text-zinc-500 dark:text-zinc-400\"\n        >\n          {item.year}\n        </span>\n      )}\n      <div\n        ref={detailRef}\n        className=\"absolute flex flex-col justify-end-safe gap-5 overflow-y-auto overscroll-contain text-sm leading-relaxed\"\n      >\n        <Dialog.Description className=\"text-zinc-600 dark:text-zinc-300\">\n          {item.description}\n        </Dialog.Description>\n        {item.content}\n        {item.href && (\n          <a\n            href={item.href}\n            className=\"flex w-fit shrink-0 items-center gap-1 border-b border-current pb-0.5 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current focus-visible:ring-offset-4\"\n          >\n            {actionLabel}\n            <ArrowUpRight className=\"size-3\" aria-hidden=\"true\" />\n          </a>\n        )}\n      </div>\n      <Dialog.Close asChild>\n        <button\n          ref={closeRef}\n          type=\"button\"\n          disabled={!open}\n          aria-label=\"Close project\"\n          className=\"absolute right-3 top-3 flex size-9 cursor-pointer items-center justify-center rounded-full bg-zinc-100 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:hover:text-white\"\n        >\n          <X className=\"size-4\" aria-hidden=\"true\" />\n        </button>\n      </Dialog.Close>\n    </Dialog.Content>\n  );\n}\n\nexport function ProjectReveal({\n  items,\n  thumbnailSize = 36,\n  overlayOpacity = 0.4,\n  showYear = true,\n  hoverDuration = 0.35,\n  hoverClassName,\n  ariaLabel = \"Selected projects\",\n  className,\n  rowClassName,\n  onOpenChange,\n  ...panelProps\n}: ProjectRevealProps) {\n  const [selection, setSelection] = React.useState<Selection | null>(null);\n  const [open, setOpen] = React.useState(false);\n  const overlayRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const highlightRef = React.useRef<HTMLLIElement>(null);\n  const hoveredRowRef = React.useRef<HTMLButtonElement | null>(null);\n  const focusedRowRef = React.useRef<HTMLButtonElement | null>(null);\n  const syncHighlightRef = React.useRef<\n    ((row: HTMLButtonElement | null, immediate?: boolean) => void) | null\n  >(null);\n  const onExitComplete = React.useCallback(() => setSelection(null), []);\n  const size = bounded(thumbnailSize, 36, 24, 64);\n\n  useGSAP(\n    () => {\n      const list = listRef.current;\n      const highlight = highlightRef.current;\n      if (!list || !highlight) return;\n\n      const media = gsap.matchMedia();\n      media.add(\n        {\n          reduced: \"(prefers-reduced-motion: reduce)\",\n          standard: \"(prefers-reduced-motion: no-preference)\",\n        },\n        (context) => {\n          const spring = createSpring(450, 38, 0.9);\n          const sync = context.add(\n            \"syncHighlight\",\n            (row: HTMLButtonElement | null, immediate = false) => {\n              const duration =\n                context.conditions?.reduced || immediate\n                  ? 0\n                  : bounded(hoverDuration, 0.35, 0.1, 0.8);\n\n              if (\n                !row?.isConnected ||\n                getComputedStyle(row).visibility === \"hidden\"\n              ) {\n                gsap.to(highlight, {\n                  autoAlpha: 0,\n                  duration: duration * 0.55,\n                  ease: \"power2.out\",\n                  overwrite: \"auto\",\n                });\n                return;\n              }\n\n              const listBounds = list.getBoundingClientRect();\n              const rowBounds = row.getBoundingClientRect();\n              const geometry = {\n                x: rowBounds.left - listBounds.left,\n                y: rowBounds.top - listBounds.top,\n                width: rowBounds.width,\n                height: rowBounds.height,\n              };\n              if (Number(gsap.getProperty(highlight, \"opacity\")) === 0) {\n                gsap.set(highlight, geometry);\n              }\n              gsap.to(highlight, {\n                ...geometry,\n                autoAlpha: 1,\n                duration,\n                ease: spring.ease,\n                overwrite: \"auto\",\n              });\n            },\n          );\n\n          syncHighlightRef.current = (row, immediate) => sync(row, immediate);\n          const resize = () =>\n            sync(hoveredRowRef.current ?? focusedRowRef.current, true);\n          const observer = new ResizeObserver(resize);\n          observer.observe(list);\n          resize();\n          return () => {\n            observer.disconnect();\n            syncHighlightRef.current = null;\n          };\n        },\n        list,\n      );\n      return () => media.revert();\n    },\n    { scope: listRef, dependencies: [hoverDuration], revertOnUpdate: true },\n  );\n\n  return (\n    <Dialog.Root\n      open={selection !== null}\n      onOpenChange={(nextOpen) => {\n        if (!nextOpen && open) {\n          setOpen(false);\n          onOpenChange?.(null);\n        }\n      }}\n    >\n      <ul\n        ref={listRef}\n        aria-label={ariaLabel}\n        onPointerLeave={() => {\n          hoveredRowRef.current = null;\n          syncHighlightRef.current?.(focusedRowRef.current);\n        }}\n        className={cn(\"relative isolate w-full max-w-2xl font-sans\", className)}\n      >\n        <li\n          ref={highlightRef}\n          aria-hidden=\"true\"\n          data-project-highlight\n          className={cn(\n            \"pointer-events-none absolute left-0 top-0 z-0 rounded-md bg-zinc-950/[0.035] ring-1 ring-inset ring-zinc-950/2.5 dark:bg-white/7.5 dark:ring-white/[0.035]\",\n            hoverClassName,\n          )}\n          style={{ opacity: 0, visibility: \"hidden\" }}\n        />\n        {items.map((item) => (\n          <li\n            key={item.id}\n            className=\"relative z-10 border-b border-zinc-200/70 last:border-b-0 dark:border-zinc-800\"\n          >\n            <Dialog.Trigger asChild>\n              <button\n                type=\"button\"\n                aria-label={`Open ${item.title}`}\n                aria-expanded={selection?.item.id === item.id}\n                className={cn(\n                  \"group flex w-full min-w-0 cursor-pointer items-center gap-3 rounded-sm px-2 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400\",\n                  rowClassName,\n                )}\n                style={{\n                  visibility:\n                    selection?.item.id === item.id ? \"hidden\" : undefined,\n                }}\n                onPointerEnter={(event) => {\n                  if (event.pointerType === \"touch\") return;\n                  hoveredRowRef.current = event.currentTarget;\n                  syncHighlightRef.current?.(event.currentTarget);\n                }}\n                onFocus={(event) => {\n                  if (!event.currentTarget.matches(\":focus-visible\")) return;\n                  focusedRowRef.current = event.currentTarget;\n                  syncHighlightRef.current?.(event.currentTarget);\n                }}\n                onBlur={() => {\n                  focusedRowRef.current = null;\n                  syncHighlightRef.current?.(hoveredRowRef.current);\n                }}\n                onClick={(event) => {\n                  syncHighlightRef.current?.(null);\n                  setSelection({ item, source: event.currentTarget });\n                  setOpen(true);\n                  onOpenChange?.(item);\n                }}\n              >\n                <span\n                  data-project-image\n                  className=\"shrink-0 overflow-hidden rounded-xs bg-zinc-200 dark:bg-zinc-800\"\n                  style={{ width: size, height: size * 1.25 }}\n                >\n                  <img\n                    src={item.image}\n                    alt=\"\"\n                    width={size}\n                    height={size * 1.25}\n                    draggable={false}\n                    className=\"h-full w-full object-cover\"\n                    style={{ objectPosition: item.imagePosition }}\n                  />\n                </span>\n                <span\n                  data-project-title\n                  className=\"min-w-0 flex-1 truncate text-[13px] font-normal leading-[1.15] tracking-[-0.045em] text-zinc-950 dark:text-zinc-100\"\n                >\n                  {item.title}\n                </span>\n                <span\n                  data-project-category\n                  className=\"w-24 shrink-0 truncate text-[11px] leading-4 text-zinc-500 dark:text-zinc-400 sm:w-32\"\n                >\n                  {item.category}\n                </span>\n                {showYear && (\n                  <span\n                    data-project-year\n                    className=\"w-9 shrink-0 text-right text-[11px] leading-4 text-zinc-500 dark:text-zinc-400\"\n                  >\n                    {item.year}\n                  </span>\n                )}\n              </button>\n            </Dialog.Trigger>\n          </li>\n        ))}\n      </ul>\n      {selection && (\n        <Dialog.Portal>\n          <Dialog.Overlay\n            ref={overlayRef}\n            className=\"fixed inset-0 z-40\"\n            style={{\n              backgroundColor: `rgba(0,0,0,${bounded(overlayOpacity, 0.4, 0, 0.8)})`,\n            }}\n          />\n          <ProjectRevealPanel\n            {...panelProps}\n            items={items}\n            selection={selection}\n            open={open}\n            onExitComplete={onExitComplete}\n            overlayRef={overlayRef}\n            showYear={showYear}\n          />\n        </Dialog.Portal>\n      )}\n    </Dialog.Root>\n  );\n}\n\nexport default ProjectReveal;\n",
      "type": "registry:ui",
      "target": "@ui/project-reveal.tsx"
    }
  ]
}
