{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button",
  "title": "Button",
  "description": "A Base UI button whose press scale depends on its width, whose label swaps with y + blur while the width springs, and whose loading spinner overlays the label so the width never changes. Popover triggers never scale.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-text-swap.json",
    "https://eased.dev/r/spinner.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/button/button.tsx",
      "content": "\"use client\";\n\nimport { Button as BaseButton } from \"@base-ui/react/button\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport type { CSSProperties, ComponentProps, ReactNode } from \"react\";\nimport { TextSwap } from \"../_primitives/text-swap/text-swap\";\nimport { exitFast, reducedFade, swap, swapIn, swapOut, swapShown, useSafeReducedMotion } from \"../lib/motion\";\nimport { press } from \"../lib/press\";\nimport { Spinner } from \"../spinner/spinner\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"danger\" | \"danger-solid\";\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\" | \"icon-sm\" | \"icon\" | \"icon-lg\";\n\nexport type ButtonProps = Omit<ComponentProps<typeof BaseButton>, \"className\" | \"children\"> & {\n  /**\n   * primary: the one filled action in a region. secondary: everything else. ghost: toolbars\n   * and quiet actions. danger: outlined, for destructive actions that confirm or undo next.\n   * danger-solid: the final confirm step only (rule 33). Default \"secondary\".\n   */\n  variant?: ButtonVariant;\n  /** sm 32px, md 36px, lg 40px; icon-sm/icon/icon-lg are the square versions. Default \"md\". */\n  size?: ButtonSize;\n  /**\n   * Overlay a spinner on the (now transparent) label: the width never changes, focus stays\n   * on the button, clicks are ignored and `aria-busy` is set.\n   */\n  loading?: boolean;\n  /**\n   * The label. A string or number swaps with y + blur whenever it changes, and the button's\n   * width springs to fit (\"Save\" → \"Saved\"). For element children, pass `swapKey`.\n   */\n  children?: ReactNode;\n  /** What counts as a new label when `children` are elements, e.g. `swapKey={state}`. */\n  swapKey?: string | number;\n  /**\n   * Scale down while pressed: 0.97, or 0.96 for icon buttons and 0.985 past 220px wide.\n   * Default true; buttons that open a popover or menu (`aria-haspopup`) never scale.\n   */\n  scaleOnPress?: boolean;\n  /**\n   * Extend the tap target to 44×44px without growing the visual. Default true for `icon-sm`\n   * (the only size under 44px both ways); pass false when the button sits in a row or toolbar\n   * beside other targets, where the extended area would steal their clicks.\n   */\n  hitArea?: boolean;\n  className?: string;\n};\n\nconst VARIANT: Record<ButtonVariant, string> = {\n  primary:\n    \"border-transparent bg-brand text-text-on-brand inset-shadow-highlight hover-fine:not-data-disabled:bg-brand-hover active:not-data-disabled:bg-brand-active data-disabled:not-aria-busy:opacity-50\",\n  secondary:\n    \"border-transparent bg-surface-hover text-text hover-fine:not-data-disabled:bg-surface-active active:not-data-disabled:bg-surface-active aria-expanded:bg-surface-active data-disabled:not-aria-busy:bg-surface-sunken data-disabled:not-aria-busy:text-text-disabled\",\n  ghost:\n    \"border-transparent text-text-muted hover-fine:not-data-disabled:bg-surface-hover hover-fine:not-data-disabled:text-text active:not-data-disabled:bg-surface-active aria-expanded:bg-surface-active aria-expanded:text-text data-disabled:not-aria-busy:text-text-disabled\",\n  danger:\n    \"border-line text-danger-text hover-fine:not-data-disabled:border-danger active:not-data-disabled:bg-danger-subtle data-disabled:not-aria-busy:bg-surface-sunken data-disabled:not-aria-busy:text-text-disabled\",\n  \"danger-solid\":\n    \"border-transparent bg-danger text-text-on-danger inset-shadow-highlight hover-fine:not-data-disabled:bg-danger-hover active:not-data-disabled:bg-danger-active data-disabled:not-aria-busy:opacity-50\",\n};\n\n// Height → radius → padding, per the consistency contract. Icon buttons are square. Only\n// icon-sm is under 44px both ways; it gets the 44×44 `hit-area` unless it sits in a row.\nconst SIZE: Record<ButtonSize, string> = {\n  sm: \"h-control-sm gap-1.5 rounded-control-sm px-3 text-ui-sm\",\n  md: \"h-control-md gap-2 rounded-control px-3.5 text-ui\",\n  lg: \"h-control-lg gap-2 rounded-control px-4 text-ui\",\n  \"icon-sm\": \"size-8 rounded-control-sm text-ui-sm\",\n  icon: \"size-9 rounded-control text-ui\",\n  \"icon-lg\": \"size-10 rounded-control text-ui\",\n};\n\nconst ICON_SIZES = new Set<ButtonSize>([\"icon-sm\", \"icon\", \"icon-lg\"]);\nconst WIDE = 220; // px; past this a 3% press reads as a lurch, so wide buttons press 1.5%\n\n/**\n * A button on Base UI's Button. The label swaps with y + blur and the width springs, so a\n * label change never makes the button jump; loading overlays a spinner on the transparent\n * label so the width does not change at all. The press scale depends on the width.\n */\nexport function Button({\n  variant = \"secondary\",\n  size = \"md\",\n  loading = false,\n  children,\n  swapKey,\n  scaleOnPress = true,\n  hitArea = size === \"icon-sm\",\n  disabled,\n  focusableWhenDisabled,\n  className = \"\",\n  style,\n  onPointerDown,\n  onFocus,\n  type = \"button\",\n  ...props\n}: ButtonProps) {\n  const reduced = useSafeReducedMotion();\n  const icon = ICON_SIZES.has(size);\n  const trigger = props[\"aria-haspopup\"] !== undefined && props[\"aria-haspopup\"] !== false;\n  const still = !scaleOnPress || trigger || disabled || loading;\n\n  // `press` scales to --eased-press-scale; retune it per button rather than stacking classes.\n  const pressScale = still ? \"1\" : icon ? \"var(--eased-press-scale-icon)\" : undefined;\n\n  // Measured on pointer down and on focus (a keyboard press always follows focus), so a\n  // button that grows into a wide slot presses like one.\n  const measure = (el: HTMLElement) => {\n    if (still || icon) return;\n    el.style.setProperty(\"--eased-press-scale\", el.offsetWidth > WIDE ? \"var(--eased-press-scale-wide)\" : \"var(--eased-press-scale)\");\n  };\n\n  const swappable = swapKey !== undefined || typeof children === \"string\" || typeof children === \"number\";\n  const label = swappable ? <TextSwap swapKey={swapKey}>{children}</TextSwap> : children;\n\n  return (\n    <BaseButton\n      {...props}\n      type={type}\n      disabled={disabled || loading}\n      // Loading must not drop focus to <body> (a disabled button blurs itself).\n      focusableWhenDisabled={loading || focusableWhenDisabled}\n      aria-busy={loading || undefined}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        measure(e.currentTarget);\n      }}\n      onFocus={(e) => {\n        onFocus?.(e);\n        measure(e.currentTarget);\n      }}\n      style={{ ...(pressScale ? { \"--eased-press-scale\": pressScale } : null), ...(style as CSSProperties) } as CSSProperties}\n      className={`relative inline-flex shrink-0 cursor-pointer items-center justify-center border font-medium whitespace-nowrap select-none data-disabled:cursor-not-allowed aria-busy:cursor-progress [&_svg]:size-4 [&_svg]:shrink-0 ${press} ${VARIANT[variant]} ${SIZE[size]} ${hitArea ? \"hit-area\" : \"\"} ${className}`}\n    >\n      <motion.span\n        className=\"inline-flex items-center justify-center gap-[inherit]\"\n        initial={false}\n        animate={loading ? (reduced ? { opacity: 0 } : swapOut) : reduced ? { opacity: 1 } : swapShown}\n        transition={\n          reduced ? reducedFade : loading ? { ...swap, opacity: exitFast, filter: exitFast } : swap\n        }\n      >\n        {label}\n      </motion.span>\n      <AnimatePresence initial={false}>\n        {loading && (\n          <motion.span\n            key=\"spinner\"\n            className=\"absolute inset-0 flex items-center justify-center\"\n            initial={reduced ? { opacity: 0 } : swapIn}\n            animate={reduced ? { opacity: 1 } : swapShown}\n            exit={reduced ? { opacity: 0, transition: reducedFade } : { ...swapIn, transition: { ...swap, opacity: exitFast, filter: exitFast } }}\n            transition={reduced ? reducedFade : swap}\n          >\n            <Spinner size=\"sm\" label={false} className=\"text-current\" />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </BaseButton>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/button/button.tsx"
    }
  ],
  "docs": "Import Button from components/eased/button/button. Variants: primary, secondary, ghost, danger, danger-solid. Sizes: sm, md, lg, icon-sm, icon, icon-lg. The Eased tokens were merged into your global CSS; nothing to import.",
  "type": "registry:ui"
}