{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["@radix-ui/react-slot","class-variance-authority","motion"],"description":"A polished button component with gradient variants and press animation","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cn } from \"@/lib/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { ButtonHTMLAttributes, ReactNode, Ref } from \"react\";\nimport { useEffect, useRef } from \"react\";\n\n/**\n * SmoothButton — the first primitive of the SmoothUI Design System.\n *\n * Three orthogonal axes (see design-system/components/button.md):\n *   variant → appearance only (solid | soft | outline | ghost | link | candy)\n *   color   → hue (accent | neutral | destructive | blue | amber | green)\n *   size    → xs | sm | default | lg + icon-*\n *\n * The `color` axis only sets CSS custom props (--btn, --btn-hover, --btn-fg);\n * each `variant` consumes them generically, so 6 variants × 6 colors stay 12\n * class strings, not 36. When no `color` is given, CSS var fallbacks apply\n * (candy → brand, everything else → neutral/foreground).\n *\n * Legacy variants `default` / `secondary` / `destructive` are preserved verbatim\n * for back-compat with existing call sites and ignore the `color` axis.\n */\nconst smoothButtonVariants = cva(\n  \"relative inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap font-medium outline-none ring-offset-background transition-[transform,background-color,border-color,color,box-shadow] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  {\n    defaultVariants: {\n      shape: \"default\",\n      size: \"default\",\n      variant: \"default\",\n    },\n    variants: {\n      color: {\n        accent:\n          \"[--btn-fg:#fff] [--btn-hover:var(--color-brand-secondary)] [--btn:var(--color-brand)]\",\n        amber:\n          \"[--btn-fg:var(--color-amber-fg)] [--btn-hover:var(--color-amber-hover)] [--btn:var(--color-amber)]\",\n        blue: \"[--btn-fg:var(--color-blue-fg)] [--btn-hover:var(--color-blue-hover)] [--btn:var(--color-blue)]\",\n        destructive:\n          \"[--btn-fg:#fff] [--btn-hover:color-mix(in_oklab,var(--color-destructive)_85%,black)] [--btn:var(--color-destructive)]\",\n        green:\n          \"[--btn-fg:var(--color-green-fg)] [--btn-hover:var(--color-green-hover)] [--btn:var(--color-green)]\",\n        neutral:\n          \"[--btn-fg:var(--color-background)] [--btn-hover:var(--color-smooth-900)] [--btn:var(--color-foreground)]\",\n      },\n      shape: {\n        default: \"\",\n        pill: \"rounded-full!\",\n        square: \"rounded-none!\",\n      },\n      size: {\n        default: \"h-10 gap-2 rounded-md px-4 py-2 text-sm [&_svg]:size-4\",\n        icon: \"size-10 rounded-md [&_svg]:size-4\",\n        \"icon-lg\": \"size-11 rounded-lg [&_svg]:size-5\",\n        \"icon-sm\": \"size-9 rounded-md [&_svg]:size-4\",\n        lg: \"h-11 gap-2 rounded-lg px-8 text-base [&_svg]:size-5\",\n        sm: \"h-9 gap-1.5 rounded-md px-3 text-sm [&_svg]:size-4\",\n        xs: \"h-7 gap-1.5 rounded-sm px-2.5 text-xs [&_svg]:size-3.5\",\n      },\n      variant: {\n        candy:\n          \"border-[0.5px] border-white/25 bg-gradient-to-b from-[var(--btn,var(--color-brand))] to-[var(--btn-hover,var(--color-brand-secondary))] text-[var(--btn-fg,#fff)] text-shadow-sm shadow-black/20 shadow-md ring-1 ring-[color-mix(in_oklab,var(--color-foreground)_15%,var(--btn,var(--color-brand)))] hover:from-[var(--btn-hover,var(--color-brand-secondary))] hover:to-[var(--btn-hover,var(--color-brand-secondary))] [&_svg]:drop-shadow-sm\",\n        // --- legacy (preserved verbatim, ignore `color`) ---\n        default:\n          \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n        destructive:\n          \"bg-gradient-to-b from-[#FD4B4E] to-destructive text-shadow-sm text-white shadow-[0px_1px_2px_rgba(0,0,0,0.4),0px_0px_0px_1px_#F61418,inset_0px_0.75px_0px_rgba(255,255,255,0.2)] hover:from-destructive hover:to-destructive\",\n        ghost:\n          \"text-[var(--btn,var(--color-foreground))] hover:bg-[color-mix(in_oklab,var(--btn,var(--color-foreground))_10%,transparent)]\",\n        link: \"text-[var(--btn,var(--color-foreground))] underline-offset-4 hover:underline\",\n        outline:\n          \"border border-transparent bg-background text-[var(--btn,var(--color-foreground))] shadow-black/15 shadow-sm ring-1 ring-foreground/10 hover:bg-primary dark:ring-foreground/15\",\n        secondary:\n          \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n        soft: \"bg-[color-mix(in_oklab,var(--btn,var(--color-foreground))_12%,transparent)] text-[var(--btn,var(--color-foreground))] hover:bg-[color-mix(in_oklab,var(--btn,var(--color-foreground))_18%,transparent)]\",\n        // --- new decoupled system (consume --btn / --btn-hover / --btn-fg) ---\n        solid:\n          \"bg-[var(--btn,var(--color-foreground))] text-[var(--btn-fg,var(--color-background))] shadow-xs hover:bg-[var(--btn-hover,var(--color-smooth-900))]\",\n      },\n    },\n  }\n);\n\nexport type SmoothButtonProps = Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"prefix\" | \"color\"\n> &\n  VariantProps<typeof smoothButtonVariants> & {\n    asChild?: boolean;\n    /** Show a spinner that morphs the button width without layout jump. */\n    loading?: boolean;\n    /** Content before the label (icon, Kbd…). */\n    prefix?: ReactNode;\n    /** Content after the label. */\n    suffix?: ReactNode;\n    /** Opt-in Safari force-press depth (scales to 0.94 under pressure). */\n    forcePress?: boolean;\n    ref?: Ref<HTMLButtonElement>;\n  };\n\nconst Spinner = () => (\n  <svg\n    aria-hidden=\"true\"\n    className=\"size-[1em] animate-spin\"\n    fill=\"none\"\n    viewBox=\"0 0 24 24\"\n  >\n    <circle\n      className=\"opacity-25\"\n      cx=\"12\"\n      cy=\"12\"\n      r=\"10\"\n      stroke=\"currentColor\"\n      strokeWidth=\"3\"\n    />\n    <path\n      className=\"opacity-90\"\n      d=\"M12 2a10 10 0 0 1 10 10\"\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeWidth=\"3\"\n    />\n  </svg>\n);\n\nfunction SmoothButton({\n  className,\n  variant,\n  color,\n  size,\n  shape,\n  asChild = false,\n  loading = false,\n  forcePress = false,\n  prefix,\n  suffix,\n  disabled,\n  children,\n  ref,\n  ...props\n}: SmoothButtonProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const localRef = useRef<HTMLButtonElement>(null);\n\n  // Safari-only force-press: deepen the press past the normal active scale.\n  useEffect(() => {\n    const node = localRef.current;\n    if (!(forcePress && node) || shouldReduceMotion) {\n      return;\n    }\n    const FORCE_THRESHOLD = 2; // Safari's \"force click\" boundary\n    const onForce = (e: Event) => {\n      const force = (e as Event & { webkitForce?: number }).webkitForce ?? 0;\n      node.style.transform = force >= FORCE_THRESHOLD ? \"scale(0.94)\" : \"\";\n    };\n    const reset = () => {\n      node.style.transform = \"\";\n    };\n    node.addEventListener(\"webkitmouseforcechanged\", onForce);\n    node.addEventListener(\"mouseup\", reset);\n    node.addEventListener(\"mouseleave\", reset);\n    return () => {\n      node.removeEventListener(\"webkitmouseforcechanged\", onForce);\n      node.removeEventListener(\"mouseup\", reset);\n      node.removeEventListener(\"mouseleave\", reset);\n    };\n  }, [forcePress, shouldReduceMotion]);\n\n  const classes = cn(\n    smoothButtonVariants({ className, color, shape, size, variant })\n  );\n\n  // asChild defers all rendering to the consumer's element — slots/loading\n  // are not injected (single-child contract of Radix Slot).\n  if (asChild) {\n    return (\n      <Slot className={classes} ref={ref} {...props}>\n        {children}\n      </Slot>\n    );\n  }\n\n  const setRefs = (node: HTMLButtonElement | null) => {\n    localRef.current = node;\n    if (typeof ref === \"function\") {\n      ref(node);\n    } else if (ref) {\n      (ref as { current: HTMLButtonElement | null }).current = node;\n    }\n  };\n\n  return (\n    <button\n      aria-busy={loading || undefined}\n      className={classes}\n      disabled={disabled || loading}\n      ref={setRefs}\n      type={props.type ?? \"button\"}\n      {...props}\n    >\n      <AnimatePresence initial={false}>\n        {loading ? (\n          <motion.span\n            animate={{ marginRight: \"0.5rem\", opacity: 1, width: \"1em\" }}\n            className=\"inline-flex shrink-0 items-center justify-center overflow-hidden\"\n            exit={{ marginRight: 0, opacity: 0, width: 0 }}\n            initial={\n              shouldReduceMotion\n                ? { marginRight: \"0.5rem\", opacity: 1, width: \"1em\" }\n                : { marginRight: 0, opacity: 0, width: 0 }\n            }\n            key=\"spinner\"\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n            }\n          >\n            <Spinner />\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n      {prefix}\n      {children}\n      {suffix}\n    </button>\n  );\n}\n\nexport default SmoothButton;\nexport { smoothButtonVariants };\n","path":"index.tsx","target":"components/smoothui/smooth-button/index.tsx","type":"registry:ui"}],"name":"smooth-button","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Smooth Button","type":"registry:ui"}