{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "slug": "project-reveal",
  "name": "Project Reveal",
  "title": "Project Reveal",
  "type": "registry:ui",
  "description": "A compact project list that smoothly expands each row into an image-led detail card with configurable GSAP spring motion.",
  "summary": "The thumbnail, title, and metadata travel from their list positions into a centered card. The description fades in as the card settles, and closing returns every element to the original row. Tune stiffness, damping, mass, speed, dimensions, and backdrop to fit your portfolio.",
  "category": "galleries-and-media",
  "tags": [
    "portfolio",
    "projects",
    "list",
    "dialog",
    "morph",
    "gsap",
    "spring",
    "expand"
  ],
  "dependencies": [
    "gsap",
    "@gsap/react",
    "@radix-ui/react-dialog",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "version": "1.0.0",
  "createdDate": "2026-10-02",
  "updatedDate": "2026-10-02",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Continuous thumbnail-to-card expansion and return to the original row",
    "Damped spring easing with configurable stiffness, damping, and mass",
    "Independent closing speed and interruptible motion",
    "Viewport-aware sizing with stacked detail cards on narrow screens",
    "Radix focus containment, Escape dismissal, and focus restoration",
    "Live motion presets and appearance controls in the component studio",
    "A shared spring-driven hover highlight glides between rows and follows keyboard focus"
  ],
  "anatomy": [
    "<ProjectReveal> semantic list and dialog state",
    "<Dialog.Trigger> accessible project rows",
    "<ProjectRevealPanel> portaled image, heading, metadata, and description",
    "<Dialog.Overlay> synchronized backdrop",
    "<Dialog.Close> accessible dismissal"
  ],
  "physics": {
    "engine": "GSAP + damped spring response",
    "description": "An analytical damped spring response drives the card geometry and shared elements on a scoped GSAP timeline. Description and backdrop use coordinated opacity tweens. Closing starts from the current geometry, including when opening is interrupted.",
    "parameters": [
      {
        "label": "Stiffness",
        "value": "300"
      },
      {
        "label": "Damping",
        "value": "32"
      },
      {
        "label": "Mass",
        "value": "1"
      },
      {
        "label": "Closing speed",
        "value": "1.15×"
      },
      {
        "label": "Reduced motion",
        "value": "Immediate state changes"
      }
    ]
  },
  "accessibility": {
    "role": "list / dialog",
    "aria": "Each project has a named button. The modal has a title, focus containment, and returns focus to the selected row after the exit animation.",
    "keyboard": [
      {
        "key": "Tab / Shift+Tab",
        "description": "Navigate project rows or controls within the active dialog."
      },
      {
        "key": "Enter / Space",
        "description": "Open a focused project or activate the close button."
      },
      {
        "key": "Escape",
        "description": "Close the active detail card."
      }
    ],
    "reducedMotion": "Respects prefers-reduced-motion, skips movement and fades, and retains all dialog interactions."
  },
  "guidelines": {
    "recommended": [
      "Portfolio project indexes and editorial work sections",
      "Image-led case studies with a short description"
    ],
    "bestPractices": [
      "Provide unique, stable project ids and useful imageAlt text.",
      "Use high-resolution portrait images for a sharp expansion.",
      "Pass your own items and href values; omit href when no project page exists.",
      "Start with the smooth preset and adjust damping before adding overshoot."
    ]
  },
  "props": [
    {
      "name": "items",
      "type": "ProjectRevealItem[]",
      "description": "Projects with a unique id, title, category, year, image, and description. Optional imageAlt, imagePosition, href, and content customize each detail card.",
      "required": true
    },
    {
      "name": "stiffness",
      "type": "number",
      "defaultValue": "300",
      "description": "Spring stiffness, clamped to 80–800. Higher values settle faster."
    },
    {
      "name": "damping",
      "type": "number",
      "defaultValue": "32",
      "description": "Spring damping, clamped to 12–80. Lower values add overshoot; higher values soften it."
    },
    {
      "name": "mass",
      "type": "number",
      "defaultValue": "1",
      "description": "Spring mass, clamped to 0.25–3. Higher values give the card more weight."
    },
    {
      "name": "speed",
      "type": "number",
      "defaultValue": "1",
      "description": "Global motion speed, clamped to 0.25–3. Applies to opening and closing."
    },
    {
      "name": "closeSpeed",
      "type": "number",
      "defaultValue": "1.15",
      "description": "Additional closing speed multiplier, clamped to 0.5–2."
    },
    {
      "name": "borderRadius",
      "type": "number",
      "defaultValue": "12",
      "description": "Expanded card corner radius in pixels, clamped to 0–32."
    },
    {
      "name": "thumbnailSize",
      "type": "number",
      "defaultValue": "36",
      "description": "Thumbnail width in pixels, clamped to 24–64. The portrait height is 1.25 times the width."
    },
    {
      "name": "modalWidth",
      "type": "number",
      "defaultValue": "840",
      "description": "Maximum card width in pixels, clamped to 320–1400 and constrained to the viewport."
    },
    {
      "name": "imageRatio",
      "type": "number",
      "defaultValue": "0.455",
      "description": "Image proportion of the desktop card width, clamped to 0.3–0.6. Narrow cards stack vertically."
    },
    {
      "name": "overlayOpacity",
      "type": "number",
      "defaultValue": "0.4",
      "description": "Backdrop opacity, clamped to 0–0.8."
    },
    {
      "name": "hoverDuration",
      "type": "number",
      "defaultValue": "0.35",
      "description": "Hover highlight travel duration in seconds, clamped to 0.1–0.8. The same highlight follows pointer and keyboard focus, with spring easing and a soft exit fade."
    },
    {
      "name": "hoverClassName",
      "type": "string",
      "description": "Additional classes for the shared row hover highlight."
    },
    {
      "name": "showYear",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Show the year in both the list and detail card."
    },
    {
      "name": "closeOnOutsideClick",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Allow dismissal by clicking outside the card. Escape and the close button remain available."
    },
    {
      "name": "actionLabel",
      "type": "string",
      "defaultValue": "\"View project\"",
      "description": "Label of the optional project link, displayed only when the selected item has an href."
    },
    {
      "name": "ariaLabel",
      "type": "string",
      "defaultValue": "\"Selected projects\"",
      "description": "Accessible label of the project list."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Additional project list classes."
    },
    {
      "name": "rowClassName",
      "type": "string",
      "description": "Additional classes for every project trigger."
    },
    {
      "name": "modalClassName",
      "type": "string",
      "description": "Additional classes for the portaled detail card."
    },
    {
      "name": "onOpenChange",
      "type": "(item: ProjectRevealItem | null) => void",
      "description": "Called with the project on opening and null when closing begins."
    }
  ],
  "usage": "import { ProjectReveal } from \"@/components/ui/project-reveal\";\n\nconst items = [\n  {\n    id: \"atlas\",\n    title: \"Atlas\",\n    category: \"Brand identity\",\n    year: 2026,\n    image: \"/images/atlas.jpg\",\n    description: \"Identity system for a mapping startup.\",\n  },\n];\n\nexport function ProjectRevealDemo() {\n  return <ProjectReveal items={items} />;\n}",
  "files": [
    {
      "name": "project-reveal.tsx",
      "path": "registry/ui/project-reveal.tsx",
      "code": "\"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",
      "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"
    }
  ]
}
