{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "liquid-button",
  "type": "registry:ui",
  "title": "Liquid Button",
  "description": "A button flooded with colour from the exact point the pointer enters, draining out where it leaves.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/liquid-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nexport interface LiquidButtonProps extends React.ComponentPropsWithoutRef<\"button\"> {\n  /** Colour of the liquid fill (any CSS colour). */\n  accent?: string;\n  /** Colour of the label while the button is filled. Pick something that contrasts with `accent`. */\n  inkColor?: string;\n  /** Fill / drain duration in milliseconds. */\n  duration?: number;\n  /** Shows a spinner, sets aria-busy and blocks activation. */\n  loading?: boolean;\n  /** Screen-reader text appended to the accessible name while loading. */\n  loadingLabel?: string;\n  /** Morph the edge of the fill like a liquid meniscus. */\n  wobble?: boolean;\n  /** Base appearance before the fill arrives. */\n  variant?: \"outline\" | \"ink\";\n  /** Corner radius in px. */\n  rounded?: number;\n}\n\ntype Ripple = { id: number; x: number; y: number; d: number };\n\n// Organic border-radius keyframes: the blob never settles into a perfect circle.\nconst BLOB = [\n  \"42% 58% 61% 39% / 47% 41% 59% 53%\",\n  \"58% 42% 39% 61% / 55% 61% 39% 45%\",\n  \"49% 51% 56% 44% / 39% 57% 43% 61%\",\n  \"42% 58% 61% 39% / 47% 41% 59% 53%\",\n];\nconst EASE: [number, number, number, number] = [0.65, 0, 0.35, 1];\n\n/** Point (px, relative to the element) plus the diameter needed to reach its farthest corner. */\nfunction measure(el: HTMLElement, clientX?: number, clientY?: number) {\n  const rect = el.getBoundingClientRect();\n  const x = clientX === undefined ? rect.width / 2 : clientX - rect.left;\n  const y = clientY === undefined ? rect.height / 2 : clientY - rect.top;\n  const d = 2 * Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y));\n  return { x, y, d };\n}\n\n/** Writes the fill origin to CSS variables, so pointer tracking never re-renders. */\nfunction placeOrigin(el: HTMLElement, clientX?: number, clientY?: number) {\n  const { x, y, d } = measure(el, clientX, clientY);\n  el.style.setProperty(\"--lb-x\", `${x}px`);\n  el.style.setProperty(\"--lb-y\", `${y}px`);\n  el.style.setProperty(\"--lb-d\", `${d}px`);\n}\n\n/**\n * A button flooded with colour from wherever the pointer enters, draining back out\n * through the exit point. Keyboard focus fills from the centre.\n */\nexport const LiquidButton = React.forwardRef<HTMLButtonElement, LiquidButtonProps>(function LiquidButton(\n  {\n    accent = \"#FF7448\",\n    inkColor = \"#0B0D0F\",\n    duration = 600,\n    loading = false,\n    loadingLabel = \"Loading\",\n    wobble = true,\n    variant = \"outline\",\n    rounded = 999,\n    className = \"\",\n    style,\n    disabled,\n    children,\n    onClick,\n    onPointerEnter,\n    onPointerLeave,\n    onPointerDown,\n    onFocus,\n    onBlur,\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = useReducedMotion();\n  const innerRef = React.useRef<HTMLButtonElement>(null);\n  React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);\n\n  const [filled, setFilled] = React.useState(false);\n  const [ripples, setRipples] = React.useState<Ripple[]>([]);\n  // True once the fill has fully covered the button.\n  const fullRef = React.useRef(false);\n  const rippleId = React.useRef(0);\n\n  const showFill = (filled || loading) && !disabled;\n  const seconds = Math.max(duration, 0) / 1000;\n  const animateBlob = wobble && !reduceMotion && showFill;\n\n  // Fires once the fill has had time to cover the button completely.\n  const fullTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  React.useEffect(() => () => clearTimeout(fullTimer.current), []);\n\n  // A loading state set programmatically (no hover) still needs a sensible origin.\n  React.useEffect(() => {\n    if (loading && !filled && innerRef.current) placeOrigin(innerRef.current);\n  }, [loading, filled]);\n\n  const fill = (clientX?: number, clientY?: number) => {\n    if (disabled || !innerRef.current) return;\n    fullRef.current = false;\n    placeOrigin(innerRef.current, clientX, clientY);\n    setFilled(true);\n    clearTimeout(fullTimer.current);\n    fullTimer.current = setTimeout(() => {\n      fullRef.current = true;\n    }, Math.max(duration, 0) * 1.1);\n  };\n\n  const drain = (clientX?: number, clientY?: number) => {\n    clearTimeout(fullTimer.current);\n    // Only re-anchor at the exit point when the button is fully covered, otherwise the blob would jump.\n    if (fullRef.current && innerRef.current) placeOrigin(innerRef.current, clientX, clientY);\n    fullRef.current = false;\n    setFilled(false);\n  };\n\n  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {\n    if (loading) {\n      // Blocks activation (including form submission) without removing focus.\n      event.preventDefault();\n      return;\n    }\n    onClick?.(event);\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {\n    onPointerDown?.(event);\n    if (loading || disabled || reduceMotion) return;\n    if (!innerRef.current) return;\n    const { x, y, d } = measure(innerRef.current, event.clientX, event.clientY);\n    rippleId.current += 1;\n    const id = rippleId.current;\n    setRipples((list) => [...list.slice(-3), { id, x, y, d }]);\n  };\n\n  const fillScale = wobble && !reduceMotion ? 1.18 : 1.02;\n  const fillTransition = (lead: boolean) => {\n    if (reduceMotion) return { opacity: { duration: 0.15 } };\n    // The pale \"meniscus\" layer leads on the way in and trails on the way out.\n    const scaleDuration = showFill ? seconds * (lead ? 0.72 : 1) : seconds * (lead ? 1 : 0.72);\n    return {\n      scale: { duration: scaleDuration, ease: EASE, delay: showFill && !lead ? seconds * 0.06 : 0 },\n      borderRadius: animateBlob ? { duration: 2.6, ease: \"easeInOut\" as const, repeat: Infinity } : { duration: 0.3 },\n    };\n  };\n\n  const layer = (lead: boolean) => (\n    <motion.span\n      className=\"absolute left-[var(--lb-x,50%)] top-[var(--lb-y,50%)] h-[var(--lb-d,200%)] w-[var(--lb-d,200%)] will-change-transform\"\n      style={{\n        x: \"-50%\",\n        y: \"-50%\",\n        background: lead ? \"color-mix(in oklab, var(--lb-accent) 42%, transparent)\" : \"var(--lb-accent)\",\n      }}\n      initial={false}\n      animate={\n        reduceMotion\n          ? { opacity: showFill ? 1 : 0, scale: fillScale, borderRadius: \"50%\" }\n          : { scale: showFill ? fillScale : 0, borderRadius: animateBlob ? BLOB : \"50%\" }\n      }\n      transition={fillTransition(lead)}\n    />\n  );\n\n  const variantClass =\n    variant === \"ink\"\n      ? \"bg-zinc-950 text-white dark:bg-white dark:text-zinc-950\"\n      : \"border border-zinc-900/15 bg-white/50 text-zinc-900 dark:border-white/15 dark:bg-white/[0.03] dark:text-zinc-100\";\n\n  return (\n    <button\n      ref={innerRef}\n      type=\"button\"\n      disabled={disabled}\n      aria-busy={loading || undefined}\n      aria-disabled={loading || undefined}\n      onClick={handleClick}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        fill(event.clientX, event.clientY);\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        drain(event.clientX, event.clientY);\n      }}\n      onPointerDown={handlePointerDown}\n      onFocus={(event) => {\n        onFocus?.(event);\n        if (event.currentTarget.matches(\":focus-visible\")) fill();\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        if (!event.currentTarget.matches(\":hover\")) drain();\n      }}\n      className={`relative isolate inline-flex h-12 select-none items-center justify-center overflow-hidden px-7 text-sm font-medium tracking-tight transition-transform duration-150 active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--lb-accent)] disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress motion-reduce:active:scale-100 ${variantClass} ${className}`}\n      style={{ borderRadius: rounded, [\"--lb-accent\" as string]: accent, [\"--lb-ink\" as string]: inkColor, ...style }}\n      {...props}\n    >\n      {/* Liquid layers — purely decorative. */}\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 overflow-hidden\" style={{ borderRadius: \"inherit\" }}>\n        {layer(true)}\n        {layer(false)}\n        {ripples.map((r) => (\n          <motion.span\n            key={r.id}\n            className=\"absolute rounded-full bg-white/40 mix-blend-soft-light\"\n            style={{ left: r.x, top: r.y, width: r.d, height: r.d, x: \"-50%\", y: \"-50%\" }}\n            initial={{ scale: 0, opacity: 0.9 }}\n            animate={{ scale: 1, opacity: 0 }}\n            transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }}\n            onAnimationComplete={() => setRipples((list) => list.filter((item) => item.id !== r.id))}\n          />\n        ))}\n      </span>\n\n      <span\n        className={`inline-flex items-center gap-2 transition-[color,opacity] ${loading ? \"opacity-0\" : \"opacity-100\"} ${showFill ? \"text-[var(--lb-ink)]\" : \"\"}`}\n        style={{\n          transitionDuration: reduceMotion ? \"120ms\" : `${Math.round(Math.max(duration, 0) * 0.45)}ms`,\n          transitionDelay: showFill && !reduceMotion ? `${Math.round(Math.max(duration, 0) * 0.18)}ms` : \"0ms\",\n        }}\n      >\n        {children}\n      </span>\n\n      {loading && (\n        <span className=\"absolute inset-0 flex items-center justify-center text-[var(--lb-ink)]\">\n          <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" className=\"size-5 animate-spin motion-reduce:animate-none\" fill=\"none\">\n            <circle cx=\"12\" cy=\"12\" r=\"9\" stroke=\"currentColor\" strokeOpacity=\"0.22\" strokeWidth=\"2\" />\n            <path d=\"M21 12a9 9 0 0 0-9-9\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n          </svg>\n          <span className=\"sr-only\">{loadingLabel}</span>\n        </span>\n      )}\n    </button>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/liquid-button"
}
