{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button",
  "type": "registry:ui",
  "title": "Button",
  "description": "Four variants, three sizes, and a loading state that never changes width.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/button.tsx",
      "content": "\"use client\";\nimport { Button as BaseButton } from \"@base-ui/react/button\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"danger\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\";\n\nconst sizes: Record<ButtonSize, { box: string; gap: string; start: string; end: string }> = {\n  sm: { box: \"h-7 rounded-md px-2 text-[12px] [&_svg:not([class*='size-'])]:size-3.5\", gap: \"gap-1.5\", start: \"pl-1.5\", end: \"pr-1.5\" },\n  md: { box: \"h-8 rounded-lg px-2.5 text-[12.5px] [&_svg:not([class*='size-'])]:size-4\", gap: \"gap-1.5\", start: \"pl-2\", end: \"pr-2\" },\n  lg: { box: \"h-9 rounded-lg px-3 text-[13px] [&_svg:not([class*='size-'])]:size-4\", gap: \"gap-2\", start: \"pl-2.5\", end: \"pr-2.5\" },\n};\n\n// Inside a ButtonGroup, secondary and ghost buttons become flat segments: the\n// group draws the border, the shadow, the hairlines and the hover wash.\nconst segment =\n  \"in-data-[slot=button-group]:rounded-none in-data-[slot=button-group]:border-0 in-data-[slot=button-group]:bg-transparent in-data-[slot=button-group]:shadow-none in-data-[slot=button-group]:hover:bg-transparent in-data-[slot=button-group]:data-popup-open:bg-transparent in-data-[slot=button-group]:active:scale-100 in-data-[slot=button-group]:focus-visible:-outline-offset-3 in-data-[slot=button-group]:focus-visible:rounded-[5px]\";\n\nconst variants: Record<ButtonVariant, string> = {\n  primary: \"bg-fg text-frame shadow-[var(--shadow)] hover:bg-fg/90 data-popup-open:bg-fg/90\",\n  secondary:\n    \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover data-popup-open:border-fg-4 data-popup-open:bg-hover \" + segment,\n  ghost: \"text-fg-2 hover:bg-hover hover:text-fg data-popup-open:bg-hover data-popup-open:text-fg \" + segment,\n  danger:\n    \"border border-danger/25 bg-danger-soft text-danger hover:border-danger/45 hover:bg-danger/15 data-popup-open:bg-danger/15\",\n};\n\n/**\n * The button's classes on their own, for putting the look on an element that\n * isn't a Button (a menu trigger, a label, a router link you render yourself).\n */\nexport function buttonVariants({\n  variant = \"secondary\",\n  size = \"md\",\n  leading = false,\n  trailing = false,\n}: { variant?: ButtonVariant; size?: ButtonSize; leading?: boolean; trailing?: boolean } = {}) {\n  const s = sizes[size];\n  return cn(\n    \"group/button relative inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium tracking-[-0.005em]\",\n    \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n    \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n    // Press: quick in (75ms), softer out (150ms). Busy buttons don't flinch.\n    \"transition-[background-color,border-color,color,scale] duration-150 ease-out-quart active:not-data-busy:scale-[0.97] active:not-data-busy:duration-75\",\n    \"data-disabled:not-data-busy:pointer-events-none data-disabled:not-data-busy:opacity-50 data-busy:cursor-progress\",\n    // A 28px button still takes a 44px tap on touch screens.\n    size === \"sm\" && \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2\",\n    s.box,\n    leading && s.start,\n    trailing && s.end,\n    variants[variant],\n  );\n}\n\n/** The busy glyph: a faint track and a quarter arc turning on it. Sized by its parent's svg rule. */\nexport function Spinner({ className, ...rest }: React.ComponentProps<\"svg\">) {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden focusable={false} className={cn(\"shrink-0 animate-[spin_0.7s_linear_infinite]\", className)} {...rest}>\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" stroke=\"currentColor\" strokeOpacity=\"0.22\" strokeWidth=\"1.5\" />\n      <path d=\"M8 2.25A5.75 5.75 0 0 1 13.75 8\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/**\n * Loading as the eye sees it: nothing for the first 150ms (most requests are done\n * by then and a flash reads as a glitch), then held for at least 400ms so it\n * never flickers.\n */\nexport function useBusyDisplay(loading: boolean, { delay = 150, minimum = 400 } = {}) {\n  const [shown, setShown] = useState(false);\n  const since = useRef(0);\n  useEffect(() => {\n    if (loading) {\n      const t = window.setTimeout(() => {\n        since.current = performance.now();\n        setShown(true);\n      }, delay);\n      return () => window.clearTimeout(t);\n    }\n    const left = Math.max(0, minimum - (performance.now() - since.current));\n    const t = window.setTimeout(() => setShown(false), left);\n    return () => window.clearTimeout(t);\n  }, [loading, delay, minimum]);\n  return shown;\n}\n\ntype Common = {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  /** Icon before the label. Decorative; the label names the button. */\n  leadingIcon?: React.ReactNode;\n  /** Icon after the label: a chevron, an arrow, an external-link mark. */\n  trailingIcon?: React.ReactNode;\n};\n\nexport type ButtonProps = Omit<BaseButton.Props, \"render\" | \"className\"> &\n  Common & {\n    className?: string;\n    /** Blocks presses at once and shows a spinner over the label after 150ms, without changing the width. */\n    loading?: boolean;\n    /** Replaces the label while loading (\"Deploying…\"). The button reserves room for the longer of the two. */\n    loadingText?: string;\n    /**\n     * Render as another element, usually a link: `render={<a href=\"/new\" />}`.\n     * It gets the look and the content, and keeps its own semantics (no button role).\n     */\n    render?: React.ReactElement;\n  };\n\nexport function Button({ render, ...props }: ButtonProps) {\n  if (render) return <ButtonElement render={render} {...props} />;\n  return <ButtonRoot {...props} />;\n}\n\nfunction ButtonRoot({\n  variant = \"secondary\",\n  size = \"md\",\n  leadingIcon,\n  trailingIcon,\n  loading = false,\n  loadingText,\n  disabled,\n  className,\n  children,\n  ...rest\n}: Omit<ButtonProps, \"render\">) {\n  const shown = useBusyDisplay(loading);\n  const busy = loading || shown;\n  return (\n    <BaseButton\n      data-variant={variant}\n      data-size={size}\n      // Busy styling starts with the press; the spinner itself waits its 150ms.\n      data-busy={busy ? \"\" : undefined}\n      aria-busy={busy || undefined}\n      disabled={disabled || busy}\n      // Keep focus on the button while it works, so keyboard users aren't dropped to <body>.\n      focusableWhenDisabled={busy && !disabled}\n      className={cn(buttonVariants({ variant, size, leading: !!leadingIcon, trailing: !!trailingIcon }), className)}\n      {...rest}\n    >\n      <Content size={size} leadingIcon={leadingIcon} trailingIcon={trailingIcon} busy={shown} loadingText={loadingText}>\n        {children}\n      </Content>\n    </BaseButton>\n  );\n}\n\nfunction ButtonElement({\n  render,\n  variant = \"secondary\",\n  size = \"md\",\n  leadingIcon,\n  trailingIcon,\n  className,\n  children,\n  // Loading and disabled don't apply to links: a link that can't be followed shouldn't be a link.\n  loading: _loading,\n  loadingText: _loadingText,\n  disabled: _disabled,\n  focusableWhenDisabled: _focusable,\n  nativeButton: _native,\n  ...rest\n}: ButtonProps & { render: React.ReactElement }) {\n  return useRender({\n    render,\n    props: mergeProps(\n      {\n        \"data-variant\": variant,\n        \"data-size\": size,\n        className: cn(buttonVariants({ variant, size, leading: !!leadingIcon, trailing: !!trailingIcon }), \"cursor-pointer\", className),\n        children: (\n          <Content size={size} leadingIcon={leadingIcon} trailingIcon={trailingIcon} busy={false}>\n            {children}\n          </Content>\n        ),\n      },\n      rest as Record<string, unknown>,\n    ),\n  });\n}\n\nfunction Content({\n  size,\n  leadingIcon,\n  trailingIcon,\n  busy,\n  loadingText,\n  children,\n}: Common & { size: ButtonSize; busy: boolean; loadingText?: string; children?: React.ReactNode }) {\n  const reduce = useReducedMotion();\n  const gap = sizes[size].gap;\n  const t = { duration: reduce ? 0.15 : 0.22, ease: ease.out };\n  // The idle row leaves upward while the busy row arrives from below, so it reads\n  // as one thing replacing another, never as the button blinking.\n  const away = reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" };\n  const below = reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" };\n  const here = { opacity: 1, y: 0, filter: \"blur(0px)\" };\n\n  return (\n    // In a group the whole button can't shrink without tearing the border, so its content squashes instead.\n    <span className=\"grid place-items-center transition-[scale] duration-100 ease-out-quart in-data-[slot=button-group]:group-active/button:scale-[0.95]\">\n      {/* Holds room for the busy row, so the button is as wide as the longer of the two. */}\n      <span aria-hidden className={cn(\"invisible col-start-1 row-start-1 inline-flex items-center\", gap)}>\n        <span className={size === \"sm\" ? \"size-3.5\" : \"size-4\"} />\n        {loadingText}\n      </span>\n      <motion.span\n        // With busy text showing, the idle label steps out of the accessible name too.\n        aria-hidden={(busy && !!loadingText) || undefined}\n        className={cn(\"col-start-1 row-start-1 inline-flex min-w-0 items-center\", gap)}\n        initial={false}\n        animate={busy ? away : here}\n        transition={t}\n      >\n        {leadingIcon && (\n          <span aria-hidden className=\"inline-flex shrink-0\">\n            {leadingIcon}\n          </span>\n        )}\n        <span>{children}</span>\n        {trailingIcon && (\n          <span aria-hidden className=\"inline-flex shrink-0\">\n            {trailingIcon}\n          </span>\n        )}\n      </motion.span>\n      <AnimatePresence initial={false}>\n        {busy && (\n          <motion.span\n            key=\"busy\"\n            className={cn(\"col-start-1 row-start-1 inline-flex items-center\", gap)}\n            initial={below}\n            animate={here}\n            exit={{ ...below, transition: { duration: reduce ? 0.1 : 0.16, ease: ease.out } }}\n            transition={t}\n          >\n            <Spinner />\n            {loadingText && <span>{loadingText}</span>}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/button"
}
