{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button",
  "type": "registry:ui",
  "dependencies": [
    "motion",
    "class-variance-authority",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "https://usva.build/r/spinner.json"
  ],
  "files": [
    {
      "path": "button.tsx",
      "target": "components/ui/button.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Spinner } from \"./spinner\";\n\ndeclare const process: { env: { NODE_ENV?: string } };\n\nexport type ButtonStatus = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport const buttonVariants = cva(\n  cn(\n    \"relative isolate inline-flex cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium tracking-[-0.01em] outline-none\",\n    \"transition-control duration-fast ease-soft\",\n    \"hover:-translate-y-px active:scale-[0.96] motion-reduce:transition-none motion-reduce:transform-none\",\n    \"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:bg-transparent before:transition-tint before:duration-fast\",\n    \"after:absolute after:inset-x-0 after:content-['']\",\n    \"focus-visible:ring-focus\",\n    \"disabled:pointer-events-none disabled:opacity-50 disabled:saturate-[0.7]\",\n    \"data-[status=loading]:pointer-events-none\",\n    \"data-[status=success]:bg-none data-[status=success]:bg-success data-[status=success]:text-on-accent\",\n    \"data-[status=error]:bg-none data-[status=error]:bg-danger data-[status=error]:text-on-accent\",\n    \"[&_svg]:pointer-events-none [&_svg]:shrink-0\",\n  ),\n  {\n    variants: {\n      variant: {\n        solid:\n          \"bg-accent bg-gradient-accent font-semibold text-on-accent shadow-raised hover:glow-ring hover:before:bg-ink/10 active:before:bg-ink/5\",\n        soft: \"bg-surface-2 text-ink shadow-raised hover:before:bg-ink/5\",\n        ghost: \"bg-transparent text-muted hover:text-ink hover:before:bg-ink/5\",\n        outline:\n          \"border border-border bg-transparent text-ink hover:border-border-strong hover:before:bg-ink/5 focus-visible:border-transparent\",\n        onSurface:\n          \"border border-ink/10 bg-ink/[0.055] font-semibold text-ink hover:border-ink/20 hover:before:bg-ink/5\",\n        glass:\n          \"border border-white/15 bg-black/40 text-white/90 shadow-raised backdrop-blur-sm hover:bg-black/55 hover:before:bg-white/5\",\n      },\n      size: {\n        sm: \"h-8 gap-1.5 rounded-md px-3 text-xs after:-inset-y-1.5\",\n        md: \"h-10 px-4 text-sm after:-inset-y-0.5\",\n        lg: \"h-12 rounded-xl px-6 text-[0.9375rem] after:inset-y-0\",\n      },\n      iconOnly: { true: \"px-0\", false: \"\" },\n      active: { true: \"glow-ring text-accent\", false: \"\" },\n      shape: { rounded: \"\", pill: \"\" },\n    },\n    compoundVariants: [\n      // the `after` inset expands the hit area to 44px without growing the box,\n      // so it has to scale inversely with the visual size\n      {\n        iconOnly: true,\n        size: \"sm\",\n        class: \"w-8 rounded-lg after:-inset-1.5 [&_svg]:size-4\",\n      },\n      {\n        iconOnly: true,\n        size: \"md\",\n        class: \"w-10 rounded-xl after:-inset-0.5 [&_svg]:size-[1.15rem]\",\n      },\n      { iconOnly: true, size: \"lg\", class: \"w-12 [&_svg]:size-5\" },\n      // icon-only outline keeps the quiet reading the old IconButton had\n      {\n        iconOnly: true,\n        variant: \"outline\",\n        active: false,\n        class: \"bg-surface text-muted hover:text-ink\",\n      },\n      { active: true, variant: \"outline\", class: \"border-transparent\" },\n      { shape: \"pill\", class: \"rounded-full\" },\n    ],\n    defaultVariants: {\n      variant: \"solid\",\n      size: \"md\",\n      iconOnly: false,\n      active: false,\n      shape: \"rounded\",\n    },\n  },\n);\n\nconst TOOLTIP_SIDE: Record<string, string> = {\n  top: \"bottom-full left-1/2 mb-2 -translate-x-1/2\",\n  bottom: \"top-full left-1/2 mt-2 -translate-x-1/2\",\n  left: \"right-full top-1/2 mr-2 -translate-y-1/2\",\n  right: \"left-full top-1/2 ml-2 -translate-y-1/2\",\n};\n\nconst SPINNER_SIZE = { sm: \"sm\", md: \"sm\", lg: \"md\" } as const;\n\nconst GRID_STYLE: React.CSSProperties = {\n  display: \"grid\",\n  placeItems: \"center\",\n};\nconst CELL_STYLE: React.CSSProperties = { gridArea: \"1 / 1\" };\nconst SIZER_STYLE: React.CSSProperties = {\n  ...CELL_STYLE,\n  visibility: \"hidden\",\n};\n// Inline, not `sr-only`: a consumer's Tailwind build never scans this file.\nconst SR_ONLY_STYLE: React.CSSProperties = {\n  position: \"absolute\",\n  width: 1,\n  height: 1,\n  margin: -1,\n  padding: 0,\n  overflow: \"hidden\",\n  clipPath: \"inset(50%)\",\n  whiteSpace: \"nowrap\",\n  border: 0,\n};\n\ntype MotionConflicts =\n  | \"onAnimationStart\"\n  | \"onAnimationEnd\"\n  | \"onDrag\"\n  | \"onDragStart\"\n  | \"onDragEnd\";\n\nexport interface ButtonProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, MotionConflicts>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n  status?: ButtonStatus;\n  loadingText?: React.ReactNode;\n  successText?: React.ReactNode;\n  errorText?: React.ReactNode;\n  /** How long `success` / `error` hold before the button settles back to idle. */\n  settleDelay?: number;\n  onSettle?: () => void;\n  /** Optional visible tooltip on hover/focus. */\n  tooltip?: React.ReactNode;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n}\n\nexport const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  (\n    {\n      className,\n      variant,\n      size = \"md\",\n      iconOnly = false,\n      active = false,\n      shape,\n      asChild,\n      status = \"idle\",\n      loadingText,\n      successText,\n      errorText,\n      settleDelay = 1200,\n      onSettle,\n      tooltip,\n      side = \"top\",\n      onClick,\n      children,\n      ...props\n    },\n    ref,\n  ) => {\n    const reduce = useReducedMotion();\n    const display = useSettlingStatus(status, settleDelay, onSettle);\n    const tooltipId = React.useId();\n    const busy = display === \"loading\";\n\n    if (\n      process.env.NODE_ENV !== \"production\" &&\n      iconOnly &&\n      !props[\"aria-label\"] &&\n      !props[\"aria-labelledby\"]\n    ) {\n      throw new Error(\"usva: an icon-only Button needs an aria-label.\");\n    }\n\n    if (asChild)\n      return (\n        <Slot\n          {...props}\n          slotRef={ref}\n          onClick={onClick}\n          className={cn(\n            buttonVariants({ variant, size, iconOnly, active, shape }),\n            className,\n          )}\n        >\n          {children}\n        </Slot>\n      );\n\n    const content: Record<ButtonStatus, React.ReactNode> = {\n      idle: children,\n      loading: (\n        <>\n          <Spinner\n            aria-hidden=\"true\"\n            label=\"\"\n            tone=\"current\"\n            size={SPINNER_SIZE[size ?? \"md\"]}\n          />\n          {loadingText ?? (iconOnly ? null : \"Loading\")}\n        </>\n      ),\n      success: (\n        <>\n          <CheckIcon />\n          {successText ?? <span style={SR_ONLY_STYLE}>{children}</span>}\n        </>\n      ),\n      error: (\n        <>\n          <AlertIcon />\n          {errorText ?? <span style={SR_ONLY_STYLE}>{children}</span>}\n        </>\n      ),\n    };\n\n    // The lift is declared twice on purpose. Motion writes an inline transform on the\n    // first press and never gives it back, which outranks the `hover:-translate-y-px`\n    // class from here on. That class is what the motion-free `asChild` path uses.\n    const button = (\n      <motion.button\n        ref={ref}\n        data-status={display}\n        aria-busy={busy || undefined}\n        aria-describedby={tooltip ? tooltipId : undefined}\n        whileHover={reduce ? undefined : { y: -1 }}\n        whileTap={reduce ? undefined : { scale: 0.96, y: 0 }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { type: \"spring\", stiffness: 420, damping: 34 }\n        }\n        onClick={busy ? undefined : onClick}\n        className={cn(\n          buttonVariants({ variant, size, iconOnly, active, shape }),\n          className,\n        )}\n        {...props}\n      >\n        {/* Every state is stacked in one grid cell so the button reserves the widest\n            state's width up front and never reshapes between them. Only the active\n            label is shown; the rest are hidden sizers. Grid placement is inline-styled,\n            not Tailwind, so a consumer's build can never tree-shake the layout away. */}\n        <span style={GRID_STYLE}>\n          {(Object.keys(content) as ButtonStatus[]).map((s) => (\n            <span\n              key={s}\n              aria-hidden\n              style={SIZER_STYLE}\n              className=\"inline-flex items-center gap-2 whitespace-nowrap\"\n            >\n              {content[s]}\n            </span>\n          ))}\n          <motion.span\n            key={display}\n            style={CELL_STYLE}\n            initial={reduce ? false : { opacity: 0, scale: 0.96 }}\n            animate={{ opacity: 1, scale: 1 }}\n            transition={\n              reduce\n                ? { duration: 0 }\n                : { duration: 0.2, ease: [0.22, 1, 0.36, 1] }\n            }\n            className=\"inline-flex items-center gap-2 whitespace-nowrap\"\n          >\n            {content[display]}\n          </motion.span>\n        </span>\n      </motion.button>\n    );\n\n    if (!tooltip) return button;\n    return (\n      <span className=\"group relative isolate inline-flex\">\n        {button}\n        <span\n          id={tooltipId}\n          role=\"tooltip\"\n          className={cn(\n            \"pointer-events-none absolute z-overlay whitespace-nowrap rounded-md border border-border bg-overlay px-2 py-1 font-mono text-[0.625rem] font-semibold uppercase tracking-[0.08em] text-ink shadow-floating\",\n            \"opacity-0 transition-opacity duration-fast ease-soft group-hover:opacity-100 group-focus-within:opacity-100 motion-reduce:transition-none\",\n            TOOLTIP_SIDE[side],\n          )}\n        >\n          {tooltip}\n        </span>\n      </span>\n    );\n  },\n);\nButton.displayName = \"Button\";\n\n/**\n * `success` and `error` are momentary: they hold for `settleDelay`, then the button\n * returns to idle on its own. The callback lives in a ref so an inline `onSettle`\n * doesn't restart the timer on every render.\n */\nfunction useSettlingStatus(\n  status: ButtonStatus,\n  settleDelay: number,\n  onSettle?: () => void,\n): ButtonStatus {\n  const [display, setDisplay] = React.useState<ButtonStatus>(status);\n  const settle = React.useRef(onSettle);\n\n  React.useEffect(() => {\n    settle.current = onSettle;\n  }, [onSettle]);\n\n  React.useEffect(() => {\n    setDisplay(status);\n    if (status !== \"success\" && status !== \"error\") return;\n    const timer = setTimeout(() => {\n      setDisplay(\"idle\");\n      settle.current?.();\n    }, settleDelay);\n    return () => clearTimeout(timer);\n  }, [status, settleDelay]);\n\n  return display;\n}\n\nfunction CheckIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 12 12\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className=\"h-4 w-4\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"M2.5 6.5 5 9l4.5-5.5\" />\n    </svg>\n  );\n}\n\nfunction AlertIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 12 12\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className=\"h-4 w-4\"\n      aria-hidden=\"true\"\n    >\n      <path d=\"M6 3v3.5\" />\n      <path d=\"M6 8.75h.01\" />\n    </svg>\n  );\n}\n\ntype Handler = (...args: unknown[]) => void;\n\nfunction childRef(element: React.ReactElement): React.Ref<unknown> | undefined {\n  return Number.parseInt(React.version, 10) >= 19\n    ? (element.props as { ref?: React.Ref<unknown> }).ref\n    : (element as unknown as { ref?: React.Ref<unknown> }).ref;\n}\n\n/**\n * Carries the ref under a name of its own. React 18 reserves `ref`, strips it\n * from a function component's props and warns, so a Slot that read `props.ref`\n * would forward nothing there while working on 19.\n */\nfunction Slot({\n  children,\n  className,\n  style,\n  slotRef,\n  ...props\n}: React.ButtonHTMLAttributes<HTMLElement> & {\n  children?: React.ReactNode;\n  slotRef?: React.Ref<unknown>;\n}) {\n  const element = React.isValidElement<React.HTMLAttributes<HTMLElement>>(\n    children,\n  )\n    ? children\n    : null;\n  const theirRef = element ? childRef(element) : undefined;\n\n  const composedRef = React.useCallback(\n    (node: unknown) => {\n      for (const target of [slotRef, theirRef]) {\n        if (typeof target === \"function\") target(node);\n        else if (target)\n          (target as React.MutableRefObject<unknown>).current = node;\n      }\n    },\n    [slotRef, theirRef],\n  );\n\n  if (!element) return null;\n  const childProps = element.props as Record<string, unknown>;\n  const merged: Record<string, unknown> = { ...props, ...childProps };\n\n  for (const [key, ours] of Object.entries(props)) {\n    if (!key.startsWith(\"on\") || typeof ours !== \"function\") continue;\n    const theirs = childProps[key];\n    merged[key] =\n      typeof theirs === \"function\"\n        ? (...args: unknown[]) => {\n            (theirs as Handler)(...args);\n            (ours as Handler)(...args);\n          }\n        : ours;\n  }\n\n  merged.className = cn(className, childProps.className as string);\n  merged.style = { ...style, ...(childProps.style as React.CSSProperties) };\n  merged.ref = composedRef;\n\n  return React.cloneElement(\n    element,\n    merged as React.HTMLAttributes<HTMLElement>,\n  );\n}\n"
    }
  ]
}