{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"button","type":"registry:ui","title":"Button","description":"Pill button in twelve styles and five sizes with a spring press, leading and trailing icons, inline links whose underline draws in, a Spinner loading state, an async state machine and a magnetic hover.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":["@geturui/spinner"],"files":[{"path":"registry/geturui/ui/button.tsx","type":"registry:ui","target":"components/ui/button.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useMotionValue, useSpring, type HTMLMotionProps } from \"motion/react\";\nimport { ArrowRight, ArrowUpRight, Check } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Spinner, type SpinnerSize } from \"@/components/ui/spinner\";\n\nexport { buttonClasses, isLinkVariant, type ButtonVariant, type ButtonSize } from \"./button-classes\";\nimport { buttonClasses, isLinkVariant, type ButtonVariant, type ButtonSize } from \"./button-classes\";\n\nconst REDUCE = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduce = (cb: () => void) => {\n  const m = window.matchMedia(REDUCE);\n  m.addEventListener(\"change\", cb);\n  return () => m.removeEventListener(\"change\", cb);\n};\n/** Reduced-motion preference that is false on the server and during hydration, so the first render matches. */\nfunction useReducedMotionSafe() {\n  return React.useSyncExternalStore(subscribeReduce, () => window.matchMedia(REDUCE).matches, () => false);\n}\n\nexport type ButtonIconComponent = React.ComponentType<{ className?: string; \"aria-hidden\"?: boolean | \"true\" | \"false\" }>;\n/** An icon component (e.g. a lucide icon) or an already-rendered node. */\nexport type ButtonIcon = ButtonIconComponent | React.ReactNode;\n\ninterface ButtonVisualProps {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  /** Trailing glyph that nudges on hover. */\n  trailing?: \"arrow\" | \"external\" | React.ReactNode;\n  /** Icon before the label. Pass a component (`Plus`) or a node (`<Plus />`). */\n  iconLeading?: ButtonIcon;\n  /** Icon after the label. Pass a component or a node. */\n  iconTrailing?: ButtonIcon;\n  /** Shows a spinner, keeps the width and blocks presses (focus stays on the button). */\n  loading?: boolean;\n  /** Keep the label visible beside the spinner while loading. */\n  showTextWhileLoading?: boolean;\n  iconOnly?: boolean;\n  block?: boolean;\n  children?: React.ReactNode;\n}\n\nexport interface ButtonProps extends Omit<HTMLMotionProps<\"button\">, \"children\">, ButtonVisualProps {\n  /** Renders an <a> with the same look. The forwarded ref then points at the anchor. */\n  href?: string;\n  target?: string;\n  rel?: string;\n  download?: boolean | string;\n}\n\nexport interface ButtonLinkProps extends Omit<HTMLMotionProps<\"a\">, \"children\">, ButtonVisualProps {\n  href?: string;\n  /** Renders without href and with aria-disabled, so it is skipped by Tab and can't be followed. */\n  disabled?: boolean;\n}\n\nconst iconClass: Record<ButtonSize, string> = { xs: \"size-3.5\", sm: \"size-4\", md: \"size-4\", lg: \"size-[18px]\", xl: \"size-5\" };\nconst spinnerSize: Record<ButtonSize, SpinnerSize> = { xs: \"xs\", sm: \"xs\", md: \"sm\", lg: \"sm\", xl: \"md\" };\n\nfunction isComponent(icon: ButtonIcon): icon is ButtonIconComponent {\n  if (typeof icon === \"function\") return true;\n  // forwardRef / memo components (lucide icons) are objects carrying $$typeof but are not elements.\n  return typeof icon === \"object\" && icon !== null && !React.isValidElement(icon) && \"$$typeof\" in icon;\n}\n\nfunction renderIcon(icon: ButtonIcon | undefined, className: string) {\n  if (icon == null || icon === false) return null;\n  if (isComponent(icon)) return React.createElement(icon, { className, \"aria-hidden\": true });\n  return <span className={cn(\"inline-flex shrink-0 [&>svg]:size-full\", className)}>{icon}</span>;\n}\n\nfunction Trailing({ trailing, size }: { trailing: ButtonProps[\"trailing\"]; size: ButtonSize }) {\n  if (!trailing) return null;\n  const s = iconClass[size];\n  if (trailing === \"arrow\")\n    return <ArrowRight aria-hidden className={cn(s, \"transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:translate-x-[3px]\")} />;\n  if (trailing === \"external\")\n    return <ArrowUpRight aria-hidden className={cn(s, \"transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:-translate-y-0.5 group-hover/btn:translate-x-0.5\")} />;\n  return <>{trailing}</>;\n}\n\n/** Shared inner layout: leading icon (or spinner), label, trailing glyphs, and the centred spinner overlay. */\nfunction ButtonInner({\n  size,\n  loading,\n  showTextWhileLoading,\n  iconLeading,\n  iconTrailing,\n  trailing,\n  children,\n}: Required<Pick<ButtonVisualProps, \"size\">> & Omit<ButtonVisualProps, \"size\" | \"variant\" | \"iconOnly\" | \"block\">) {\n  const reduce = useReducedMotionSafe();\n  const inline = loading && showTextWhileLoading;\n  const s = iconClass[size];\n  const spin = (\n    <motion.span\n      key=\"spin\"\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(4px)\" }}\n      animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(4px)\" }}\n      transition={{ type: \"spring\", stiffness: 520, damping: 30 }}\n      className=\"inline-flex\"\n    >\n      <Spinner size={spinnerSize[size]} label={null} />\n    </motion.span>\n  );\n  const leading = iconLeading ? renderIcon(iconLeading, cn(s, \"transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:scale-110\")) : null;\n  return (\n    <>\n      <span\n        className={cn(\n          \"inline-flex items-center gap-[inherit] transition-[opacity,filter] duration-200\",\n          loading && !inline && \"opacity-0 blur-[2px]\",\n        )}\n      >\n        {inline ? <AnimatePresence initial={false}>{spin}</AnimatePresence> : leading}\n        {children}\n        {renderIcon(iconTrailing, cn(s, \"transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:translate-x-0.5\"))}\n        <Trailing trailing={trailing} size={size} />\n      </span>\n      <AnimatePresence initial={false}>\n        {loading && !inline && (\n          <motion.span key=\"overlay\" className=\"absolute inset-0 grid place-items-center\" aria-hidden>\n            {spin}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </>\n  );\n}\n\n/** Anchor that looks like a Button: same variants, sizes, icons and spring press. */\nexport const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(function ButtonLink(\n  { variant = \"primary\", size = \"md\", trailing, iconLeading, iconTrailing, loading, showTextWhileLoading, iconOnly, block, className, children, disabled, href, onClick, ...props },\n  ref,\n) {\n  const inert = disabled || loading;\n  const link = isLinkVariant(variant);\n  return (\n    <motion.a\n      ref={ref}\n      href={inert ? undefined : href}\n      aria-disabled={inert || undefined}\n      aria-busy={loading || undefined}\n      data-loading={loading ? \"\" : undefined}\n      whileTap={inert || link ? undefined : { scale: 0.96 }}\n      transition={{ type: \"spring\", stiffness: 520, damping: 26 }}\n      onClick={(e) => {\n        if (inert) {\n          e.preventDefault();\n          return;\n        }\n        onClick?.(e);\n      }}\n      className={buttonClasses({ variant, size, iconOnly, block, className: cn(disabled && \"pointer-events-none opacity-45\", className) })}\n      {...props}\n    >\n      <ButtonInner\n        size={size}\n        loading={loading}\n        showTextWhileLoading={showTextWhileLoading}\n        iconLeading={iconLeading}\n        iconTrailing={iconTrailing}\n        trailing={trailing}\n      >\n        {children}\n      </ButtonInner>\n    </motion.a>\n  );\n});\n\n/**\n * Pill button with a spring press (scale .96), leading/trailing icons, a Spinner loading state and five sizes.\n * Pass `href` to render an anchor with the same look.\n */\nexport const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(\n  {\n    variant = \"primary\",\n    size = \"md\",\n    trailing,\n    iconLeading,\n    iconTrailing,\n    loading,\n    showTextWhileLoading,\n    iconOnly,\n    block,\n    className,\n    children,\n    disabled,\n    type = \"button\",\n    href,\n    onClick,\n    ...props\n  },\n  ref,\n) {\n  if (href !== undefined) {\n    // Button-only attributes are dropped; everything else (aria-*, data-*, handlers, target, rel) carries over.\n    const { form: _form, formAction: _fa, formMethod: _fm, formNoValidate: _fnv, formTarget: _ft, name: _n, value: _v, ...rest } = props;\n    void [_form, _fa, _fm, _fnv, _ft, _n, _v];\n    return (\n      <ButtonLink\n        ref={ref as unknown as React.Ref<HTMLAnchorElement>}\n        href={href}\n        variant={variant}\n        size={size}\n        trailing={trailing}\n        iconLeading={iconLeading}\n        iconTrailing={iconTrailing}\n        loading={loading}\n        showTextWhileLoading={showTextWhileLoading}\n        iconOnly={iconOnly}\n        block={block}\n        disabled={disabled}\n        className={className}\n        onClick={onClick as unknown as React.MouseEventHandler<HTMLAnchorElement>}\n        {...(rest as unknown as Omit<ButtonLinkProps, \"href\">)}\n      >\n        {children}\n      </ButtonLink>\n    );\n  }\n  const link = isLinkVariant(variant);\n  return (\n    <motion.button\n      ref={ref}\n      type={type}\n      whileTap={link ? undefined : { scale: 0.96 }}\n      transition={{ type: \"spring\", stiffness: 520, damping: 26 }}\n      disabled={disabled}\n      aria-disabled={loading || undefined}\n      aria-busy={loading || undefined}\n      data-loading={loading ? \"\" : undefined}\n      onClick={(e) => {\n        // Loading keeps focus (no native disabled) but still blocks presses and implicit form submits.\n        if (loading) {\n          e.preventDefault();\n          return;\n        }\n        onClick?.(e);\n      }}\n      className={buttonClasses({ variant, size, iconOnly, block, className })}\n      {...props}\n    >\n      <ButtonInner\n        size={size}\n        loading={loading}\n        showTextWhileLoading={showTextWhileLoading}\n        iconLeading={iconLeading}\n        iconTrailing={iconTrailing}\n        trailing={trailing}\n      >\n        {children}\n      </ButtonInner>\n    </motion.button>\n  );\n});\n\nexport interface StatefulButtonProps extends Omit<ButtonProps, \"onClick\" | \"loading\"> {\n  /** Resolve to show the success state, reject to shake back. */\n  onAction: () => Promise<unknown>;\n  successLabel?: React.ReactNode;\n}\n\n/** Button that runs an async action: label → spinner → check, with a width spring between states. */\nexport function StatefulButton({ onAction, successLabel = \"Done\", children, ...props }: StatefulButtonProps) {\n  const [state, setState] = React.useState<\"idle\" | \"loading\" | \"success\" | \"error\">(\"idle\");\n  async function run() {\n    if (state === \"loading\") return;\n    setState(\"loading\");\n    try {\n      await onAction();\n      setState(\"success\");\n      setTimeout(() => setState(\"idle\"), 1800);\n    } catch {\n      setState(\"error\");\n      setTimeout(() => setState(\"idle\"), 600);\n    }\n  }\n  return (\n    <motion.div animate={state === \"error\" ? { x: [0, -6, 6, -3, 3, 0] } : { x: 0 }} transition={{ duration: 0.36 }} className=\"inline-flex\">\n      {/* The button springs its own width; the label is layout-corrected so it doesn't squash mid-resize. */}\n      <Button layout {...props} loading={state === \"loading\"} onClick={run}>\n        <motion.span key={state === \"success\" ? \"success\" : \"label\"} layout=\"position\" className=\"inline-flex items-center gap-[inherit]\">\n          {state === \"success\" ? (\n            <>\n              <Check className=\"size-4\" aria-hidden />\n              {successLabel}\n            </>\n          ) : (\n            children\n          )}\n        </motion.span>\n      </Button>\n    </motion.div>\n  );\n}\n\n/** Button that leans toward the cursor while hovered and springs home on leave. */\nexport function MagneticButton({ strength = 0.35, className, ...props }: ButtonProps & { strength?: number }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const sx = useSpring(x, { stiffness: 260, damping: 18 });\n  const sy = useSpring(y, { stiffness: 260, damping: 18 });\n  return (\n    <motion.div\n      ref={ref}\n      style={{ x: sx, y: sy }}\n      className=\"inline-flex\"\n      onPointerMove={(e) => {\n        const r = ref.current?.getBoundingClientRect();\n        if (!r) return;\n        x.set((e.clientX - (r.left + r.width / 2)) * strength);\n        y.set((e.clientY - (r.top + r.height / 2)) * strength);\n      }}\n      onPointerLeave={() => {\n        x.set(0);\n        y.set(0);\n      }}\n    >\n      <Button className={className} {...props} />\n    </motion.div>\n  );\n}\n"},{"path":"registry/geturui/ui/button-classes.ts","type":"registry:ui","target":"components/ui/button-classes.ts","content":"import { cn } from \"@/lib/utils\";\n\nconst variants = {\n  primary: \"bg-volt text-on-volt hover:shadow-glow\",\n  secondary: \"bg-surface text-ink border border-border hover:bg-surface-raised hover:border-border-strong\",\n  outline: \"bg-transparent text-ink border border-border-strong hover:bg-surface-hover\",\n  ghost: \"bg-transparent text-ink-muted hover:bg-surface-hover hover:text-ink\",\n  inverse: \"bg-ink text-bg hover:opacity-90\",\n  pro: \"bg-ember text-on-ember hover:shadow-[0_8px_28px_var(--ember-soft)]\",\n  /** Outlined destructive action (kept for compatibility). */\n  danger: \"bg-transparent text-danger border border-danger hover:bg-surface-hover\",\n  /** Solid destructive action: the final \"Delete\" in a confirm dialog. */\n  destructive: \"bg-danger text-bg hover:shadow-[0_8px_28px_var(--danger-soft)]\",\n  /** Quiet destructive action: \"Remove\" in a list row. */\n  \"destructive-ghost\": \"bg-transparent text-danger hover:bg-danger-soft\",\n  /** Inline volt link; the underline draws left to right on hover. */\n  link: \"text-volt-ink\",\n  /** Inline muted link that brightens to ink on hover. */\n  \"link-muted\": \"text-ink-muted hover:text-ink\",\n  /** Inline destructive link. */\n  \"link-destructive\": \"text-danger\",\n} as const;\n\nconst sizes = {\n  xs: \"h-7 px-2.5 text-xs gap-1\",\n  sm: \"h-8 px-3.5 text-[13px] gap-1.5\",\n  md: \"h-10 px-[18px] text-sm gap-2\",\n  lg: \"h-12 px-6 text-[15px] gap-2\",\n  xl: \"h-14 px-7 text-base gap-2.5\",\n} as const;\n\nconst iconSizes = { xs: \"size-7\", sm: \"size-8\", md: \"size-10\", lg: \"size-12\", xl: \"size-14\" } as const;\n\n/** Link variants keep only the type scale: no height, padding or pill. */\nconst linkSizes = { xs: \"text-xs gap-1\", sm: \"text-[13px] gap-1\", md: \"text-sm gap-1.5\", lg: \"text-[15px] gap-1.5\", xl: \"text-base gap-2\" } as const;\n\nexport type ButtonVariant = keyof typeof variants;\nexport type ButtonSize = keyof typeof sizes;\n\n/** True for the inline text-link variants (no padding, underline on hover). */\nexport function isLinkVariant(variant: ButtonVariant) {\n  return variant === \"link\" || variant === \"link-muted\" || variant === \"link-destructive\";\n}\n\n/** Class string for anything that should look like a GetUrUI button (links included). Safe to call on the server. */\nexport function buttonClasses({\n  variant = \"primary\",\n  size = \"md\",\n  iconOnly,\n  block,\n  className,\n}: { variant?: ButtonVariant; size?: ButtonSize; iconOnly?: boolean; block?: boolean; className?: string }) {\n  const link = isLinkVariant(variant);\n  return cn(\n    \"group/btn relative inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-full font-medium tracking-[-0.1px]\",\n    \"transition-[background-color,box-shadow,color,border-color,opacity] duration-150 disabled:pointer-events-none disabled:opacity-45\",\n    \"data-[loading]:pointer-events-none\",\n    \"focus-visible:shadow-[var(--focus-ring)] outline-none\",\n    variants[variant],\n    link\n      ? cn(\n          \"h-auto rounded-xs p-0 transition-[background-size,color,opacity] duration-300 ease-[var(--ease-spring)]\",\n          \"[background-image:linear-gradient(currentColor,currentColor)] bg-no-repeat [background-position:0_100%] [background-size:0%_1px] hover:[background-size:100%_1px]\",\n          linkSizes[size],\n        )\n      : iconOnly\n        ? cn(iconSizes[size], \"p-0\")\n        : sizes[size],\n    block && \"w-full\",\n    className,\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","button","cta","link","destructive","loading","async","magnetic","spring"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/button"}}