{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"Reveals any children through a custom SVG shape mask that can morph on a loop, on scroll, or on hover.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  motion,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  type RefObject,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\n\n/** Motion scroll offset tuple, typed from `useScroll` itself. */\nexport type ScrollOffset = NonNullable<\n  Parameters<typeof useScroll>[0]\n>[\"offset\"];\n\nconst EASE_MOVE = [0.645, 0.045, 0.355, 1] as const;\nconst TRANSITION_DURATION = 0.3;\nconst DEFAULT_MORPH_DURATION = 4;\n// The mask has to finish opening while the frame is still in view; the full\n// enter-to-exit range parks the fully-revealed state off-screen.\nconst DEFAULT_SCROLL_OFFSET: ScrollOffset = [\"start 0.9\", \"end 0.6\"];\n\nconst PRESET_PATHS = {\n  arch: \"M0,1 L0,0.4 C0,0.18 0.18,0 0.5,0 C0.82,0 1,0.18 1,0.4 L1,1 Z\",\n  blob: \"M0.5,0.02 C0.75,0.02 0.98,0.25 0.98,0.5 C0.98,0.75 0.75,0.98 0.5,0.98 C0.25,0.98 0.02,0.75 0.02,0.5 C0.02,0.25 0.25,0.02 0.5,0.02 Z\",\n  diamond: \"M0.5,0 L1,0.5 L0.5,1 L0,0.5 Z\",\n  wave: \"M0,0.3 C0.25,0.05 0.75,0.55 1,0.3 L1,1 L0,1 Z\",\n} as const;\n\nexport type SvgClipMaskShape = \"blob\" | \"arch\" | \"diamond\" | \"wave\" | \"custom\";\nexport type SvgClipMaskAnimate = \"none\" | \"morph\" | \"scroll\" | \"hover\";\n\nexport interface SvgClipMaskProps {\n  /**\n   * \"morph\" cycles through morphPaths forever, \"scroll\" steps through them as\n   * the container scrolls, \"hover\" swaps to morphPaths[0] on hover.\n   */\n  animate?: SvgClipMaskAnimate;\n  children: ReactNode;\n  className?: string;\n  /** Ref to a scrollable ancestor that drives \"scroll\" mode instead of the window. */\n  container?: RefObject<HTMLElement | null>;\n  /** Seconds for one full \"morph\" loop. */\n  duration?: number;\n  /**\n   * Paths to cycle between for \"morph\"/\"scroll\"/\"hover\". They must all share\n   * the same command structure (same sequence of M/L/C/Z and point counts) —\n   * browsers interpolate matching parameters positionally, so mismatched\n   * structures snap instead of morphing smoothly.\n   */\n  morphPaths?: string[];\n  /** Custom path data, in objectBoundingBox (0..1) coordinates. Required when shape is \"custom\". */\n  path?: string;\n  shape?: SvgClipMaskShape;\n}\n\nconst useHoverCapable = (enabled: boolean) => {\n  const [isHoverCapable, setIsHoverCapable] = useState(false);\n\n  useEffect(() => {\n    if (!enabled) {\n      return;\n    }\n\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverCapable(mediaQuery.matches);\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      setIsHoverCapable(event.matches);\n    };\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, [enabled]);\n\n  return isHoverCapable;\n};\n\nexport default function SvgClipMask({\n  children,\n  shape = \"blob\",\n  path,\n  animate = \"none\",\n  morphPaths,\n  duration = DEFAULT_MORPH_DURATION,\n  container,\n  className,\n}: SvgClipMaskProps) {\n  const clipId = useId();\n  const shouldReduceMotion = useReducedMotion();\n  const ref = useRef<HTMLDivElement>(null);\n  const isHoverCapable = useHoverCapable(animate === \"hover\");\n  const [isHovered, setIsHovered] = useState(false);\n  const [scrollIndex, setScrollIndex] = useState(0);\n\n  const basePath =\n    shape === \"custom\" ? (path ?? PRESET_PATHS.blob) : PRESET_PATHS[shape];\n  const paths = morphPaths && morphPaths.length > 1 ? morphPaths : [basePath];\n\n  const { scrollYProgress } = useScroll({\n    container,\n    offset: DEFAULT_SCROLL_OFFSET,\n    target: ref,\n  });\n\n  useMotionValueEvent(scrollYProgress, \"change\", (value) => {\n    if (shouldReduceMotion || animate !== \"scroll\" || paths.length < 2) {\n      return;\n    }\n    const index = Math.min(paths.length - 1, Math.floor(value * paths.length));\n    setScrollIndex(index);\n  });\n\n  const isMorphing =\n    animate === \"morph\" && !shouldReduceMotion && paths.length > 1;\n\n  const activeD = (() => {\n    if (shouldReduceMotion || animate === \"none\" || isMorphing) {\n      return paths[0];\n    }\n    if (animate === \"scroll\") {\n      return paths[scrollIndex] ?? paths[0];\n    }\n    if (animate === \"hover\") {\n      return isHoverCapable && isHovered ? (paths[1] ?? paths[0]) : paths[0];\n    }\n    return paths[0];\n  })();\n\n  return (\n    <div\n      className={cn(\"relative\", className)}\n      onPointerEnter={() => setIsHovered(true)}\n      onPointerLeave={() => setIsHovered(false)}\n      ref={ref}\n    >\n      <svg aria-hidden=\"true\" className=\"absolute h-0 w-0\">\n        <defs>\n          <clipPath clipPathUnits=\"objectBoundingBox\" id={clipId}>\n            <motion.path\n              animate={isMorphing ? { d: paths } : { d: activeD }}\n              initial={false}\n              transition={\n                isMorphing\n                  ? {\n                      duration,\n                      ease: EASE_MOVE,\n                      repeat: Number.POSITIVE_INFINITY,\n                      repeatType: \"loop\",\n                    }\n                  : { duration: TRANSITION_DURATION, ease: EASE_MOVE }\n              }\n            />\n          </clipPath>\n        </defs>\n      </svg>\n      <div\n        className=\"h-full w-full\"\n        style={{\n          clipPath: `url(#${clipId})`,\n          WebkitClipPath: `url(#${clipId})`,\n        }}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/svg-clip-mask/index.tsx","type":"registry:ui"}],"name":"svg-clip-mask","registryDependencies":[],"title":"Svg Clip Mask","type":"registry:ui"}