{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ink-hover","type":"registry:component","title":"Ink Hover Reveal","description":"A brush stroke of ink pours the colour photo in on hover.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/ink-hover/ink-hover.tsx","type":"registry:component","target":"components/dimaac/ink-hover.tsx","content":"\"use client\";\n\nimport { useId, useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A greyscale photo that fills with colour when you hover it. A thick brush stroke draws across\n// the card like wet ink, and the colour photo shows through the stroke as it widens, until the\n// whole card is poured in. Leaving runs the same timeline backwards, so the colour drains away\n// along the same line. Everything lives in one SVG, so the photo and the brush always line up.\n\n/** The three brush paths. Each one is drawn in a 800 x 1000 box and runs from outside the card to outside it. */\nconst PATHS = {\n  // Soft S-bends, row after row down the card\n  wave: \"M -60 130 C 120 20 260 240 420 130 S 720 20 860 130 C 960 200 700 300 520 300 S 160 270 -60 330 C -110 350 60 480 220 480 S 560 380 860 480 C 950 520 760 650 560 650 S 200 570 -60 650 C -110 680 100 800 300 800 S 620 700 860 810 C 950 850 780 940 560 940 S 220 890 -60 950\",\n  // Hard diagonal passes, like a marker filling a page\n  zigzag: \"M -40 60 L 840 220 L -40 380 L 840 540 L -40 700 L 840 860 L 120 1060\",\n  // A loose, looping scribble\n  scribble: \"M 90 940 C -40 640 380 740 300 520 S 700 700 640 400 S 130 460 240 220 S 720 280 700 60 C 690 -40 520 -20 560 120 S 640 420 420 600 S 100 780 200 950 S 520 1010 640 880 S 860 700 760 560\",\n} as const;\n\nexport type InkHoverPath = keyof typeof PATHS;\n\nexport type InkHoverProps = {\n  /** The photo. It shows in greyscale until the ink pours in. A 4:5 portrait fits best. */\n  src: string;\n  /** A different photo to pour in. Leave it empty to pour in the colour version of `src`. */\n  hoverSrc?: string;\n  /** Alt text for the photo. Leave it empty if the label already says what it is. */\n  alt?: string;\n  /** Title shown on the card once the colour is in. */\n  label?: string;\n  /** A short line under the title. */\n  caption?: string;\n  /** How thick the brush stroke is while it draws, in pixels of a 800 wide card. The stroke then widens until it floods the card. */\n  strokeWidth?: number;\n  /** How long the brush takes to cross the card, in seconds. */\n  duration?: number;\n  /** The shape of the brush path. */\n  path?: InkHoverPath;\n  /** Show the base photo in greyscale. Turn it off to see the full colour photo under the ink. */\n  grayscaleBase?: boolean;\n  /** The colour of the wet ink that leads the stroke. */\n  inkColor?: string;\n  /** Extra classes for the card. It is a 4:5 box by default: give it your own size or aspect. */\n  className?: string;\n};\n\n// How wide the stroke ends up, so the last pass floods every gap between the rows\nconst FLOOD_WIDTH = 780;\n// The path is normalised to length 1 (pathLength), so the dash tricks work for any shape.\n// Starting a little past 1 keeps the round cap of the empty stroke out of the card.\nconst HIDDEN_OFFSET = 1.12;\n\nexport function InkHover({\n  src,\n  hoverSrc,\n  alt = \"\",\n  label,\n  caption,\n  strokeWidth = 140,\n  duration = 0.9,\n  path = \"wave\",\n  grayscaleBase = true,\n  inkColor = \"#111111\",\n  className = \"\",\n}: InkHoverProps) {\n  const cardRef = useRef<HTMLButtonElement>(null);\n  const timelineRef = useRef<gsap.core.Timeline | null>(null);\n  const pointerTypeRef = useRef(\"mouse\");\n  const openRef = useRef(false);\n\n  // useId gives each card its own ids, so two cards never share a mask or a filter\n  const uid = useId().replace(/[^a-zA-Z0-9]/g, \"\");\n  const maskId = `ink-mask-${uid}`;\n  const greyId = `ink-grey-${uid}`;\n  const d = PATHS[path];\n  const revealSrc = hoverSrc || src;\n\n  useGSAP(\n    () => {\n      const card = cardRef.current;\n      if (!card) return;\n      const inkLine = card.querySelector<SVGPathElement>(\"[data-ink]\");\n      const maskLine = card.querySelector<SVGPathElement>(\"[data-mask]\");\n      const reveal = card.querySelector<SVGGElement>(\"[data-reveal]\");\n      const revealImage = card.querySelector<SVGImageElement>(\"[data-reveal-image]\");\n      const words = card.querySelector<HTMLElement>(\"[data-words]\");\n      if (!inkLine || !maskLine || !reveal || !revealImage || !words) return;\n\n      const mm = gsap.matchMedia();\n\n      mm.add(\n        { calm: \"(prefers-reduced-motion: reduce)\", lively: \"(prefers-reduced-motion: no-preference)\" },\n        (context) => {\n          const calm = Boolean(context.conditions?.calm);\n          const timeline = gsap.timeline({ paused: true, defaults: { ease: \"power2.out\" } });\n          timelineRef.current = timeline;\n\n          if (calm) {\n            // Reduced motion: no brush. The mask is fully open and the colour photo simply fades in.\n            gsap.set(maskLine, { attr: { \"stroke-dashoffset\": 0, \"stroke-width\": FLOOD_WIDTH } });\n            gsap.set(inkLine, { opacity: 0 });\n            gsap.set(reveal, { opacity: 0 });\n            gsap.set(words, { opacity: 0 });\n            timeline.to(reveal, { opacity: 1, duration: 0.3 }, 0).to(words, { opacity: 1, duration: 0.3 }, 0);\n            return;\n          }\n\n          // The ink is a little thinner than the colour, so it only shows as a dark tip at the\n          // front of the stroke and a thin rim along its edge, never as blobs between the passes\n          const inkScale = 0.8;\n          gsap.set(maskLine, { attr: { \"stroke-dashoffset\": HIDDEN_OFFSET, \"stroke-width\": strokeWidth } });\n          gsap.set(inkLine, { attr: { \"stroke-dashoffset\": HIDDEN_OFFSET, \"stroke-width\": strokeWidth * inkScale } });\n          gsap.set(revealImage, { scale: 1.12, transformOrigin: \"50% 50%\" });\n          gsap.set(words, { opacity: 0, y: 14 });\n\n          // The ink goes first, the colour photo follows a beat behind\n          const lag = duration * 0.07;\n          [\n            { line: inkLine, at: 0, width: FLOOD_WIDTH * inkScale },\n            { line: maskLine, at: lag, width: FLOOD_WIDTH },\n          ].forEach(({ line, at, width }) => {\n            timeline.to(line, { attr: { \"stroke-dashoffset\": 0 }, duration, ease: \"power2.out\" }, at);\n            // The stroke thickens while it is still travelling, which is what makes it feel poured\n            timeline.to(line, { attr: { \"stroke-width\": width }, duration: duration * 0.8, ease: \"power2.inOut\" }, at + duration * 0.25);\n          });\n          timeline.to(revealImage, { scale: 1, duration: duration * 1.2, ease: \"power2.out\" }, lag);\n          timeline.to(words, { opacity: 1, y: 0, duration: 0.5 }, duration * 0.55);\n        },\n      );\n\n      const play = () => {\n        openRef.current = true;\n        card.setAttribute(\"aria-pressed\", \"true\");\n        timelineRef.current?.timeScale(1).play();\n      };\n      // Leaving is a touch faster than arriving\n      const stop = () => {\n        openRef.current = false;\n        card.setAttribute(\"aria-pressed\", \"false\");\n        timelineRef.current?.timeScale(1.3).reverse();\n      };\n\n      const onPointerEnter = (event: PointerEvent) => {\n        if (event.pointerType !== \"touch\") play();\n      };\n      const onPointerLeave = (event: PointerEvent) => {\n        if (event.pointerType !== \"touch\") stop();\n      };\n      const onPointerDown = (event: PointerEvent) => {\n        pointerTypeRef.current = event.pointerType;\n      };\n      // Touch has no hover, so a tap opens the card and the next tap closes it\n      const onClick = () => {\n        if (pointerTypeRef.current !== \"touch\") return;\n        if (openRef.current) stop();\n        else play();\n      };\n      // Keyboard users get the same reveal when the card takes focus\n      const onFocus = () => {\n        if (card.matches(\":focus-visible\")) play();\n      };\n      const onBlur = () => {\n        if (openRef.current && pointerTypeRef.current !== \"touch\") stop();\n      };\n\n      card.addEventListener(\"pointerenter\", onPointerEnter);\n      card.addEventListener(\"pointerleave\", onPointerLeave);\n      card.addEventListener(\"pointerdown\", onPointerDown);\n      card.addEventListener(\"click\", onClick);\n      card.addEventListener(\"focus\", onFocus);\n      card.addEventListener(\"blur\", onBlur);\n\n      return () => {\n        card.removeEventListener(\"pointerenter\", onPointerEnter);\n        card.removeEventListener(\"pointerleave\", onPointerLeave);\n        card.removeEventListener(\"pointerdown\", onPointerDown);\n        card.removeEventListener(\"click\", onClick);\n        card.removeEventListener(\"focus\", onFocus);\n        card.removeEventListener(\"blur\", onBlur);\n        timelineRef.current?.kill();\n        timelineRef.current = null;\n        openRef.current = false;\n        card.setAttribute(\"aria-pressed\", \"false\");\n        mm.revert();\n      };\n    },\n    { scope: cardRef, dependencies: [strokeWidth, duration, path, src, revealSrc], revertOnUpdate: true },\n  );\n\n  const accessibleName = [label, caption].filter(Boolean).join(\", \") || alt || undefined;\n\n  return (\n    <button\n      ref={cardRef}\n      type=\"button\"\n      aria-pressed=\"false\"\n      aria-label={accessibleName}\n      className={`group relative block aspect-[4/5] w-full cursor-pointer overflow-hidden rounded-xl border-0 bg-neutral-200 p-0 text-left outline-offset-4 focus-visible:outline-2 focus-visible:outline-current dark:bg-neutral-800 ${className}`}\n      style={{ touchAction: \"manipulation\", WebkitTapHighlightColor: \"transparent\" }}\n    >\n      <svg viewBox=\"0 0 800 1000\" preserveAspectRatio=\"xMidYMid slice\" className=\"absolute inset-0 h-full w-full\" aria-hidden=\"true\" focusable=\"false\">\n        <defs>\n          <filter id={greyId} colorInterpolationFilters=\"sRGB\">\n            <feColorMatrix type=\"saturate\" values=\"0\" />\n          </filter>\n          {/* The brush is white on a clear mask, so the colour photo shows only where the brush has been */}\n          <mask id={maskId} maskUnits=\"userSpaceOnUse\" x=\"-400\" y=\"-400\" width=\"1600\" height=\"1800\">\n            <path data-mask d={d} pathLength={1} fill=\"none\" stroke=\"#fff\" strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={strokeWidth} strokeDasharray=\"1 2\" strokeDashoffset={HIDDEN_OFFSET} />\n          </mask>\n        </defs>\n        <image href={src} x=\"0\" y=\"0\" width=\"800\" height=\"1000\" preserveAspectRatio=\"xMidYMid slice\" filter={grayscaleBase ? `url(#${greyId})` : undefined} />\n        <path data-ink d={d} pathLength={1} fill=\"none\" stroke={inkColor} strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={strokeWidth} strokeDasharray=\"1 2\" strokeDashoffset={HIDDEN_OFFSET} />\n        <g data-reveal mask={`url(#${maskId})`}>\n          <image data-reveal-image href={revealSrc} x=\"0\" y=\"0\" width=\"800\" height=\"1000\" preserveAspectRatio=\"xMidYMid slice\" />\n        </g>\n      </svg>\n\n      {(label || caption) && (\n        <span data-words className=\"pointer-events-none absolute inset-x-0 bottom-0 flex flex-col gap-0.5 bg-gradient-to-t from-black/60 to-transparent px-5 pt-14 pb-4 text-white opacity-0\" aria-hidden=\"true\">\n          {label && <span className=\"text-[17px] leading-tight font-medium tracking-[-0.01em]\">{label}</span>}\n          {caption && <span className=\"text-[13px] opacity-80\">{caption}</span>}\n        </span>\n      )}\n    </button>\n  );\n}\n"}],"categories":["cursor","gsap","svg","hover","mask","image","card"],"docs":"import { InkHover } from \"@/components/dimaac/ink-hover\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/ink-hover","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/ink-hover","credits":[{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Fashion portrait","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Dona Mara","url":"https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p","what":"Perfume bottle","source":"Lummi","licence":"Lummi License"},{"by":"Cayetano Gros","url":"https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7","what":"Coastline","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp","what":"Cylinders","source":"Lummi","licence":"Lummi License"},{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa","what":"Architecture","source":"Lummi","licence":"Lummi License"}]}}