{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "project-hover-reveal",
  "type": "registry:block",
  "title": "Project Hover Reveal",
  "description": "A split selected-projects list: titles on the left, one stage on the right. Pointing at a project opens its image like an iris from the edge nearest that row, over the dimming last one, and rolls in its role, timeline, year and team line by line.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/project-hover-reveal.tsx",
      "type": "registry:component",
      "target": "components/tweenui/project-hover-reveal.tsx",
      "content": "'use client';\n\nimport {\n  useRef,\n  type ComponentPropsWithoutRef,\n  type FocusEvent,\n  type PointerEvent,\n  type ReactNode,\n} from 'react';\nimport { useGSAP } from '@gsap/react';\nimport gsap from 'gsap';\nimport { cn } from '@/lib/utils';\n\ngsap.registerPlugin(useGSAP);\n\nconst FULL = 'inset(0% 0% 0% 0%)';\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst isFocusVisible = (el: Element) => {\n  try {\n    return el.matches(':focus-visible');\n  } catch {\n    return true;\n  }\n};\n\nexport interface ProjectHoverRevealItem {\n  title: string;\n  role: string;\n  timeline: string;\n  year: string;\n  team: string;\n  image: string;\n  href: string;\n}\n\nexport interface ProjectHoverRevealProps extends Omit<\n  ComponentPropsWithoutRef<'section'>,\n  'title'\n> {\n  title?: ReactNode;\n  items?: ProjectHoverRevealItem[];\n}\n\nconst photo = (id: string) => `https://assets.lummi.ai/assets/${id}?auto=format&w=900`;\n\nconst DEFAULT_ITEMS: ProjectHoverRevealItem[] = [\n  {\n    title: 'Northwind',\n    role: 'Brand · Product · Motion',\n    timeline: '14 Months',\n    year: '2025–2026',\n    team: 'In-House Studio',\n    image: photo('QmS81k18XzrbqKymEVtF63QNybDLVpQkWUY4CDfyYenQtj'),\n    href: '#',\n  },\n  {\n    title: 'Kairo Health',\n    role: 'Web · Product',\n    timeline: '8 Months',\n    year: '2025',\n    team: 'With the Kairo team',\n    image: photo('Qmc4PdY6g9s62pRLzUzhFgfHgUbUvR2UgF71JEKv6iuMYL'),\n    href: '#',\n  },\n  {\n    title: 'Lumen',\n    role: 'Motion & 3D',\n    timeline: '5 Months',\n    year: '2025',\n    team: 'Studio + Freelance',\n    image: photo('QmSRjZYNUygnRZVn8DpnWVdrmXLufPysL88797aebvF5Vv'),\n    href: '#',\n  },\n  {\n    title: 'Field Notes',\n    role: 'Editorial · Brand',\n    timeline: '6 Months',\n    year: '2024',\n    team: 'In-House Studio',\n    image: photo('QmToeVFF7J32X1iakjeULc7rWqxyckLAJ1xvgx6BezrPxi'),\n    href: '#',\n  },\n  {\n    title: 'Orbit Analytics',\n    role: 'Product · Marketing',\n    timeline: '11 Months',\n    year: '2024',\n    team: 'Orbit Design Team',\n    image: photo('QmZUMYMMU4bJYALDuoCLoVjN4hnufnGYJEUosBxvKLJ8mp'),\n    href: '#',\n  },\n];\n\nconst FIELDS = [\n  ['Role', 'role'],\n  ['Timeline', 'timeline'],\n  ['Year', 'year'],\n  ['Team', 'team'],\n] as const;\n\nconst ArrowIcon = () => {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.8\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className=\"size-5\"\n    >\n      <path d=\"M5 12h13M13 6l6 6-6 6\" />\n    </svg>\n  );\n};\n\nconst ProjectHoverReveal = ({\n  title = 'Selected Projects',\n  items = DEFAULT_ITEMS,\n  className,\n  ...props\n}: ProjectHoverRevealProps) => {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const frameRef = useRef<HTMLDivElement>(null);\n  const state = useRef({ active: 0 });\n\n  const rows = () =>\n    Array.from(rootRef.current?.querySelectorAll<HTMLElement>('[data-project-row]') ?? []);\n\n  const images = () =>\n    Array.from(frameRef.current?.querySelectorAll<HTMLElement>('[data-project-image]') ?? []);\n\n  const fields = () =>\n    Array.from(panelRef.current?.querySelectorAll<HTMLElement>('[data-project-field]') ?? []);\n\n  const indexOf = (target: EventTarget | null) => {\n    const row = (target as HTMLElement | null)?.closest?.('[data-project-row]');\n    return row instanceof HTMLElement ? Number(row.dataset.index) : -1;\n  };\n\n  const highlight = (index: number) => {\n    rows().forEach((row, i) => {\n      row.dataset.active = i === index ? 'true' : 'false';\n    });\n  };\n\n  // Every project image is mounted and loaded up front, stacked in the one\n  // frame. The new one opens as a circle from the edge nearest the row you\n  // pointed at, while the old one stays underneath and dims, so the frame\n  // never shows an empty gap between projects.\n  const swap = (from: number, to: number, animate: boolean) => {\n    const all = images();\n    const next = all[to];\n    const prev = from >= 0 && from !== to ? all[from] : undefined;\n\n    all.forEach((image) => {\n      if (image === next || image === prev) return;\n      gsap.killTweensOf(image);\n      gsap.set(image, { autoAlpha: 0, zIndex: 0 });\n    });\n    if (!next) return;\n\n    const rest = { clipPath: FULL, scale: 1, filter: 'brightness(1)' };\n    if (!animate || !prev) {\n      gsap.killTweensOf([next, prev]);\n      gsap.set(next, { autoAlpha: 1, zIndex: 2, ...rest });\n      if (prev) gsap.set(prev, { autoAlpha: 0, zIndex: 0 });\n      return;\n    }\n\n    const at = originOf(to);\n    gsap.killTweensOf(prev);\n    gsap.set(prev, { autoAlpha: 1, zIndex: 1, ...rest });\n    gsap.to(prev, { filter: 'brightness(0.55)', duration: 0.8, ease: 'power2.out' });\n    gsap.fromTo(\n      next,\n      { autoAlpha: 1, zIndex: 2, clipPath: `circle(0% at ${at})`, scale: 1.2 },\n      {\n        clipPath: `circle(150% at ${at})`,\n        scale: 1,\n        duration: 0.8,\n        ease: 'power3.out',\n        overwrite: true,\n        onComplete: () => {\n          gsap.set(next, { clipPath: FULL });\n          gsap.set(prev, { autoAlpha: 0, zIndex: 0, filter: 'brightness(1)' });\n        },\n      }\n    );\n  };\n\n  // The row's centre projected onto the frame and pinned to its edge: beside\n  // the list that is the left edge at the row's height, and when the stage\n  // stacks above the list it is the bottom edge.\n  const originOf = (index: number) => {\n    const frame = frameRef.current;\n    const row = rows()[index];\n    if (!frame || !row) return '0% 50%';\n    const box = frame.getBoundingClientRect();\n    const rect = row.getBoundingClientRect();\n    if (!box.width || !box.height) return '0% 50%';\n    const x = ((rect.left + rect.width / 2 - box.left) / box.width) * 100;\n    const y = ((rect.top + rect.height / 2 - box.top) / box.height) * 100;\n    const pin = (value: number) => Math.round(Math.min(Math.max(value, 0), 100));\n    return `${pin(x)}% ${pin(y)}%`;\n  };\n\n  // Each detail rolls in its own masked line, a beat after the one before,\n  // in the direction you moved through the list.\n  const roll = (from: number, to: number, animate: boolean) => {\n    const direction = to > from ? 1 : -1;\n    fields().forEach((field, f) => {\n      const lines = Array.from(field.querySelectorAll<HTMLElement>('[data-project-value]'));\n      const next = lines[to];\n      const prev = from >= 0 && from !== to ? lines[from] : undefined;\n\n      lines.forEach((line) => {\n        if (animate && line === prev) return;\n        gsap.killTweensOf(line);\n        gsap.set(line, { autoAlpha: line === next ? 1 : 0, yPercent: 0 });\n      });\n      if (!animate || !next) return;\n\n      const delay = f * 0.05;\n      if (prev) {\n        gsap.to(prev, {\n          yPercent: -100 * direction,\n          autoAlpha: 0,\n          duration: 0.4,\n          ease: 'power3.inOut',\n          delay,\n          overwrite: true,\n        });\n      }\n      gsap.fromTo(\n        next,\n        { yPercent: 100 * direction, autoAlpha: 0 },\n        { yPercent: 0, autoAlpha: 1, duration: 0.55, ease: 'power3.out', delay, overwrite: true }\n      );\n    });\n  };\n\n  const { contextSafe } = useGSAP(\n    () => {\n      if (!frameRef.current) return;\n      state.current.active = 0;\n      highlight(0);\n      swap(-1, 0, false);\n      roll(-1, 0, false);\n    },\n    { scope: rootRef, dependencies: [items] }\n  );\n\n  const activate = contextSafe((index: number) => {\n    const s = state.current;\n    if (index < 0 || index === s.active) return;\n    const animate = !prefersReducedMotion();\n    const from = s.active;\n    s.active = index;\n    highlight(index);\n    swap(from, index, animate);\n    roll(from, index, animate);\n  });\n\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== 'touch') activate(indexOf(event.target));\n  };\n\n  const onFocus = (event: FocusEvent<HTMLDivElement>) => {\n    if (isFocusVisible(event.target)) activate(indexOf(event.target));\n  };\n\n  return (\n    <section\n      data-project-hover-reveal\n      className={cn('w-full py-12 md:py-16', className)}\n      {...props}\n    >\n      <div className=\"@container mx-auto w-full max-w-[1100px] px-4\">\n        <div className=\"flex items-end justify-between gap-4 border-b border-[#12161F]/15 pb-6 md:pb-8 dark:border-white/15\">\n          <h2 className=\"text-4xl leading-[0.95] font-normal tracking-[-0.03em] text-[#12161F] @3xl:text-6xl dark:text-white\">\n            {title}\n          </h2>\n          <p className=\"text-sm text-[#045f64]/70 tabular-nums dark:text-[#9fd4d6]/80\">\n            {String(items.length).padStart(2, '0')}\n          </p>\n        </div>\n\n        <div\n          ref={rootRef}\n          className=\"grid gap-8 pt-6 @3xl:grid-cols-[minmax(0,1fr)_minmax(0,380px)] @3xl:gap-12 @3xl:pt-8 @5xl:grid-cols-[minmax(0,1fr)_440px] @5xl:gap-16\"\n          onPointerMove={onPointerMove}\n          onFocus={onFocus}\n        >\n          <ul className=\"m-0 list-none self-center p-0\">\n            {items.map((item, index) => (\n              <li\n                key={item.href + item.title}\n                data-project-row\n                data-index={index}\n                data-active={index === 0 ? 'true' : 'false'}\n                className=\"group/row relative border-b border-[#12161F]/10 last:border-b-0 dark:border-white/10\"\n              >\n                <img\n                  src={item.image}\n                  alt={item.title}\n                  loading=\"lazy\"\n                  className=\"mt-4 hidden aspect-[4/3] w-full rounded-lg object-cover pointer-coarse:block\"\n                />\n                <a\n                  href={item.href}\n                  className=\"flex items-baseline gap-4 py-4 no-underline outline-none after:absolute after:inset-0 after:rounded-sm focus-visible:after:ring-2 focus-visible:after:ring-[#045f64] focus-visible:after:ring-inset @3xl:py-5 dark:focus-visible:after:ring-[#9fd4d6]\"\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"w-7 shrink-0 text-xs text-[#12161F]/35 tabular-nums transition-colors duration-500 group-data-[active=true]/row:text-[#045f64] motion-reduce:transition-none dark:text-white/35 dark:group-data-[active=true]/row:text-[#c6f56f]\"\n                  >\n                    {String(index + 1).padStart(2, '0')}\n                  </span>\n                  <span className=\"flex min-w-0 flex-col gap-1\">\n                    <span className=\"text-3xl leading-[1.05] font-normal tracking-[-0.02em] text-[#12161F]/30 transition-[color,translate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[active=true]/row:translate-x-2 group-data-[active=true]/row:text-[#12161F] motion-reduce:transition-none @3xl:text-[40px] @5xl:text-[44px] dark:text-white/30 dark:group-data-[active=true]/row:text-white pointer-coarse:text-[#12161F] dark:pointer-coarse:text-white\">\n                      {item.title}\n                    </span>\n                    <span className=\"sr-only text-sm text-[#12161F]/60 dark:text-white/60 pointer-coarse:not-sr-only\">\n                      {item.role}, {item.year}\n                    </span>\n                  </span>\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"ml-auto -translate-x-2 self-center text-[#045f64] opacity-0 transition-[opacity,translate] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[active=true]/row:translate-x-0 group-data-[active=true]/row:opacity-100 motion-reduce:transition-none dark:text-[#c6f56f] pointer-coarse:hidden\"\n                  >\n                    <ArrowIcon />\n                  </span>\n                </a>\n              </li>\n            ))}\n          </ul>\n\n          <div aria-hidden=\"true\" className=\"order-first @3xl:order-none pointer-coarse:hidden\">\n            <div ref={panelRef}>\n              <div\n                ref={frameRef}\n                className=\"relative aspect-[4/3] overflow-hidden rounded-xl bg-[#0b3b3e] shadow-[0_30px_60px_-30px_rgba(4,47,50,0.55)]\"\n              >\n                {items.map((item) => (\n                  <img\n                    key={item.href + item.title}\n                    data-project-image\n                    src={item.image}\n                    alt={item.title}\n                    decoding=\"async\"\n                    className=\"invisible absolute inset-0 size-full object-cover opacity-0\"\n                  />\n                ))}\n              </div>\n              <dl className=\"m-0 mt-6 grid grid-cols-2 gap-x-8 gap-y-5\">\n                {FIELDS.map(([label, key]) => (\n                  <div key={key} data-project-field>\n                    <dt className=\"text-[11px] tracking-[0.14em] text-[#045f64]/70 uppercase dark:text-[#9fd4d6]/70\">\n                      {label}\n                    </dt>\n                    <dd className=\"m-0 mt-2 grid overflow-hidden text-[15px] text-[#12161F] dark:text-white\">\n                      {items.map((item) => (\n                        <span\n                          key={item.href + item.title}\n                          data-project-value\n                          className=\"invisible truncate opacity-0 [grid-area:1/1]\"\n                        >\n                          {item[key]}\n                        </span>\n                      ))}\n                    </dd>\n                  </div>\n                ))}\n              </dl>\n            </div>\n          </div>\n        </div>\n      </div>\n    </section>\n  );\n};\n\nexport default ProjectHoverReveal;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/project-hover-reveal\nPass your own `items` (`title`, `role`, `timeline`, `year`, `team`, `image`, `href`). Every image is mounted in the stage up front, so keep them web-sized."
}