{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "droplet-button",
  "type": "registry:component",
  "title": "Droplet Button",
  "description": "A pill button that squashes when you press it and, as you let go, flicks a little crown of droplets from right where you pressed, then swallows them back up.",
  "categories": [
    "component",
    "Liquid"
  ],
  "dependencies": [
    "@base-ui/react",
    "class-variance-authority",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/droplet-button.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactElement,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst dropletButtonVariants = cva(\n  \"group/droplet relative isolate inline-flex [--droplet-shadow:color-mix(in_srgb,var(--goo)_28%,transparent)] dark:[--droplet-shadow:color-mix(in_srgb,var(--color-black)_48%,transparent)] shrink-0 cursor-pointer touch-manipulation select-none items-center justify-center whitespace-nowrap rounded-full font-medium outline-none [-webkit-tap-highlight-color:transparent] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:not-data-loading:opacity-64 data-loading:pointer-events-none pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:min-h-11 pointer-coarse:after:-translate-y-1/2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  {\n    variants: {\n      tone: {\n        primary:\n          \"text-primary-foreground [--goo:var(--color-primary)] hover:[--goo:color-mix(in_srgb,var(--color-primary)_90%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-primary)_84%,var(--color-background))]\",\n        neutral:\n          \"text-foreground [--goo:color-mix(in_srgb,var(--color-foreground)_7%,var(--color-background))] hover:[--goo:color-mix(in_srgb,var(--color-foreground)_10%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-foreground)_13%,var(--color-background))]\",\n        info: \"text-white [--goo:var(--color-info)] hover:[--goo:color-mix(in_srgb,var(--color-info)_90%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-info)_84%,var(--color-background))]\",\n        success:\n          \"text-white [--goo:var(--color-success)] hover:[--goo:color-mix(in_srgb,var(--color-success)_90%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-success)_84%,var(--color-background))]\",\n        destructive:\n          \"text-white [--goo:var(--color-destructive)] hover:[--goo:color-mix(in_srgb,var(--color-destructive)_90%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-destructive)_84%,var(--color-background))]\",\n      },\n      size: {\n        sm: \"h-8 gap-1.5 px-3.5 text-sm sm:text-xs [&_svg:not([class*='size-'])]:size-3.5\",\n        default: \"h-10 gap-2 px-5 text-base sm:text-sm\",\n        lg: \"h-12 gap-2 px-6 text-base [&_svg:not([class*='size-'])]:size-4.5\",\n      },\n    },\n    defaultVariants: { tone: \"primary\", size: \"default\" },\n  },\n);\n\nconst SIZES = {\n  sm: { drop: 4.5, satellite: 3, rise: 13, blur: 3, dot: 5 },\n  default: { drop: 5.5, satellite: 3.75, rise: 17, blur: 3.5, dot: 6 },\n  lg: { drop: 6.5, satellite: 4.5, rise: 20, blur: 4, dot: 7 },\n} as const;\n\ntype Size = keyof typeof SIZES;\n\nconst squash = { stiffness: 700, damping: 20, mass: 1 };\nconst rise = [0.2, 0.75, 0.4, 1] as const;\nconst fall = [0.6, 0, 0.85, 0.35] as const;\n\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\ninterface Splash {\n  id: number;\n  x: number;\n  width: number;\n}\n\nfunction Drop({\n  from,\n  to,\n  height,\n  radius,\n  duration,\n  delay = 0,\n  onLand,\n}: {\n  from: number;\n  to: number;\n  height: number;\n  radius: number;\n  duration: number;\n  delay?: number;\n  onLand?: () => void;\n}) {\n  const sunk = radius + 2;\n  return (\n    <motion.span\n      animate={{ x: [from, (from + to) / 2, to], y: [sunk, -height, sunk], scaleY: [1.25, 1, 1.2] }}\n      className=\"absolute top-0 left-0 rounded-full bg-(--goo) transition-colors duration-150\"\n      initial={{ x: from, y: sunk, scaleY: 1.25 }}\n      onAnimationComplete={onLand}\n      style={{ width: radius * 2, height: radius * 2, marginLeft: -radius, marginTop: -radius }}\n      transition={{\n        duration,\n        delay,\n        times: [0, 0.46, 1],\n        ease: \"easeInOut\",\n        x: { duration, delay, ease: \"linear\" },\n        y: { duration, delay, times: [0, 0.46, 1], ease: [rise, fall] },\n      }}\n    />\n  );\n}\n\nfunction SplashCrown({ splash, size, onLand }: { splash: Splash; size: Size; onLand: () => void }) {\n  const { drop, satellite, rise: height } = SIZES[size];\n  const flat = Math.min(splash.width / 2, 24);\n  const clamp = (x: number) => Math.min(Math.max(x, flat), splash.width - flat);\n  const x = clamp(splash.x);\n  const lean = (splash.width / 2 - x) * 0.06;\n  const spread = height * 1.3;\n  return (\n    <>\n      <Drop delay={0.03} duration={0.28} from={x - drop * 1.1} height={height * 0.42} radius={satellite} to={clamp(x - spread)} />\n      <Drop delay={0.03} duration={0.28} from={x + drop * 1.1} height={height * 0.42} radius={satellite} to={clamp(x + spread)} />\n      <Drop duration={0.38} from={x} height={height} onLand={onLand} radius={drop} to={x + lean} />\n    </>\n  );\n}\n\nfunction LiquidDots({ size, reduce }: { size: Size; reduce: boolean }) {\n  const filterId = `droplet-dots-${useId().replace(/:/g, \"\")}`;\n  const { dot } = SIZES[size];\n  const travel = dot * 1.5;\n  const swing = { duration: 0.62, repeat: Number.POSITIVE_INFINITY, repeatType: \"mirror\", ease: \"easeInOut\" } as const;\n  return (\n    <motion.span\n      animate={{ opacity: 1, scale: 1 }}\n      aria-hidden\n      className=\"pointer-events-none absolute inset-0 flex items-center justify-center\"\n      initial={{ opacity: 0, scale: 0.85 }}\n      transition={{ duration: 0.16, delay: 0.05, ease: [0.23, 1, 0.32, 1] }}\n    >\n      <svg className=\"absolute size-0\">\n        <filter colorInterpolationFilters=\"sRGB\" height=\"100%\" id={filterId} width=\"100%\" x=\"0\" y=\"0\">\n          <feGaussianBlur in=\"SourceGraphic\" result=\"blur\" stdDeviation={dot * 0.5} />\n          <feColorMatrix in=\"blur\" result=\"goo\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7\" />\n          <feMerge>\n            <feMergeNode in=\"goo\" />\n            <feMergeNode in=\"SourceGraphic\" />\n          </feMerge>\n        </filter>\n      </svg>\n      <span\n        className=\"relative flex items-center justify-center\"\n        style={{ width: travel * 2 + dot * 4, height: dot * 4, filter: reduce ? undefined : `url(#${filterId})` }}\n      >\n        {[-1, 1].map((side) => (\n          <motion.span\n            animate={reduce ? { opacity: [1, 0.4] } : { scale: [side < 0 ? 1.2 : 0.8, side < 0 ? 0.8 : 1.2] }}\n            className=\"absolute rounded-full bg-current\"\n            key={side}\n            style={{ width: dot, height: dot, x: side * travel }}\n            transition={swing}\n          />\n        ))}\n        <motion.span\n          animate={reduce ? { opacity: [0.4, 1] } : { x: [-travel, travel] }}\n          className=\"absolute rounded-full bg-current\"\n          style={{ width: dot * 0.9, height: dot * 0.9, x: reduce ? 0 : -travel }}\n          transition={swing}\n        />\n      </span>\n    </motion.span>\n  );\n}\n\nexport interface DropletButtonProps\n  extends useRender.ComponentProps<\"button\">,\n    VariantProps<typeof dropletButtonVariants> {\n  loading?: boolean;\n  splash?: boolean;\n}\n\nexport function DropletButton({\n  className,\n  tone,\n  size,\n  render,\n  children,\n  loading = false,\n  splash: splashEnabled = true,\n  disabled,\n  ...props\n}: DropletButtonProps): ReactElement {\n  const key = size ?? \"default\";\n  const { rise: height, drop, blur } = SIZES[key];\n  const reduce = useSafeReducedMotion();\n  const filterId = `droplet-${useId().replace(/:/g, \"\")}`;\n  const boxRef = useRef<HTMLSpanElement>(null);\n  const [pressed, setPressed] = useState(false);\n  const [splashes, setSplashes] = useState<Splash[]>([]);\n  const holding = useRef(false);\n  const tapTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const nextId = useRef(0);\n  const origin = useRef(0.5);\n\n  const scaleXTarget = useMotionValue(1);\n  const scaleYTarget = useMotionValue(1);\n  const scaleX = useSpring(scaleXTarget, squash);\n  const springY = useSpring(scaleYTarget, squash);\n  const ripple = useMotionValue(1);\n  const scaleY = useTransform(() => springY.get() * ripple.get());\n  const transformOrigin = useMotionValue(\"50% 100%\");\n\n  useEffect(() => () => clearTimeout(tapTimer.current), []);\n\n  const room = { x: blur * 3 + 10, top: height + drop * 2 + blur * 3 + 4, bottom: blur * 3 + 4 };\n  const animated = !reduce && splashEnabled;\n\n  const squeeze = (x?: number) => {\n    setPressed(true);\n    const width = boxRef.current?.offsetWidth ?? 0;\n    origin.current = x === undefined || !width ? 0.5 : Math.min(Math.max(x / width, 0.2), 0.8);\n    transformOrigin.set(`${origin.current * 100}% 100%`);\n    if (!animated) return;\n    scaleXTarget.set(1.035);\n    scaleYTarget.set(0.88);\n  };\n\n  const letGo = (burst: boolean) => {\n    setPressed(false);\n    scaleXTarget.set(1);\n    scaleYTarget.set(1);\n    if (!animated || !burst) return;\n    const width = boxRef.current?.offsetWidth ?? 0;\n    const id = nextId.current++;\n    setSplashes((current) => [...current.slice(-2), { id, x: origin.current * width, width }]);\n  };\n\n  const press = (x?: number) => {\n    if (holding.current) return;\n    holding.current = true;\n    squeeze(x);\n  };\n\n  const release = (burst: boolean) => {\n    if (!holding.current) return;\n    holding.current = false;\n    letGo(burst);\n  };\n\n  // Enter clicks on keydown, often before a keyup can arrive, so it plays the whole press at once.\n  const tap = () => {\n    clearTimeout(tapTimer.current);\n    squeeze();\n    tapTimer.current = setTimeout(() => letGo(true), 70);\n  };\n\n  const land = (id: number) => {\n    setSplashes((current) => current.filter((s) => s.id !== id));\n    animate(ripple, [1, 0.965, 1.012, 1], { duration: 0.3, ease: \"easeOut\" });\n  };\n\n  const localX = (event: PointerEvent) => event.clientX - (boxRef.current?.getBoundingClientRect().left ?? 0);\n\n  const isDisabled = Boolean(disabled || loading);\n\n  const defaultProps = {\n    className: cn(dropletButtonVariants({ tone, size }), className),\n    \"aria-busy\": loading || undefined,\n    \"data-loading\": loading ? \"\" : undefined,\n    \"data-pressed\": pressed ? \"\" : undefined,\n    \"data-slot\": \"droplet-button\",\n    disabled: isDisabled,\n    type: render ? undefined : (\"button\" as const),\n    onPointerDown: (event: PointerEvent<HTMLButtonElement>) => {\n      if (event.button === 0) press(localX(event));\n    },\n    onPointerUp: () => release(true),\n    onPointerLeave: () => release(false),\n    onPointerCancel: () => release(false),\n    onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => {\n      if (event.repeat) return;\n      if (event.key === \"Enter\") tap();\n      if (event.key === \" \") press();\n    },\n    onKeyUp: (event: KeyboardEvent<HTMLButtonElement>) => {\n      if (event.key === \" \") release(true);\n    },\n    onBlur: () => release(false),\n    children: (\n      <>\n        <svg aria-hidden className=\"absolute size-0\">\n          <filter colorInterpolationFilters=\"sRGB\" height=\"100%\" id={filterId} width=\"100%\" x=\"0\" y=\"0\">\n            <feGaussianBlur in=\"SourceGraphic\" result=\"blur\" stdDeviation={blur} />\n            <feColorMatrix in=\"blur\" result=\"goo\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -11\" />\n            <feMerge>\n              <feMergeNode in=\"goo\" />\n              <feMergeNode in=\"SourceGraphic\" />\n            </feMerge>\n          </filter>\n        </svg>\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute\"\n          style={{\n            inset: `-${room.top}px -${room.x}px -${room.bottom}px`,\n            filter: `${reduce ? \"\" : `url(#${filterId}) `}drop-shadow(0 1px 1.5px var(--droplet-shadow))`,\n          }}\n        >\n          <span\n            className=\"absolute\"\n            ref={boxRef}\n            style={{ inset: `${room.top}px ${room.x}px ${room.bottom}px` }}\n          >\n            <motion.span\n              className=\"absolute inset-0 rounded-full bg-(--goo) transition-colors duration-150\"\n              style={{ scaleX, scaleY, transformOrigin }}\n            />\n            {splashes.map((s) => (\n              <SplashCrown key={s.id} onLand={() => land(s.id)} size={key} splash={s} />\n            ))}\n          </span>\n        </span>\n        <motion.span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 rounded-full inset-shadow-[0_1px_--theme(--color-white/18%)]\"\n          style={{ scaleX, scaleY, transformOrigin }}\n        />\n        <span\n          className={cn(\n            \"relative inline-flex items-center gap-[inherit] transition-[opacity,scale] duration-[80ms] ease-out\",\n            animated && \"group-data-pressed/droplet:scale-[0.97]\",\n            loading && \"opacity-0\",\n          )}\n        >\n          {children}\n        </span>\n        {loading && <LiquidDots reduce={reduce} size={key} />}\n      </>\n    ),\n  };\n\n  return useRender({\n    defaultTagName: \"button\",\n    props: mergeProps<\"button\">(defaultProps, props),\n    render,\n  });\n}\n\nexport { dropletButtonVariants };\n"
    }
  ]
}