{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"project-hover-section","type":"registry:component","title":"Project hover","description":"Thumbnails float with the cursor.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/project-hover-section/project-hover-section.tsx","type":"registry:component","target":"components/dimaac/project-hover-section.tsx","content":"\"use client\";\n\nimport { useId, useRef, useState, useSyncExternalStore, type MouseEvent } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A list of projects. Hover a row and a photo grows at the cursor, follows it around and slides to\n// that row's picture. Tab to a row and the photo shows up beside it, so keyboard users get it too.\n// On touch screens there is no cursor, so each row opens like an accordion and shows its photo inline.\n// The look comes from `currentColor`: the list takes the text colour of whatever it sits in.\n\nexport type ProjectHoverItem = {\n  /** The big name on the left of the row. */\n  title: string;\n  /** The small line on the right (a year, a discipline). */\n  subtitle: string;\n  /** The photo shown for this row. */\n  image: string;\n  /** Describes the photo for screen readers. Defaults to the title. */\n  alt?: string;\n  /** Makes the row a link. Without it the row is a button. */\n  href?: string;\n};\n\nexport type ProjectHoverSectionProps = {\n  /** The rows, top to bottom. Three to eight look best. */\n  projects: ProjectHoverItem[];\n  /** Width of the floating photo, in pixels. */\n  thumbnailWidth?: number;\n  /** Height of the floating photo, in pixels. */\n  thumbnailHeight?: number;\n  /** Corner radius of the floating photo, in pixels. */\n  thumbnailRadius?: number;\n  /** How long the photo takes to catch up with the cursor, in seconds. */\n  followDuration?: number;\n  /** How the photo changes between rows: \"slide\" scrolls the strip, \"fade\" cross-fades. */\n  transition?: \"slide\" | \"fade\";\n  /** How far the title and subtitle of the hovered row move apart, in pixels. */\n  shift?: number;\n  /** The opacity of the hovered row, from 0 to 1. 1 leaves it alone. */\n  dimOpacity?: number;\n  /** The most the photo leans into the direction of the cursor, in degrees. 0 keeps it straight. */\n  tilt?: number;\n  /** Called when a row is clicked or pressed. */\n  onSelect?: (project: ProjectHoverItem, index: number) => void;\n  /** Extra classes for the outer box. It is as wide as its parent and as tall as the list. */\n  className?: string;\n};\n\n// A mouse (or a pen) that can hover. Everything else gets the tap-to-open accordion.\nconst FINE_POINTER = \"(hover: hover) and (pointer: fine)\";\n\nfunction subscribeToPointer(onChange: () => void) {\n  const query = window.matchMedia(FINE_POINTER);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\n\n// The server and the first client render say \"mouse\", then this settles on the real answer.\n// The layout itself is decided in CSS, so nothing jumps; this only drives the aria attributes.\nfunction useTouchLayout() {\n  return useSyncExternalStore(\n    subscribeToPointer,\n    () => !window.matchMedia(FINE_POINTER).matches,\n    () => false,\n  );\n}\n\nexport function ProjectHoverSection({\n  projects,\n  thumbnailWidth = 380,\n  thumbnailHeight = 250,\n  thumbnailRadius = 10,\n  followDuration = 0.4,\n  transition = \"slide\",\n  shift = 15,\n  dimOpacity = 0.5,\n  tilt = 5,\n  onSelect,\n  className = \"\",\n}: ProjectHoverSectionProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLUListElement>(null);\n  const thumbRef = useRef<HTMLDivElement>(null);\n  const stripRef = useRef<HTMLDivElement>(null);\n  const [openIndex, setOpenIndex] = useState<number | null>(null);\n  const touchLayout = useTouchLayout();\n  const panelId = useId();\n\n  useGSAP(\n    () => {\n      const box = boxRef.current;\n      const list = listRef.current;\n      const thumb = thumbRef.current;\n      const strip = stripRef.current;\n      if (!box || !list || !thumb || !strip) return;\n\n      const rows = gsap.utils.toArray<HTMLElement>(\"[data-row]\", list);\n      const slides = gsap.utils.toArray<HTMLElement>(\"[data-slide]\", strip);\n      const last = rows.length - 1;\n      if (last < 0) return;\n\n      // matchMedia re-runs this block if the visitor plugs in a mouse or flips the motion setting\n      const mm = gsap.matchMedia();\n      mm.add({ fine: FINE_POINTER, calm: \"(prefers-reduced-motion: reduce)\" }, (context, contextSafe) => {\n        const { fine, calm } = context.conditions as { fine: boolean; calm: boolean };\n        if (!fine || !contextSafe) return;\n        // contextSafe returns a plain Function, so give the handler its own type back\n        const safe = <Handler extends (...args: never[]) => void>(handler: Handler) => contextSafe(handler) as Handler;\n\n        // The photo starts hidden, centred on its own middle so x and y are the cursor position.\n        // With reduced motion it never scales or travels: it just appears, and fades.\n        gsap.set(thumb, { xPercent: -50, yPercent: -50, scale: calm ? 1 : 0, autoAlpha: 0, force3D: true });\n        // Slide: pictures sit one below the other and the strip moves. Fade: they sit on top of each other.\n        gsap.set(slides, { yPercent: (i: number) => (transition === \"slide\" ? i * 100 : 0), autoAlpha: (i: number) => (transition === \"slide\" || i === 0 ? 1 : 0) });\n        gsap.set(strip, { yPercent: 0 });\n\n        const moveX = calm ? null : gsap.quickTo(thumb, \"x\", { duration: followDuration, ease: \"power3.out\" });\n        const moveY = calm ? null : gsap.quickTo(thumb, \"y\", { duration: followDuration, ease: \"power3.out\" });\n        const lean = calm || tilt === 0 ? null : gsap.quickTo(thumb, \"rotation\", { duration: 0.6, ease: \"power3.out\" });\n        // When the cursor stops, the photo straightens up\n        const straighten = lean ? gsap.delayedCall(0.08, () => lean(0)).pause() : null;\n\n        let shown = false;\n        let pointerInside = false;\n        let activeIndex = -1;\n        let lastClientX = 0;\n\n        const markActive = (index: number) => {\n          rows.forEach((row, i) => {\n            if (i === index) {\n              row.dataset.active = \"true\";\n              if (!calm) row.dataset.shift = \"true\";\n            } else {\n              delete row.dataset.active;\n              delete row.dataset.shift;\n            }\n          });\n        };\n\n        const showPicture = (index: number, instant: boolean) => {\n          if (transition === \"slide\") {\n            gsap.to(strip, { yPercent: -100 * index, duration: instant || calm ? 0 : 0.4, ease: \"power2.out\", overwrite: \"auto\" });\n          } else {\n            gsap.to(slides, { autoAlpha: (i: number) => (i === index ? 1 : 0), duration: instant || calm ? 0 : 0.4, ease: \"power2.out\", overwrite: \"auto\" });\n          }\n        };\n\n        // x and y are measured from the top left corner of the box the photo lives in\n        const reveal = (index: number, x: number, y: number) => {\n          const wasShown = shown;\n          if (!wasShown) {\n            gsap.set(thumb, { x, y });\n            // One call in the quickTo functions' own tween, so they start from here too\n            moveX?.(x);\n            moveY?.(y);\n          }\n          if (index !== activeIndex) showPicture(index, !wasShown);\n          activeIndex = index;\n          markActive(index);\n          shown = true;\n          gsap.to(thumb, { scale: 1, autoAlpha: 1, duration: calm ? 0.15 : 0.4, ease: \"power2.out\", overwrite: \"auto\" });\n        };\n\n        const hide = () => {\n          if (!shown) return;\n          shown = false;\n          activeIndex = -1;\n          markActive(-1);\n          lean?.(0);\n          gsap.to(thumb, { scale: calm ? 1 : 0, autoAlpha: 0, duration: calm ? 0.1 : 0.3, ease: \"power2.out\", overwrite: \"auto\" });\n        };\n\n        const toBox = (clientX: number, clientY: number) => {\n          const rect = box.getBoundingClientRect();\n          return { x: clientX - rect.left, y: clientY - rect.top };\n        };\n\n        const isMouse = (event: PointerEvent) => event.pointerType === \"mouse\" || event.pointerType === \"pen\";\n\n        const onListEnter = safe((event: PointerEvent) => {\n          if (isMouse(event)) pointerInside = true;\n        });\n        const onListMove = safe((event: PointerEvent) => {\n          if (!isMouse(event) || !shown) return;\n          const { x, y } = toBox(event.clientX, event.clientY);\n          if (moveX && moveY) {\n            moveX(x);\n            moveY(y);\n          } else {\n            gsap.set(thumb, { x, y });\n          }\n          if (lean && straighten) {\n            lean(gsap.utils.clamp(-tilt, tilt, (event.clientX - lastClientX) * 0.5));\n            straighten.restart(true);\n          }\n          lastClientX = event.clientX;\n        });\n        const onListLeave = safe((event: PointerEvent) => {\n          if (!isMouse(event)) return;\n          pointerInside = false;\n          hide();\n        });\n\n        const cleanups: (() => void)[] = [];\n        list.addEventListener(\"pointerenter\", onListEnter);\n        list.addEventListener(\"pointermove\", onListMove);\n        list.addEventListener(\"pointerleave\", onListLeave);\n        cleanups.push(() => {\n          list.removeEventListener(\"pointerenter\", onListEnter);\n          list.removeEventListener(\"pointermove\", onListMove);\n          list.removeEventListener(\"pointerleave\", onListLeave);\n        });\n\n        rows.forEach((row, index) => {\n          const onRowEnter = safe((event: PointerEvent) => {\n            if (!isMouse(event)) return;\n            lastClientX = event.clientX;\n            const { x, y } = toBox(event.clientX, event.clientY);\n            reveal(index, x, y);\n          });\n          // Keyboard focus: the photo appears beside the row. A mouse click also focuses a row,\n          // but then :focus-visible is false and the cursor is already driving.\n          const onRowFocus = safe(() => {\n            if (pointerInside || !row.matches(\":focus-visible\")) return;\n            const rect = row.getBoundingClientRect();\n            const { x, y } = toBox(rect.left + rect.width * 0.62, rect.top + rect.height / 2);\n            reveal(index, x, y);\n            moveX?.(x);\n            moveY?.(y);\n          });\n          const onRowBlur = safe((event: FocusEvent) => {\n            if (pointerInside || rows.includes(event.relatedTarget as HTMLElement)) return;\n            hide();\n          });\n          row.addEventListener(\"pointerenter\", onRowEnter);\n          row.addEventListener(\"focus\", onRowFocus);\n          row.addEventListener(\"blur\", onRowBlur);\n          cleanups.push(() => {\n            row.removeEventListener(\"pointerenter\", onRowEnter);\n            row.removeEventListener(\"focus\", onRowFocus);\n            row.removeEventListener(\"blur\", onRowBlur);\n          });\n        });\n\n        return () => {\n          cleanups.forEach((cleanup) => cleanup());\n          straighten?.kill();\n          markActive(-1);\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: boxRef, dependencies: [projects.length, transition, followDuration, tilt], revertOnUpdate: true },\n  );\n\n  const handleClick = (event: MouseEvent<HTMLElement>, project: ProjectHoverItem, index: number) => {\n    onSelect?.(project, index);\n    if (!touchLayout) return;\n    // On touch the row opens its picture. A linked row gets a \"View project\" link inside the panel.\n    if (project.href) event.preventDefault();\n    setOpenIndex((current) => (current === index ? null : index));\n  };\n\n  return (\n    <div\n      ref={boxRef}\n      className={`relative w-full ${className}`}\n      style={{ \"--phs-shift\": `${shift}px`, \"--phs-dim\": dimOpacity } as React.CSSProperties}\n    >\n      <ul ref={listRef} className=\"m-0 mx-auto flex w-full max-w-[1000px] list-none flex-col border-b border-current/20 p-0\">\n        {projects.map((project, index) => {\n          const open = touchLayout && openIndex === index;\n          const panelName = `${panelId}-${index}`;\n          const rowProps = {\n            \"data-row\": \"\",\n            className:\n              \"group relative flex w-full cursor-pointer flex-col items-start gap-1 border-t border-current/20 bg-transparent px-4 py-5 text-left text-inherit no-underline outline-none transition-opacity duration-300 focus-visible:bg-current/[0.04] data-[active=true]:opacity-[var(--phs-dim)] sm:flex-row sm:items-center sm:justify-between sm:gap-6 md:px-14 md:py-9 [@media(hover:hover)_and_(pointer:fine)]:focus-visible:outline-none\",\n            onClick: (event: MouseEvent<HTMLElement>) => handleClick(event, project, index),\n            \"aria-expanded\": touchLayout ? open : undefined,\n            \"aria-controls\": touchLayout ? panelName : undefined,\n          };\n          const content = (\n            <>\n              <span className=\"block text-[clamp(1.6rem,4.4vw,3.5rem)] leading-[1.05] font-medium tracking-[-0.035em] transition-transform duration-500 ease-out group-data-[shift=true]:-translate-x-[var(--phs-shift)]\">\n                {project.title}\n              </span>\n              <span className=\"flex items-center gap-4 text-sm opacity-70 transition-transform duration-500 ease-out group-data-[shift=true]:translate-x-[var(--phs-shift)] md:text-base [@media(hover:none)]:sm:mr-8\">\n                {project.subtitle}\n              </span>\n              {/* Touch only: a plus that turns into a minus while the row is open */}\n              <span aria-hidden=\"true\" className=\"absolute top-1/2 right-4 block size-3 -translate-y-1/2 opacity-70 md:right-14 [@media(hover:hover)_and_(pointer:fine)]:hidden\">\n                <span className=\"absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-current\" />\n                <span className={`absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-current transition-transform duration-300 ${open ? \"scale-y-0\" : \"\"}`} />\n              </span>\n            </>\n          );\n\n          return (\n            <li key={`${index}-${project.title}`} className=\"m-0 p-0\">\n              {project.href ? (\n                <a href={project.href} role={touchLayout ? \"button\" : undefined} {...rowProps}>\n                  {content}\n                </a>\n              ) : (\n                <button type=\"button\" {...rowProps}>\n                  {content}\n                </button>\n              )}\n\n              {/* Touch only: the picture opens under the row */}\n              <div\n                id={panelName}\n                inert={!open}\n                className={`grid transition-[grid-template-rows] duration-500 ease-out motion-reduce:transition-none [@media(hover:hover)_and_(pointer:fine)]:hidden ${open ? \"grid-rows-[1fr]\" : \"grid-rows-[0fr]\"}`}\n              >\n                <div className=\"min-h-0 overflow-hidden\">\n                  <div className=\"px-4 pb-5\">\n                    <img\n                      src={project.image}\n                      alt={project.alt ?? project.title}\n                      loading=\"lazy\"\n                      decoding=\"async\"\n                      draggable={false}\n                      className={`block w-full object-cover transition-opacity duration-500 ${open ? \"opacity-100\" : \"opacity-0\"}`}\n                      style={{ aspectRatio: `${thumbnailWidth} / ${thumbnailHeight}`, borderRadius: thumbnailRadius }}\n                    />\n                    {project.href ? (\n                      <a href={project.href} className=\"mt-3 inline-block text-sm underline underline-offset-4\">\n                        View project\n                      </a>\n                    ) : null}\n                  </div>\n                </div>\n              </div>\n            </li>\n          );\n        })}\n      </ul>\n\n      {/* The floating photo. Decorative: every row already says what the picture is. */}\n      <div\n        ref={thumbRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute top-0 left-0 z-50 hidden overflow-hidden border border-current/20 shadow-[0_30px_60px_-24px_rgba(0,0,0,0.45)] [@media(hover:hover)_and_(pointer:fine)]:block\"\n        style={{ width: thumbnailWidth, height: thumbnailHeight, borderRadius: thumbnailRadius, opacity: 0, visibility: \"hidden\", willChange: \"transform\" }}\n      >\n        <div ref={stripRef} className=\"absolute inset-0\">\n          {projects.map((project, index) => (\n            <div key={`${index}-${project.image}`} data-slide=\"\" className=\"absolute inset-0\">\n              <img src={project.image} alt=\"\" decoding=\"async\" draggable={false} className=\"block h-full w-full object-cover\" />\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"categories":["cursor","gsap","hover","list","portfolio","images"],"docs":"import { ProjectHoverSection } from \"@/components/dimaac/project-hover-section\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/project-hover-section","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/project-hover-section","credits":[{"by":"Ricardo Matos","url":"https://www.lummi.ai/photo/minimalist-urban-design-abiri","what":"Concrete corner","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"Siren Media","url":"https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl","what":"Red wall interior","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"Binks AI","url":"https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c","what":"Palm pool","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"Mikiwa","url":"https://www.lummi.ai/photo/modern-building-corner-dr5dz","what":"White corner","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"},{"by":"Mariate","url":"https://www.lummi.ai/photo/minimalist-architectural-design-b8bbc","what":"Concrete balconies","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"}]}}