{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"button-clean","type":"registry:ui","title":"Button (Clean)","description":"Primary, secondary, ghost and destructive buttons with icons, sizes and a shimmering loading state","dependencies":[],"files":[{"path":"registry/button-clean/button-clean.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, clean style.\n * Clean style: a dark chip, a white button with hairline shadows, a quiet ghost and a minimal red fill.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type ButtonProps = ComponentPropsWithRef<\"button\"> & {\n  /** primary: a dark chip · secondary: white with a hairline shadow · ghost: text only · destructive: a light red fill, 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-clean\" precedence=\"default\">\n        {css}\n      </style>\n      <button\n        type={type}\n        className={[\"ui-buttonc\", 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-icon-left={(!iconOnly && Boolean(icon)) || undefined}\n        data-icon-right={(!iconOnly && Boolean(iconRight)) || 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-buttonc__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-buttonc__icon\" aria-hidden=\"true\" key={`icon-${iconKey}`} onErrorCapture={hideBroken}>\n            {loading ? <Spinner /> : icon}\n          </span>\n        )}\n        {hasLabel && (\n          <span className=\"ui-buttonc__label\" data-text={text} dir=\"auto\" key={`label-${labelKey}`}>\n            {children}\n          </span>\n        )}\n        {iconRight && (\n          <span className=\"ui-buttonc__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-buttonc__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-buttonc (see Theming in the docs) */\n.ui-buttonc {\n  --ui-btnc-title: var(--aui-ink, #000);\n  --ui-btnc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-btnc-muted: var(--aui-muted, rgba(0, 0, 0, 0.61));\n  --ui-btnc-icon: var(--aui-muted, rgba(0, 0, 0, 0.447));\n  --ui-btnc-solid: var(--aui-primary, rgba(0, 0, 0, 0.88));\n  --ui-btnc-solid-hover: rgba(0, 0, 0, 0.8);\n  --ui-btnc-on-solid: var(--aui-on-primary, #fcfcfc);\n  --ui-btnc-surface: var(--aui-surface, #ffffff);\n  --ui-btnc-shadow: 0 0 0 0.5px var(--aui-border, rgba(0, 0, 0, 0.06)), 0 1px 1px -1px rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  --ui-btnc-shadow-hover: 0 0 0 0.5px var(--aui-border-strong, rgba(0, 0, 0, 0.1)), 0 1px 1px -1px rgba(0, 0, 0, 0.12), 0 1px 3px 0 rgba(0, 0, 0, 0.07);\n  --ui-btnc-wash: rgba(0, 0, 0, 0.03);\n  --ui-btnc-danger: var(--aui-danger, #c4320a);\n  --ui-btnc-danger-bg: color-mix(in srgb, var(--aui-red, #f04438) 8%, transparent);\n  --ui-btnc-danger-bg-hover: color-mix(in srgb, var(--aui-red, #f04438) 12%, transparent);\n  --ui-btnc-shine: rgba(255, 255, 255, 0.82);\n  --ui-btnc-shine-on-solid: #ffffff;\n  --ui-btnc-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-btnc-ease: cubic-bezier(0.22, 1, 0.36, 1);\n  --ui-btnc-load: 650ms;\n\n  --h: 28px;\n  --px: 10px;\n  --gap: 6px;\n  --fs: 13px;\n  --lh: 20px;\n  --icon: 16px;\n  --radius: var(--aui-radius, 6px);\n\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: var(--gap);\n  box-sizing: border-box;\n  height: var(--h);\n  padding: 0 var(--px);\n  margin: 0;\n  border: 0;\n  border-radius: var(--radius);\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: var(--fs);\n  font-weight: 500;\n  line-height: var(--lh);\n  letter-spacing: 0;\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 140ms ease,\n    color 140ms ease,\n    box-shadow 140ms ease,\n    transform 160ms var(--ui-btnc-ease),\n    opacity 140ms ease;\n}\n\n/* Sizes: 24 · 28 · 32 high (taller on touch screens, see below) */\n.ui-buttonc[data-size=\"sm\"] {\n  --h: 24px;\n  --px: 8px;\n  --gap: 4px;\n  --fs: 12px;\n  --lh: 16px;\n  --icon: 14px;\n  --radius: var(--aui-radius, 6px);\n}\n.ui-buttonc[data-size=\"lg\"] {\n  --h: 32px;\n  --px: 12px;\n  --gap: 6px;\n  --fs: 13px;\n  --icon: 16px;\n  --radius: var(--aui-radius, 6px);\n}\n/* Touch screens: every size grows to a comfortable tap height – 32 · 34 · 36 – the look stays the same */\n@media (pointer: coarse) {\n  .ui-buttonc[data-size=\"sm\"] { --h: 32px; }\n  .ui-buttonc[data-size=\"md\"] { --h: 34px; }\n  .ui-buttonc[data-size=\"lg\"] { --h: 36px; }\n}\n/* An icon sits closer to the edge than the text, so both look evenly spaced */\n.ui-buttonc[data-icon-left] {\n  padding-left: calc(var(--px) - 2px);\n}\n.ui-buttonc[data-icon-right] {\n  padding-right: calc(var(--px) - 2px);\n}\n.ui-buttonc[data-icon-only] {\n  width: var(--h);\n  padding: 0;\n}\n\n/* primary: a dark chip */\n.ui-buttonc[data-variant=\"primary\"] {\n  background: var(--ui-btnc-solid);\n  color: var(--ui-btnc-on-solid);\n}\n.ui-buttonc[data-variant=\"primary\"]:hover:not(:disabled) {\n  background: var(--ui-btnc-solid-hover);\n}\n\n/* secondary: white with the card's hairline shadow */\n.ui-buttonc[data-variant=\"secondary\"] {\n  background: var(--ui-btnc-surface);\n  color: var(--ui-btnc-title);\n  box-shadow: var(--ui-btnc-shadow);\n}\n.ui-buttonc[data-variant=\"secondary\"] .ui-buttonc__icon {\n  color: var(--ui-btnc-icon);\n}\n.ui-buttonc[data-variant=\"secondary\"]:hover:not(:disabled) {\n  box-shadow: var(--ui-btnc-shadow-hover);\n}\n\n/* ghost: a muted label, a soft wash appears on hover */\n.ui-buttonc[data-variant=\"ghost\"] {\n  background: transparent;\n  color: var(--ui-btnc-muted);\n}\n.ui-buttonc[data-variant=\"ghost\"] .ui-buttonc__icon {\n  color: var(--ui-btnc-icon);\n}\n.ui-buttonc[data-variant=\"ghost\"]:hover:not(:disabled) {\n  background: var(--ui-btnc-wash);\n  color: var(--ui-btnc-ink);\n}\n.ui-buttonc[data-variant=\"ghost\"]:hover:not(:disabled) .ui-buttonc__icon {\n  color: var(--ui-btnc-ink);\n}\n\n/* destructive: red label on a minimal red fill, no stroke */\n.ui-buttonc[data-variant=\"destructive\"] {\n  background: var(--ui-btnc-danger-bg);\n  color: var(--ui-btnc-danger);\n}\n.ui-buttonc[data-variant=\"destructive\"]:hover:not(:disabled) {\n  background: var(--ui-btnc-danger-bg-hover);\n}\n\n.ui-buttonc .ui-buttonc__icon {\n  transition: color 140ms ease;\n}\n.ui-buttonc:active:not(:disabled):not([data-loading]) {\n  transform: scale(0.97);\n}\n.ui-buttonc:focus-visible {\n  outline: 2px solid var(--ui-btnc-focus);\n  outline-offset: 2px;\n}\n.ui-buttonc:disabled {\n  opacity: 0.4;\n  cursor: default;\n}\n.ui-buttonc[data-loading] {\n  cursor: default;\n}\n\n.ui-buttonc__icon {\n  display: grid;\n  place-items: center;\n  flex: none;\n  width: var(--icon);\n  height: var(--icon);\n}\n.ui-buttonc__icon {\n  overflow: hidden;\n}\n.ui-buttonc__icon > svg,\n.ui-buttonc__icon > img {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n.ui-buttonc__icon > img {\n  object-fit: contain;\n}\n\n/* Morph: the button clips while its width glides; changed parts blur in */\n.ui-buttonc {\n  overflow: clip;\n}\n.ui-buttonc[data-ready] .ui-buttonc__label {\n  animation: ui-buttonc-label-in 480ms cubic-bezier(0.25, 1, 0.5, 1) both;\n}\n.ui-buttonc[data-ready] .ui-buttonc__icon {\n  animation: ui-buttonc-icon-in 520ms cubic-bezier(0.25, 1, 0.5, 1) both;\n}\n\n/* Loading: a light band sweeps across a copy of the dimmed label */\n/* A label too long for its parent ends in \"…\" – the full text shows on hover */\n.ui-buttonc__label {\n  position: relative;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n.ui-buttonc__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-btnc-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-buttonc[data-variant=\"primary\"] .ui-buttonc__label::after {\n  --ui-btnc-shine: var(--ui-btnc-shine-on-solid);\n}\n.ui-buttonc[data-loading] .ui-buttonc__label {\n  color: color-mix(in srgb, currentColor 50%, transparent);\n}\n.ui-buttonc[data-loading] .ui-buttonc__label::after {\n  opacity: 1;\n  animation: ui-buttonc-sweep calc(var(--ui-btnc-load) * 1.6) linear infinite;\n}\n.ui-buttonc__spinner {\n  animation: ui-buttonc-spin 900ms linear infinite;\n}\n\n@keyframes ui-buttonc-label-in {\n  from {\n    opacity: 0;\n    filter: blur(3px);\n    transform: translateY(25%);\n  }\n}\n@keyframes ui-buttonc-icon-in {\n  from {\n    opacity: 0;\n    filter: blur(2px);\n    transform: scale(0.7);\n  }\n}\n\n@keyframes ui-buttonc-spin {\n  to {\n    transform: rotate(1turn);\n  }\n}\n\n@keyframes ui-buttonc-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-buttonc[data-ready] .ui-buttonc__label,\n  .ui-buttonc[data-ready] .ui-buttonc__icon {\n    animation: none;\n  }\n  .ui-buttonc {\n    transition-duration: 1ms;\n  }\n  .ui-buttonc[data-loading] .ui-buttonc__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-buttonc {\n  /* primary inverts: a light chip with dark text */\n  --ui-btnc-solid: var(--aui-primary, rgba(255, 255, 255, 0.9));\n  --ui-btnc-solid-hover: rgba(255, 255, 255, 0.82);\n  --ui-btnc-on-solid: var(--aui-on-primary, #161618);\n  --ui-btnc-shine-on-solid: #ffffff;\n\n  --ui-btnc-title: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-btnc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-btnc-muted: var(--aui-muted, rgba(255, 255, 255, 0.62));\n  --ui-btnc-icon: var(--aui-muted, rgba(255, 255, 255, 0.5));\n  --ui-btnc-surface: var(--aui-surface, #1c1c1e);\n  --ui-btnc-shadow: 0 0 0 0.5px var(--aui-border, rgba(255, 255, 255, 0.1)), 0 1px 2px 0 rgba(0, 0, 0, 0.4);\n  --ui-btnc-shadow-hover: 0 0 0 0.5px var(--aui-border-strong, rgba(255, 255, 255, 0.16)), 0 1px 2px 0 rgba(0, 0, 0, 0.4);\n  --ui-btnc-wash: rgba(255, 255, 255, 0.06);\n  --ui-btnc-danger: var(--aui-danger, #ff8a7a);\n  --ui-btnc-danger-bg: color-mix(in srgb, var(--aui-red, #ff6b5f) 14%, transparent);\n  --ui-btnc-danger-bg-hover: color-mix(in srgb, var(--aui-red, #ff6b5f) 20%, transparent);\n  --ui-btnc-shine: #ffffff;\n  --ui-btnc-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n\n/* Hover of the primary button follows its fill – also a brand color from --aui-primary. Browsers without\n   color-mix() (Safari before 16.2) keep the fixed hover above */\n@supports (color: color-mix(in srgb, red 50%, transparent)) {\n  .ui-buttonc {\n    --ui-btnc-solid-hover: color-mix(in srgb, var(--ui-btnc-solid) 91%, transparent);\n  }\n}\n`;\n"}]}