{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"button","type":"registry:ui","title":"Button","description":"Primary, secondary, ghost and destructive buttons with icons, sizes and a shimmering loading state","dependencies":[],"files":[{"path":"registry/button/button.tsx","type":"registry:ui","content":"\"use client\";\n\nimport {\n  isValidElement,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentPropsWithRef,\n  type SyntheticEvent,\n  type ReactNode,\n} from \"react\";\n\n/*\n * Button – a single self-contained file.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type ButtonProps = ComponentPropsWithRef<\"button\"> & {\n  /** primary: dark and filled · secondary: white with an outline · ghost: text only · destructive: red, for deleting */\n  variant?: \"primary\" | \"secondary\" | \"ghost\" | \"destructive\";\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Shows a shimmer over the label and blocks clicks */\n  loading?: boolean;\n  /** Icon before the label, e.g. an inline SVG */\n  icon?: ReactNode;\n  /** Icon after the label */\n  iconRight?: ReactNode;\n};\n\nexport function Button({\n  variant = \"primary\",\n  size = \"md\",\n  loading = false,\n  icon,\n  iconRight,\n  children,\n  className,\n  disabled,\n  onClick,\n  onPointerEnter,\n  type = \"button\",\n  ref,\n  ...rest\n}: ButtonProps) {\n  // The shimmer copies the label, so it only works on plain text\n  const text = typeof children === \"string\" ? children : undefined;\n  // An empty string or false is no label – the icon stays centered\n  const hasLabel = children != null && children !== false && children !== \"\";\n  const iconOnly = !hasLabel && Boolean(icon || iconRight);\n  // Unknown values fall back to the defaults instead of an unstyled button\n  const tone = VARIANTS.includes(variant) ? variant : \"primary\";\n  const scale = SIZES.includes(size) ? size : \"md\";\n\n  // Morph: when label or icon change, the width glides to its new size and the new content blurs in.\n  // Nothing animates on the first render.\n  const inner = useRef<HTMLButtonElement | null>(null);\n  const lastWidth = useRef(0);\n  const [ready, setReady] = useState(false);\n  useEffect(() => setReady(true), []);\n  const labelKey = text ?? \"label\";\n  const iconKey = loading && icon ? \"spinner\" : contentKey(icon);\n  const iconRightKey = contentKey(iconRight);\n\n  const content = `${labelKey}|${iconKey}|${iconRightKey}`;\n  const lastContent = useRef(content);\n\n  // Runs after every render, so the remembered width is always current\n  useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const width = el.getBoundingClientRect().width;\n    const from = lastWidth.current;\n    const changed = lastContent.current !== content;\n    lastWidth.current = width;\n    lastContent.current = content;\n    if (!changed || !from || Math.abs(from - width) < 0.5) return;\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    el.animate([{ width: `${from}px` }, { width: `${width}px` }], {\n      duration: 480,\n      easing: \"cubic-bezier(0.25, 1, 0.5, 1)\",\n    });\n  });\n\n  return (\n    <>\n      {/* React 19 hoists this into <head> once, no matter how many buttons render */}\n      <style href=\"ui-button\" precedence=\"default\">\n        {css}\n      </style>\n      <button\n        type={type}\n        className={[\"ui-button\", className].filter(Boolean).join(\" \")}\n        data-variant={tone}\n        data-size={scale}\n        data-loading={loading || undefined}\n        data-icon-only={iconOnly || undefined}\n        data-ready={ready || undefined}\n        ref={(el) => {\n          inner.current = el;\n          if (typeof ref === \"function\") ref(el);\n          else if (ref) ref.current = el;\n        }}\n        disabled={disabled}\n        aria-busy={loading || undefined}\n        aria-disabled={loading || undefined}\n        onClick={(event) => {\n          if (loading) {\n            event.preventDefault();\n            return;\n          }\n          onClick?.(event);\n        }}\n        onPointerEnter={(event) => {\n          // A label cut short shows its full text on hover\n          const label = event.currentTarget.querySelector<HTMLElement>(\".ui-button__label\");\n          if (label && text && !rest.title) label.title = label.scrollWidth > label.clientWidth + 1 ? text : \"\";\n          onPointerEnter?.(event);\n        }}\n        {...rest}\n      >\n        {/* New keys remount the parts that changed, which replays their enter animation */}\n        {icon && (\n          <span className=\"ui-button__icon\" aria-hidden=\"true\" key={`icon-${iconKey}`} onErrorCapture={hideBroken}>\n            {loading ? <Spinner /> : icon}\n          </span>\n        )}\n        {hasLabel && (\n          <span className=\"ui-button__label\" data-text={text} dir=\"auto\" key={`label-${labelKey}`}>\n            {children}\n          </span>\n        )}\n        {iconRight && (\n          <span className=\"ui-button__icon\" aria-hidden=\"true\" key={`icon-right-${iconRightKey}`} onErrorCapture={hideBroken}>\n            {iconRight}\n          </span>\n        )}\n      </button>\n    </>\n  );\n}\n\nconst VARIANTS = [\"primary\", \"secondary\", \"ghost\", \"destructive\"];\nconst SIZES = [\"sm\", \"md\", \"lg\"];\n\n/** An icon image that fails to load leaves an empty slot instead of a broken-image glyph */\nconst hideBroken = (event: SyntheticEvent<HTMLElement>) => {\n  if (event.target instanceof HTMLImageElement) event.target.style.visibility = \"hidden\";\n};\n\n/** Shown in place of the left icon while loading */\nconst Spinner = () => (\n  <svg\n    className=\"ui-button__spinner\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth={2}\n    strokeLinecap=\"round\"\n  >\n    <circle cx=\"12\" cy=\"12\" r=\"8.5\" opacity={0.2} />\n    <path d=\"M12 3.5a8.5 8.5 0 0 1 8.5 8.5\" />\n  </svg>\n);\n\n/** A stable id per icon component, so swapping e.g. <CopyIcon /> for <CheckIcon /> counts as a change */\nconst ids = new WeakMap<object, number>();\nlet nextId = 1;\nfunction contentKey(node: ReactNode) {\n  if (!isValidElement(node) || typeof node.type === \"string\") return 0;\n  const type = node.type as object;\n  if (!ids.has(type)) ids.set(type, nextId++);\n  return ids.get(type)!;\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors and motion as variables – override them on .ui-button (see Theming in the docs) */\n.ui-button {\n  --ui-btn-ink: var(--aui-ink, #171717);\n  --ui-btn-on-ink: #fafafa;\n  --ui-btn-dark-top: #323137;\n  --ui-btn-dark-bottom: #201e25;\n  --ui-btn-dark-hover-top: #3c3b42;\n  --ui-btn-dark-hover-bottom: #29272f;\n  --ui-btn-edge-top: #4b4951;\n  --ui-btn-edge-mid: #31303a;\n  --ui-btn-edge-bottom: #18171b;\n  --ui-btn-ring: #0d0d0d;\n  --ui-btn-dark-shadow: rgba(0, 0, 0, 0.1);\n  --ui-btn-surface: var(--aui-surface, #ffffff);\n  --ui-btn-surface-hover: #fafafa;\n  --ui-btn-surface-shadow: rgba(0, 0, 0, 0.06);\n  --ui-btn-line: var(--aui-border, #e5e5e5);\n  --ui-btn-muted: var(--aui-muted, #737373);\n  --ui-btn-hover: #404040;\n  --ui-btn-fill: #f0f0f0;\n  --ui-btn-shine: rgba(255, 255, 255, 0.82);\n  --ui-btn-shine-on-ink: rgba(255, 255, 255, 0.95);\n  --ui-btn-danger: var(--aui-danger, #c4320a);\n  --ui-btn-danger-bg: #ffe9e6;\n  --ui-btn-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-btn-ease: cubic-bezier(0.22, 1, 0.36, 1);\n  --ui-btn-load: 650ms;\n\n  --h: 40px;\n  --px: 16px;\n  --gap: 8px;\n  --fs: 14px;\n  --icon: 18px;\n  --radius: var(--aui-radius, 12px);\n\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: var(--gap);\n  height: var(--h);\n  padding: 0 var(--px);\n  margin: 0;\n  border: 1px solid transparent;\n  border-radius: var(--radius);\n  font-family: var(--aui-font, \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: var(--fs);\n  font-weight: 500;\n  line-height: 1;\n  letter-spacing: -0.01em;\n  max-width: 100%;\n  min-width: 0;\n  white-space: nowrap;\n  text-decoration: none;\n  cursor: pointer;\n  user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  -webkit-font-smoothing: antialiased;\n  transition:\n    background-color 160ms ease,\n    border-color 160ms ease,\n    color 160ms ease,\n    box-shadow 160ms ease,\n    transform 160ms var(--ui-btn-ease),\n    opacity 160ms ease;\n}\n\n.ui-button[data-size=\"sm\"] {\n  --h: 32px;\n  --px: 12px;\n  --gap: 6px;\n  --fs: 13px;\n  --icon: 16px;\n  --radius: calc(var(--aui-radius, 12px) - 2px);\n}\n.ui-button[data-size=\"lg\"] {\n  --h: 48px;\n  --px: 20px;\n  --gap: 10px;\n  --fs: 15px;\n  --icon: 20px;\n  --radius: calc(var(--aui-radius, 12px) + 2px);\n}\n.ui-button[data-icon-only] {\n  width: var(--h);\n  padding: 0;\n}\n\n/* primary: like the send button */\n/* primary: a dark gradient with a light edge running down the border and a 1px ring */\n.ui-button[data-variant=\"primary\"] {\n  background:\n    linear-gradient(var(--ui-btn-dark-top), var(--ui-btn-dark-bottom)) padding-box,\n    linear-gradient(var(--ui-btn-edge-top), var(--ui-btn-edge-mid), var(--ui-btn-edge-bottom)) border-box;\n  color: var(--ui-btn-on-ink);\n  box-shadow:\n    0 0 0 1px var(--ui-btn-ring),\n    0 2px 4px var(--ui-btn-dark-shadow);\n}\n.ui-button[data-variant=\"primary\"]:hover:not(:disabled) {\n  background:\n    linear-gradient(var(--ui-btn-dark-hover-top), var(--ui-btn-dark-hover-bottom)) padding-box,\n    linear-gradient(var(--ui-btn-edge-top), var(--ui-btn-edge-mid), var(--ui-btn-edge-bottom)) border-box;\n}\n\n/* secondary: white with the card outline */\n.ui-button[data-variant=\"secondary\"] {\n  background: var(--ui-btn-surface);\n  border-color: var(--ui-btn-line);\n  color: var(--ui-btn-ink);\n  box-shadow: 0 1px 2px var(--ui-btn-surface-shadow);\n}\n.ui-button[data-variant=\"secondary\"]:hover:not(:disabled) {\n  background: var(--ui-btn-surface-hover);\n}\n\n/* ghost: a muted label, the gray layer appears on hover */\n.ui-button[data-variant=\"ghost\"] {\n  background: transparent;\n  color: var(--ui-btn-muted);\n}\n.ui-button[data-variant=\"ghost\"]:hover:not(:disabled) {\n  background: var(--ui-btn-fill);\n  color: var(--ui-btn-hover);\n}\n\n/* destructive: red label on a light red fill */\n.ui-button[data-variant=\"destructive\"] {\n  background: var(--ui-btn-danger-bg);\n  color: var(--ui-btn-danger);\n}\n.ui-button[data-variant=\"destructive\"]:hover:not(:disabled) {\n  background: color-mix(in srgb, var(--ui-btn-danger-bg) 88%, var(--ui-btn-danger));\n}\n.ui-button[data-variant=\"destructive\"] .ui-button__label::after {\n  --ui-btn-shine: rgba(255, 255, 255, 0.82);\n}\n\n.ui-button:active:not(:disabled):not([data-loading]) {\n  transform: scale(0.97);\n}\n.ui-button:focus-visible {\n  outline: 2px solid var(--ui-btn-focus);\n  outline-offset: 2px;\n}\n.ui-button:disabled {\n  opacity: 0.4;\n  cursor: default;\n}\n.ui-button[data-loading] {\n  cursor: default;\n}\n\n.ui-button__icon {\n  display: grid;\n  place-items: center;\n  flex: none;\n  width: var(--icon);\n  height: var(--icon);\n}\n.ui-button__icon {\n  overflow: hidden;\n}\n.ui-button__icon > svg,\n.ui-button__icon > img {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n.ui-button__icon > img {\n  object-fit: contain;\n}\n\n/* Morph: the button clips while its width glides; changed parts blur in */\n.ui-button {\n  overflow: clip;\n}\n.ui-button[data-ready] .ui-button__label {\n  animation: ui-button-label-in 480ms cubic-bezier(0.25, 1, 0.5, 1) both;\n}\n.ui-button[data-ready] .ui-button__icon {\n  animation: ui-button-icon-in 520ms cubic-bezier(0.25, 1, 0.5, 1) both;\n}\n\n/* Loading: a band sweeps across a copy of the label, like \"Request approval\" in the Chat Composer */\n/* A label too long for its parent ends in \"…\" – the full text shows on hover */\n.ui-button__label {\n  position: relative;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  /* Room for descenders now that the label clips */\n  line-height: normal;\n}\n.ui-button__label::after {\n  content: attr(data-text);\n  content: attr(data-text) / \"\";\n  position: absolute;\n  inset: 0;\n  white-space: nowrap;\n  color: transparent;\n  background: linear-gradient(90deg, transparent 38%, var(--ui-btn-shine) 50%, transparent 62%) 100% 0 / 300% 100%\n    no-repeat;\n  -webkit-background-clip: text;\n  background-clip: text;\n  opacity: 0;\n  pointer-events: none;\n}\n.ui-button[data-variant=\"primary\"] .ui-button__label::after {\n  --ui-btn-shine: var(--ui-btn-shine-on-ink);\n}\n.ui-button[data-loading] .ui-button__label {\n  color: color-mix(in srgb, currentColor 55%, transparent);\n}\n.ui-button[data-loading] .ui-button__label::after {\n  opacity: 1;\n  animation: ui-button-sweep calc(var(--ui-btn-load) * 1.6) linear infinite;\n}\n.ui-button__spinner {\n  animation: ui-button-spin 900ms linear infinite;\n}\n\n@keyframes ui-button-label-in {\n  from {\n    opacity: 0;\n    filter: blur(3px);\n    transform: translateY(25%);\n  }\n}\n@keyframes ui-button-icon-in {\n  from {\n    opacity: 0;\n    filter: blur(2px);\n    transform: scale(0.7);\n  }\n}\n\n@keyframes ui-button-spin {\n  to {\n    transform: rotate(1turn);\n  }\n}\n\n@keyframes ui-button-sweep {\n  from {\n    background-position: 100% 0;\n  }\n  to {\n    background-position: 0% 0;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .ui-button[data-ready] .ui-button__label,\n  .ui-button[data-ready] .ui-button__icon {\n    animation: none;\n  }\n  .ui-button {\n    transition-duration: 1ms;\n  }\n  .ui-button[data-loading] .ui-button__label::after {\n    animation: none;\n    opacity: 0;\n  }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-button {\n  /* primary inverts: a light gradient with dark text */\n  --ui-btn-on-ink: #121213;\n  --ui-btn-dark-top: #f4f4f5;\n  --ui-btn-dark-bottom: #e4e4e7;\n  --ui-btn-dark-hover-top: #ffffff;\n  --ui-btn-dark-hover-bottom: #ececef;\n  --ui-btn-edge-top: #ffffff;\n  --ui-btn-edge-mid: #ececef;\n  --ui-btn-edge-bottom: #d4d4d8;\n  --ui-btn-ring: rgba(0, 0, 0, 0.6);\n  --ui-btn-dark-shadow: rgba(0, 0, 0, 0.4);\n  --ui-btn-shine-on-ink: rgba(255, 255, 255, 0.9);\n\n  --ui-btn-ink: var(--aui-ink, #ededed);\n  --ui-btn-surface: var(--aui-surface, #232326);\n  --ui-btn-surface-hover: #2a2a2d;\n  --ui-btn-surface-shadow: rgba(0, 0, 0, 0.4);\n  --ui-btn-line: var(--aui-border, rgba(255, 255, 255, 0.1));\n  --ui-btn-muted: var(--aui-muted, #8f8f8f);\n  --ui-btn-hover: #ededed;\n  --ui-btn-fill: rgba(255, 255, 255, 0.06);\n  --ui-btn-shine: rgba(237, 237, 237, 0.95);\n  --ui-btn-danger: var(--aui-danger, #ff8a7a);\n  --ui-btn-danger-bg: color-mix(in srgb, var(--aui-red, #ff6b5f) 16%, transparent);\n  --ui-btn-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n`;\n"}]}