{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button",
  "type": "registry:ui",
  "title": "Button",
  "description": "The core action element. Three orthogonal axes — variant × intent × size — plus loading and icon slots.",
  "dependencies": [
    "@radix-ui/react-slot",
    "@saasflare/ui",
    "class-variance-authority",
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/button.tsx",
      "type": "registry:ui",
      "content": "// @toreview\n\"use client\"\n\n/**\n * @fileoverview Saasflare Button — primary interactive component.\n * @module packages/ui/components/ui/button\n * @package ui\n *\n * 3-axis variant system: variant × intent × size.\n * Extends {@link SaasflareComponentProps} so `surface` and `animated` can be\n * supplied per-instance or inherited from <SaasflareProvider>.\n *\n * Precedence for resolved theme props: component prop > provider context > hardcoded default.\n *\n * Stateful concerns (loading, async pending) live in\n * {@link StatefulButton} — this file stays purely presentational.\n *\n * @example\n * import { Button } from \"@saasflare/ui\";\n *\n * <Button>Default</Button>\n * <Button variant=\"solid\" intent=\"success\">Save</Button>\n * <Button variant=\"outline\" intent=\"danger\" size=\"sm\">Delete</Button>\n * <Button surface=\"glass\">Inherits glass surface</Button>\n * <Button animated={false}>No motion</Button>\n * <Button variant=\"shadow\">Elevated Shadow</Button>\n * <Button variant=\"ghost\" size=\"icon\"><MagnifyingGlassIcon /></Button>\n */\n\nimport * as React from \"react\"\nimport { m } from \"motion/react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport * as Slot from \"@radix-ui/react-slot\"\nimport { cn } from \"@saasflare/ui\"\nimport {\n  useSaasflareProps,\n  type SaasflareComponentProps,\n} from \"@saasflare/ui\"\nimport { spring, useSaasflareMotion } from \"@saasflare/ui\"\nimport { CircleNotchIcon } from \"@saasflare/ui\"\n\n// React-style dev warnings: the consumer's bundler replaces process.env.NODE_ENV.\ndeclare const process: { readonly env: { readonly NODE_ENV?: string } }\n\n/**\n * Motion-wrapped Slot.Root for the asChild + animated path. MUST be defined\n * at module top level — defining it inside the component creates a fresh\n * component identity per render and breaks React reconciliation.\n */\nconst MotionSlot = m.create(Slot.Root)\n\n/* ── Intent type ── */\nconst INTENTS = [\"primary\", \"neutral\", \"success\", \"warning\", \"danger\", \"info\"] as const\n/** Semantic color intent of a {@link Button} — emitted as `data-intent` and resolved to the `--intent` CSS tokens by the theme. */\ntype Intent = (typeof INTENTS)[number]\n\n/**\n * Where the loading spinner renders relative to the label.\n *\n * @example\n * <Button isLoading spinnerPlacement=\"end\">Saving…</Button>\n */\ntype SpinnerPlacement = \"start\" | \"end\"\n\n/**\n * Maps a dimensional `size` to its square icon-button counterpart, used when\n * `isIconOnly` is set and the consumer has not already passed an `icon*` size.\n * `xl` has no `icon-xl` token in the cva, so it clamps to `icon-lg`.\n */\nconst ICON_ONLY_SIZE_MAP = {\n  xs: \"icon-xs\",\n  sm: \"icon-sm\",\n  md: \"icon\",\n  lg: \"icon-lg\",\n  xl: \"icon-lg\",\n} as const\n\n/* ── Backward-compat variant mapping ── */\nconst LEGACY_VARIANT_MAP: Record<string, { variant: string; intent?: Intent }> = {\n  default: { variant: \"solid\", intent: \"primary\" },\n  destructive: { variant: \"solid\", intent: \"danger\" },\n  secondary: { variant: \"soft\", intent: \"neutral\" },\n}\n\n/**\n * Button variant definitions using the 3-axis system.\n *\n * Axes:\n *   variant — visual treatment: solid, soft, outline, ghost, link, glass, clay, shadow\n *   intent  — color intent via data-intent attribute + CSS tokens\n *   size    — dimensional: xs, sm, md, lg, xl, icon, icon-xs, icon-sm, icon-lg\n */\nconst buttonVariants = cva(\n  \"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  {\n    variants: {\n      variant: {\n        solid:\n          \"bg-[var(--intent)] text-[var(--intent-fg)] shadow-xs hover:brightness-110 dark:hover:brightness-125\",\n        soft:\n          \"bg-[var(--intent)]/15 text-[var(--intent-text)] hover:bg-[var(--intent)]/25 dark:bg-[var(--intent)]/20 dark:hover:bg-[var(--intent)]/30\",\n        outline:\n          \"border border-[var(--intent-text)]/30 text-[var(--intent-text)] shadow-xs hover:bg-[var(--intent-text)]/10 dark:border-[var(--intent-text)]/40 dark:hover:bg-[var(--intent-text)]/15\",\n        ghost:\n          \"text-[var(--intent-text)] hover:bg-[var(--intent-text)]/10 dark:hover:bg-[var(--intent-text)]/15\",\n        link:\n          \"text-[var(--intent-text)] underline-offset-4 hover:underline\",\n        glass:\n          \"bg-[var(--surface-bg)] text-[var(--intent-text)] border border-[var(--surface-border)] [backdrop-filter:var(--surface-backdrop)] [-webkit-backdrop-filter:var(--surface-backdrop)] shadow-[var(--surface-shadow)] hover:brightness-110 dark:hover:brightness-125\",\n        clay:\n          \"bg-[var(--intent)] text-[var(--intent-fg)] shadow-[var(--surface-shadow)] hover:brightness-110 active:translate-y-px dark:hover:brightness-125\",\n        shadow:\n          \"bg-[var(--intent)] text-[var(--intent-fg)] shadow-[0_8px_24px_-6px_oklch(from_var(--intent)_l_c_h_/_0.5)] hover:shadow-[0_12px_32px_-6px_oklch(from_var(--intent)_l_c_h_/_0.6)] hover:brightness-110 dark:hover:brightness-125\",\n      },\n      size: {\n        xs: \"h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3\",\n        sm: \"h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5\",\n        md: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n        xl: \"h-12 rounded-lg px-8 text-base has-[>svg]:px-5 [&_svg:not([class*='size-'])]:size-5\",\n        icon: \"size-9\",\n        \"icon-xs\": \"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3\",\n        \"icon-sm\": \"size-8\",\n        \"icon-lg\": \"size-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"solid\",\n      size: \"md\",\n    },\n  }\n)\n\n/** Motion event overrides that conflict with React HTML events */\ntype MotionConflicts = \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n\n/**\n * Props for the Saasflare Button component.\n *\n * Extends {@link SaasflareComponentProps} to accept `surface` and `animated`\n * overrides that are resolved against the <SaasflareProvider> context.\n */\ninterface ButtonProps\n  extends Omit<React.ComponentProps<\"button\">, MotionConflicts>,\n    VariantProps<typeof buttonVariants>,\n    SaasflareComponentProps {\n  /**\n   * Render as child element (Radix Slot pattern). Mutually exclusive with the\n   * presentational slot props below — when `asChild` is set they are ignored\n   * (dev-only `console.warn`) since a Slot cannot own injected DOM children.\n   */\n  asChild?: boolean\n  /** Semantic color intent */\n  intent?: Intent\n  /** Stretch to full width of container */\n  fullWidth?: boolean\n\n  // ── Presentational slots (additive) ──\n  /**\n   * Node rendered before the label (e.g. a leading icon). Ignored when `asChild`.\n   * Intended for non-interactive adornments; mark meaningful icons `aria-hidden`\n   * yourself if they are decorative.\n   */\n  startContent?: React.ReactNode\n  /**\n   * Node rendered after the label (e.g. a trailing icon). Ignored when `asChild`.\n   * Intended for non-interactive adornments.\n   */\n  endContent?: React.ReactNode\n  /**\n   * Presentational loading flag. Renders a hardcoded-regular `CircleNotch`\n   * spinner, forces `disabled`, sets `aria-busy=\"true\"`, and disables motion.\n   * This is the DUMB flag you flip yourself; for promise/async orchestration\n   * reach for {@link StatefulButton}.\n   *\n   * @default false\n   */\n  isLoading?: boolean\n  /**\n   * Spinner side while `isLoading`. Replaces `startContent` (start) or\n   * `endContent` (end); the opposite slot still renders its content. No-op when\n   * `isLoading` is false.\n   *\n   * @default \"start\"\n   */\n  spinnerPlacement?: SpinnerPlacement\n  /**\n   * Convenience: square icon button. Maps the resolved `size` to its paired\n   * icon size token for the cva call only (`xs→icon-xs`, `sm→icon-sm`,\n   * `md→icon`, `lg→icon-lg`, `xl→icon-lg`). If an `icon*` size is already\n   * passed, that wins. Icon-only buttons require an accessible name — pass\n   * `aria-label` (dev-only warn otherwise). Ignored when `asChild`.\n   *\n   * @default false\n   */\n  isIconOnly?: boolean\n}\n\n/**\n * Primary interactive button with motion and intent support.\n *\n * Resolves `surface` and `animated` via {@link useSaasflareProps} with the\n * precedence: component prop > <SaasflareProvider> context > hardcoded default.\n *\n * When no explicit `variant` is set and the resolved surface is `\"glass\"` or\n * `\"clay\"`, the button promotes itself to that matching variant. An explicit\n * `variant` prop always wins over the surface-based promotion.\n *\n * For loading / pending states use {@link StatefulButton}.\n *\n * @component\n * @layer ui\n *\n * @param {string} variant - Visual treatment: \"solid\" | \"soft\" | \"outline\" | \"ghost\" | \"link\" | \"glass\" | \"clay\" | \"shadow\"\n * @param {string} intent - Color intent: \"primary\" | \"neutral\" | \"success\" | \"warning\" | \"danger\" | \"info\"\n * @param {string} size - Button size: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"icon\" | \"icon-xs\" | \"icon-sm\" | \"icon-lg\"\n * @param {string} surface - Surface style override: \"flat\" | \"glass\" | \"clay\" (inherits from provider when omitted)\n * @param {string} radius - Radius preset override: \"sharp\" | \"soft\" | \"rounded\" | \"pill\" (inherits from provider when omitted)\n * @param {string} iconWeight - Phosphor icon weight override: \"regular\" | \"bold\" | \"fill\" | \"duotone\" (inherits from provider when omitted)\n * @param {boolean} animated - Gate motion effects (inherits from provider when omitted)\n * @param {boolean} fullWidth - Stretches to container width\n * @param {boolean} asChild - Render as child element (Slot pattern)\n *\n * @example\n * // Solid primary (default)\n * <Button>Save Changes</Button>\n *\n * @example\n * // Outline danger\n * <Button variant=\"outline\" intent=\"danger\">Delete Account</Button>\n *\n * @example\n * // Inherits surface from provider — auto-promotes to glass variant\n * <SaasflareProvider surface=\"glass\"><Button>Frosted</Button></SaasflareProvider>\n *\n * @example\n * // Icon button\n * <Button variant=\"ghost\" size=\"icon\"><SettingsIcon /></Button>\n *\n * @example\n * // Leading + trailing icon slots\n * <Button startContent={<ArrowLeftIcon />} endContent={<ArrowRightIcon />}>\n *   Navigate\n * </Button>\n *\n * @example\n * // Presentational loading flag (dumb): you flip it yourself\n * <Button isLoading>Saving…</Button>\n * <Button isLoading spinnerPlacement=\"end\" endContent={<ArrowRightIcon />}>Next</Button>\n *\n * @example\n * // Square icon-only button — aria-label REQUIRED for an accessible name\n * <Button isIconOnly aria-label=\"Search\"><MagnifyingGlassIcon /></Button>\n *\n * @example\n * // Legacy API (deprecated but supported)\n * <Button variant=\"destructive\">Delete</Button>\n */\nfunction Button({\n  className,\n  variant: variantProp,\n  size = \"md\",\n  intent: intentProp = \"primary\",\n  asChild = false,\n  fullWidth = false,\n  startContent,\n  endContent,\n  isLoading = false,\n  spinnerPlacement = \"start\",\n  isIconOnly = false,\n  surface,\n  iconWeight,\n  radius,\n  animated,\n  disabled,\n  children,\n  ...props\n}: ButtonProps) {\n  const sf = useSaasflareProps({ surface, radius, animated, iconWeight })\n\n  /* ── Loading is purely presentational, but it gates disabled + motion ── */\n  const busy = isLoading === true\n  const effectiveDisabled = (disabled ?? false) || busy\n  const motion = useSaasflareMotion(sf.animated, spring, effectiveDisabled)\n\n  /* ── Dev-only guidance for asChild + presentational props and a11y ── */\n  if (process.env.NODE_ENV !== \"production\") {\n    if (asChild && (startContent || endContent || busy || isIconOnly)) {\n      console.warn(\n        \"[Saasflare][Button] `startContent`, `endContent`, `isLoading`, and `isIconOnly` are ignored when `asChild` is set — a Slot cannot own injected DOM children. Compose these inside the child element instead.\",\n      )\n    }\n    if (\n      isIconOnly &&\n      props[\"aria-label\"] === undefined &&\n      props[\"aria-labelledby\"] === undefined\n    ) {\n      console.warn(\n        \"[Saasflare][Button] `isIconOnly` buttons need an accessible name. Pass `aria-label` (or `aria-labelledby`).\",\n      )\n    }\n  }\n\n  /* ── Surface → variant promotion (only when variant is not explicit) ── */\n  const effectiveVariant: string =\n    variantProp ??\n    (sf.surface === \"glass\" ? \"glass\" : sf.surface === \"clay\" ? \"clay\" : \"solid\")\n\n  /* ── Backward compat: map legacy variant names ── */\n  let resolvedVariant = effectiveVariant\n  let resolvedIntent = intentProp\n\n  const legacy = LEGACY_VARIANT_MAP[effectiveVariant]\n  if (legacy) {\n    resolvedVariant = legacy.variant\n    if (legacy.intent) {\n      resolvedIntent = legacy.intent\n    }\n  }\n\n  /* ── isIconOnly: remap a dimensional size to its icon counterpart for the\n   * cva call only. A consumer-supplied `icon*` size always wins; the `size`\n   * prop the consumer sees is never mutated. ── */\n  const isIconSize = typeof size === \"string\" && size.startsWith(\"icon\")\n  const resolvedSize =\n    isIconOnly && !isIconSize\n      ? ICON_ONLY_SIZE_MAP[size as keyof typeof ICON_ONLY_SIZE_MAP] ?? size\n      : size\n\n  const dataAttrs = {\n    \"data-slot\": \"button\",\n    \"data-variant\": resolvedVariant,\n    \"data-intent\": resolvedIntent,\n    \"data-size\": size,\n    \"data-surface\": sf.surface,\n    \"data-radius\": sf.radius,\n    \"data-animated\": String(sf.animated),\n    \"data-loading\": String(busy),\n    \"data-icon-only\": String(isIconOnly),\n  }\n\n  const classes = cn(\n    buttonVariants({\n      variant: resolvedVariant as VariantProps<typeof buttonVariants>[\"variant\"],\n      size: resolvedSize as VariantProps<typeof buttonVariants>[\"size\"],\n    }),\n    fullWidth && \"w-full\",\n    className\n  )\n\n  /* ── Slot rendering (Pattern A: animated asChild via m.create(Slot.Root)).\n   * Presentational props are intentionally ignored here (dev-warned above);\n   * a Slot forwards to a single child and cannot own injected content. ── */\n  if (asChild) {\n    return (\n      <MotionSlot\n        {...props}\n        {...dataAttrs}\n        whileHover={motion.disabled ? undefined : { scale: 1.02 }}\n        whileTap={motion.disabled ? undefined : { scale: 0.97 }}\n        transition={motion.transition}\n        className={classes}\n      >\n        {children}\n      </MotionSlot>\n    )\n  }\n\n  /* ── Spinner: HARDCODED regular weight per the documented iconWeight\n   * Spinner/Button-loading visual-identity exemption — do NOT forward\n   * sf.iconWeight here. (consumer startContent/endContent icons keep normal\n   * iconWeight propagation via provider context.) ── */\n  const spinner = (\n    <CircleNotchIcon weight=\"regular\" aria-hidden=\"true\" className=\"animate-spin\" />\n  )\n\n  /* ── Compose slot children. When busy, the spinner replaces the slot on the\n   * placement side; the opposite slot still renders its content. ── */\n  const leading = busy && spinnerPlacement !== \"end\" ? spinner : startContent\n  const trailing = busy && spinnerPlacement === \"end\" ? spinner : endContent\n  const body = (\n    <>\n      {leading}\n      {children}\n      {trailing}\n    </>\n  )\n\n  return (\n    <m.button\n      {...dataAttrs}\n      whileHover={motion.disabled ? undefined : { scale: 1.02 }}\n      whileTap={motion.disabled ? undefined : { scale: 0.97 }}\n      transition={motion.transition}\n      className={classes}\n      aria-busy={busy || undefined}\n      disabled={effectiveDisabled}\n      {...props}\n    >\n      {body}\n    </m.button>\n  )\n}\n\nexport { Button, buttonVariants, type ButtonProps, type Intent, type SpinnerPlacement }\n",
      "target": "components/ui/button.tsx"
    }
  ],
  "$meta": {
    "source": "https://ui.saasflare.io/r/button.json"
  }
}
