{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button",
  "type": "registry:ui",
  "title": "Button",
  "description": "A clear, responsive action with quiet secondary states.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/button/button.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, isValidElement, useCallback, useEffect, useLayoutEffect, useRef } from \"react\";\nimport type { ButtonHTMLAttributes, ReactNode, Ref, RefObject } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { TargetAndTransition, Variants } from \"motion/react\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"ghost\" | \"quiet\" | \"accent\" | \"success\" | \"danger\";\nexport type ButtonSize = \"xs\" | \"sm\" | \"md\" | \"lg\";\n\n/* Hover answers only where it means something (`enabled:`); a disabled outlined button keeps its shape and fades its\n   label, so it still reads as a button that is off. Press feedback is colour as well as scale, so menu triggers (which\n   never scale) still answer the finger. */\nconst variants: Record<ButtonVariant, string> = {\n  primary:\n    \"border-ink bg-ink text-page enabled:hover:opacity-91 enabled:hover:shadow-resting enabled:active:opacity-84\",\n  secondary:\n    \"border-line bg-surface text-ink enabled:hover:bg-hover enabled:hover:shadow-resting enabled:active:bg-hover disabled:bg-hover disabled:text-ink-3 disabled:opacity-100\",\n  ghost:\n    \"bg-transparent text-ink-2 enabled:hover:bg-hover enabled:hover:text-ink enabled:active:bg-hover enabled:active:text-ink disabled:text-ink-3 disabled:opacity-100\",\n  quiet:\n    \"bg-transparent text-ink enabled:hover:bg-hover enabled:active:bg-hover-2 disabled:text-ink-3 disabled:opacity-100\",\n  accent:\n    \"border-accent bg-accent text-on-accent enabled:hover:border-accent-ink enabled:hover:bg-accent-ink enabled:active:opacity-90\",\n  success: \"border-green bg-green text-white enabled:hover:brightness-95 enabled:active:brightness-90\",\n  danger:\n    \"border-line bg-surface text-red enabled:hover:border-red enabled:hover:bg-hover enabled:active:border-red enabled:active:bg-hover disabled:bg-hover disabled:text-[color-mix(in_oklab,var(--red)_55%,var(--hover))] disabled:opacity-100\",\n};\nconst sizes: Record<ButtonSize, string> = {\n  xs: \"min-h-7 gap-1 px-2.5 text-xs\",\n  sm: \"min-h-control-sm px-3\",\n  md: \"min-h-control-md\",\n  lg: \"min-h-control-lg px-5\",\n};\n/** An icon-only button is as wide as it is tall, so the pill becomes a circle. */\nconst circles: Record<ButtonSize, string> = {\n  xs: \"w-7 px-0\",\n  sm: \"w-control-sm px-0\",\n  md: \"w-control-md px-0\",\n  lg: \"w-control-lg px-0\",\n};\n\nexport interface ButtonProps extends Omit<\n  ButtonHTMLAttributes<HTMLButtonElement>,\n  \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationStart\"\n> {\n  variant?: ButtonVariant;\n  size?: ButtonSize;\n  loading?: boolean;\n}\n\n/** Icon buttons press a little deeper, wide buttons a little less, so every size reads as the same push. */\nconst pressVariants: Variants = {\n  pressed: (button: RefObject<HTMLButtonElement | null>) => {\n    const width = button.current?.offsetWidth ?? 0;\n    return {\n      scale: width > 220 ? 0.985 : width && width <= 48 ? 0.96 : 0.97,\n      transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n    };\n  },\n};\n\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\n/** Text rises about .3em out of a soft blur; the outgoing label lifts away a little faster. */\nconst textIn: TargetAndTransition = { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: -3,\n  filter: `blur(${motionTokens.blur.soft}px)`,\n  transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst iconOut: TargetAndTransition = {\n  opacity: 0,\n  scale: 0.6,\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n};\nconst fadeIn: TargetAndTransition = { ...rest, opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */\nconst iconEnter = {\n  ...motionTokens.spring.snappy,\n  opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n  filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] },\n} as const;\n\n/** A key for the label content: text plus element names, so a new label or icon crossfades while prop-only updates stay in place. */\nfunction labelKey(node: ReactNode): string {\n  if (node == null || typeof node === \"boolean\") return \"\";\n  if (typeof node === \"string\" || typeof node === \"number\" || typeof node === \"bigint\") return String(node);\n  if (Array.isArray(node)) return node.map(labelKey).join(\"\");\n  if (!isValidElement(node)) return \"\";\n  const type = node.type as string | { displayName?: string; name?: string };\n  return `<${typeof type === \"string\" ? type : (type?.displayName ?? type?.name ?? \"\")}>${labelKey((node.props as { children?: ReactNode }).children)}`;\n}\n\n/** Springs the slot to the natural width of the incoming label when it changes, so a new label never snaps the button's size.\n *  The outgoing label is popped out of flow at once, so it never holds the old width. Other resizes (a late web font, a parent reflow) jump straight to the new width, so nothing wobbles on first paint. */\nfunction useMorphWidth(content: RefObject<HTMLElement | null>, key: string, reduced: boolean) {\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const lastKey = useRef(key),\n    armedUntil = useRef(0);\n  useLayoutEffect(() => {\n    if (lastKey.current === key) return;\n    lastKey.current = key;\n    armedUntil.current = performance.now() + 700;\n  }, [key]);\n  useEffect(() => {\n    const node = content.current,\n      slot = node?.parentElement;\n    if (!node || !slot || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.contentRect.width;\n      if (!next || !measured || reduced || performance.now() > armedUntil.current) {\n        measured = next > 0;\n        width.jump(next || \"auto\");\n        delete slot.dataset.morphing;\n        return;\n      }\n      slot.dataset.morphing = \"\";\n      animate(width, next, {\n        ...motionTokens.spring.morph,\n        onComplete: () => {\n          delete slot.dataset.morphing;\n        },\n      });\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [content, reduced, width]);\n  return width;\n}\n\nfunction LabelPhase({\n  children,\n  icon,\n  reduced,\n  ref,\n}: {\n  children: ReactNode;\n  icon: boolean;\n  reduced: boolean;\n  ref?: Ref<HTMLSpanElement>;\n}) {\n  const present = useIsPresent();\n  return (\n    <motion.span\n      ref={ref}\n      className=\"inline-flex items-center justify-center gap-2 whitespace-nowrap [&_svg]:flex-none\"\n      aria-hidden={present ? undefined : true}\n      initial={reduced ? fadeIn : icon ? iconIn : textIn}\n      animate={rest}\n      exit={reduced ? fadeOut : icon ? iconOut : textOut}\n      transition={\n        reduced\n          ? { duration: motionTokens.duration.instant }\n          : icon\n            ? iconEnter\n            : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n      }\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nexport const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(\n  { className, variant = \"primary\", size = \"md\", loading = false, disabled, children, onClick, ...props },\n  ref,\n) {\n  const reduceMotion = useReducedMotion() ?? false;\n  const buttonRef = useRef<HTMLButtonElement | null>(null);\n  const contentRef = useRef<HTMLSpanElement>(null);\n  const key = labelKey(children);\n  const iconOnly = key !== \"\" && !/\\S/.test(key.replace(/<[^>]*>/g, \"\"));\n  const width = useMorphWidth(contentRef, key, reduceMotion);\n  const setRefs = useCallback(\n    (node: HTMLButtonElement | null) => {\n      buttonRef.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n  // A trigger that anchors a menu, popover, or dialog keeps its rect still while pressed, so the layer never measures a scaled anchor.\n  // Radix triggers (asChild) pass aria-haspopup or data-state through; the stylesheet repeats the guard.\n  const popup = props[\"aria-haspopup\"];\n  const anchorsLayer =\n    (popup !== undefined && popup !== false && popup !== \"false\") ||\n    props.role === \"combobox\" ||\n    (props as Record<string, unknown>)[\"data-state\"] !== undefined;\n  const inert = disabled || loading || props[\"aria-disabled\"] === true || props[\"aria-disabled\"] === \"true\";\n  // Press scale is driven by Motion (whileTap) alone, so transform never gets a CSS transition. Anchors of floating layers\n  // never scale: Radix opens on pointerdown and would measure a shrunken trigger.\n  const classes = cn(\n    \"relative inline-flex items-center justify-center gap-2 rounded-button border border-transparent px-4 text-sm leading-body font-medium whitespace-nowrap [-webkit-tap-highlight-color:transparent]\",\n    \"transition-[color,background-color,border-color,opacity,box-shadow] duration-fast ease-standard motion-reduce:transition-none\",\n    \"disabled:cursor-not-allowed disabled:opacity-52 aria-busy:cursor-progress [&:is([aria-haspopup]:not([aria-haspopup=false]),[data-state],[role=combobox])]:transform-none!\",\n    variants[variant],\n    sizes[size],\n    iconOnly && circles[size],\n    className,\n  );\n\n  return (\n    <motion.button\n      ref={setRefs}\n      tabIndex={props.tabIndex ?? 0}\n      className={classes}\n      disabled={disabled}\n      aria-busy={loading || undefined}\n      custom={buttonRef}\n      variants={pressVariants}\n      whileTap={reduceMotion || anchorsLayer || inert ? undefined : \"pressed\"}\n      transition={motionTokens.spring.snappy}\n      {...props}\n      // Loading keeps the button focusable (a disabled button would drop keyboard focus mid-action) and swallows presses instead.\n      aria-disabled={loading || props[\"aria-disabled\"] || undefined}\n      onClick={loading ? (event) => event.preventDefault() : onClick}\n    >\n      <AnimatePresence initial={false}>\n        {loading ? (\n          <motion.span\n            key=\"loader\"\n            className=\"absolute top-1/2 left-1/2 -m-2 grid size-4\"\n            aria-hidden=\"true\"\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduceMotion ? fadeOut : { ...iconOut, scale: 0.8 }}\n            transition={reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter}\n          >\n            <span className=\"rounded-full border-[1.5px] border-current border-r-transparent animate-[spin_.7s_linear_infinite] motion-reduce:animate-none\" />\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n      {/* The slot's width follows the incoming label on a spring; while it morphs, the outgoing label pops out of flow and the slot clips it. */}\n      <motion.span\n        className={cn(\n          \"relative inline-flex min-w-0 items-center justify-center transition-opacity duration-fast ease-standard motion-reduce:duration-instant motion-reduce:ease-linear data-morphing:[clip-path:inset(-50%_calc(var(--space-3)*-1))]\",\n          loading && \"opacity-0\",\n        )}\n        style={{ width }}\n      >\n        <span ref={contentRef} className=\"inline-flex flex-none items-center\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <LabelPhase key={key} icon={iconOnly} reduced={reduceMotion}>\n              {children}\n            </LabelPhase>\n          </AnimatePresence>\n        </span>\n      </motion.span>\n    </motion.button>\n  );\n});\n\nButton.displayName = \"Button\";\n\nexport default Button;\n",
      "type": "registry:component",
      "target": "@components/iris/button/button.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/button",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "Button",
    "tags": [
      "action",
      "control"
    ],
    "docs": "https://irisui.dev/components/button"
  }
}
