{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "halo-button",
  "type": "registry:ui",
  "description": "Primary action button with animated gradient rim, frosted fill, staggered label, and loading spinner",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "files": [
    {
      "path": "registry/default/ui/halo-button.tsx",
      "content": "\"use client\"\n\n/**\n * Primary action button matching `HaloSearchInput` polish: animated gradient\n * rim, frosted fill, staggered label, loading text + trailing spinner crossfade.\n */\nimport {\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ReactNode,\n} from \"react\"\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst LABEL_STAGGER_SEC = 0.018\nconst LABEL_CHAR_DURATION_SEC = 0.16\nconst DEFAULT_LOADING_STAGGER_SEC = 0.038\nconst DEFAULT_LOADING_CHAR_DURATION_SEC = 0.36\nconst TRAILING_EXIT_DURATION_SEC = 0.2\n\n/** Layout spring: smooth width when label/spinner changes; bounce kept low per motion skill. */\nconst layoutSpring = {\n  type: \"spring\" as const,\n  bounce: 0.12,\n  damping: 34,\n  stiffness: 420,\n}\n\nconst trailingExitTransition = (reduceMotion: boolean) => ({\n  duration: reduceMotion ? 0 : TRAILING_EXIT_DURATION_SEC,\n  ease: \"easeOut\" as const,\n})\n\nconst trailingCrossfade = (reduceMotion: boolean) => ({\n  exit: {\n    opacity: reduceMotion ? 1 : 0,\n    scale: reduceMotion ? 1 : 0.88,\n  },\n  initial: {\n    opacity: reduceMotion ? 1 : 0,\n    scale: reduceMotion ? 1 : 0.88,\n  },\n})\n\nfunction ButtonSpinner({\n  className,\n  reduceMotion,\n}: {\n  className?: string\n  reduceMotion: boolean\n}) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex origin-center text-muted-foreground\",\n        !reduceMotion && \"animate-spin\",\n        className\n      )}\n    >\n      <svg\n        aria-hidden=\"true\"\n        className=\"h-3.5 w-3.5\"\n        focusable=\"false\"\n        viewBox=\"0 0 24 24\"\n      >\n        <circle\n          className=\"opacity-[0.22]\"\n          cx=\"12\"\n          cy=\"12\"\n          fill=\"none\"\n          r=\"9\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2.25\"\n        />\n        <circle\n          cx=\"12\"\n          cy=\"12\"\n          fill=\"none\"\n          r=\"9\"\n          stroke=\"currentColor\"\n          strokeDasharray=\"14 42\"\n          strokeLinecap=\"round\"\n          strokeWidth=\"2.25\"\n        />\n      </svg>\n    </span>\n  )\n}\n\nfunction StaggeredTextLabel({\n  text,\n  reduceMotion,\n  staggerSec = LABEL_STAGGER_SEC,\n  charDurationSec = LABEL_CHAR_DURATION_SEC,\n}: {\n  text: string\n  reduceMotion: boolean\n  staggerSec?: number\n  charDurationSec?: number\n}) {\n  const chars = useMemo(() => {\n    const counts = new Map<string, number>()\n    return Array.from(text, (char) => {\n      const count = counts.get(char) ?? 0\n      counts.set(char, count + 1)\n      return { char, key: `${char}-${count}` }\n    })\n  }, [text])\n\n  return (\n    <motion.span\n      animate=\"visible\"\n      className=\"inline-flex items-center font-medium text-foreground text-sm tracking-tight antialiased\"\n      initial=\"hidden\"\n      key={text}\n      variants={{\n        hidden: {},\n        visible: {\n          transition: {\n            staggerChildren: reduceMotion ? 0 : staggerSec,\n          },\n        },\n      }}\n    >\n      {chars.map((glyph) => (\n        <motion.span\n          className=\"inline-block whitespace-pre\"\n          key={glyph.key}\n          transition={{\n            duration: reduceMotion ? 0 : charDurationSec,\n            ease: \"easeOut\",\n          }}\n          variants={{\n            hidden: { opacity: reduceMotion ? 1 : 0 },\n            visible: { opacity: 1 },\n          }}\n        >\n          {glyph.char}\n        </motion.span>\n      ))}\n    </motion.span>\n  )\n}\n\nfunction TrailingLoadingSlot({\n  reduceMotion,\n  reveal,\n}: {\n  reduceMotion: boolean\n  reveal: boolean\n}) {\n  const cross = trailingCrossfade(reduceMotion)\n  return (\n    <motion.div\n      animate={\n        reveal\n          ? { opacity: 1, scale: 1 }\n          : { opacity: 0, scale: reduceMotion ? 1 : 0.88 }\n      }\n      aria-hidden\n      className={cn(\n        \"absolute inset-0 flex items-center justify-center rounded-full\",\n        \"pointer-events-none bg-muted text-muted-foreground shadow-foreground/10 shadow-sm ring-1 ring-foreground/10\"\n      )}\n      exit={{ ...cross.exit, transition: trailingExitTransition(reduceMotion) }}\n      initial={cross.initial}\n      key=\"trailing-loading\"\n      transition={\n        reduceMotion\n          ? { duration: 0 }\n          : reveal\n            ? { ...layoutSpring }\n            : { duration: 0 }\n      }\n    >\n      <ButtonSpinner reduceMotion={reduceMotion} />\n    </motion.div>\n  )\n}\n\nexport interface HaloButtonProps\n  extends Omit<ComponentProps<typeof ButtonPrimitive>, \"children\"> {\n  children: ReactNode\n  /** Frosted inner surface so gradient blobs read through (matches search translucent mode). */\n  translucent?: boolean\n  /** Shows trailing spinner and sets `aria-busy`; label switches to `loadingText`. */\n  isLoading?: boolean\n  /** Copy shown while `isLoading` (staggered when `staggerLabel` is true). */\n  loadingText?: string\n  /** Stagger between loading glyphs (seconds). */\n  loadingStaggerSec?: number\n  /** Duration of each loading glyph fade-in (seconds). */\n  loadingCharDurationSec?: number\n  /**\n   * When `children` is a string, replay character stagger on change.\n   * Ignored for non-string children.\n   */\n  staggerLabel?: boolean\n  className?: string\n}\n\nexport function HaloButton({\n  children,\n  className,\n  translucent = true,\n  isLoading = false,\n  loadingText = \"Working…\",\n  loadingStaggerSec = DEFAULT_LOADING_STAGGER_SEC,\n  loadingCharDurationSec = DEFAULT_LOADING_CHAR_DURATION_SEC,\n  staggerLabel = true,\n  disabled,\n  onFocus,\n  onBlur,\n  onMouseEnter,\n  onMouseLeave,\n  style,\n  ...props\n}: HaloButtonProps) {\n  const [isFocused, setIsFocused] = useState(false)\n  const [isHovered, setIsHovered] = useState(false)\n  /** Spinner fades in only after outer `layout` width animation finishes (avoids leading the expansion). */\n  const [trailingRevealReady, setTrailingRevealReady] = useState(false)\n  const reduceMotion = useReducedMotion() ?? false\n  const isLoadingRef = useRef(isLoading)\n  isLoadingRef.current = isLoading\n\n  useEffect(() => {\n    if (!isLoading) {\n      setTrailingRevealReady(false)\n      return\n    }\n    if (reduceMotion) {\n      setTrailingRevealReady(true)\n    }\n  }, [isLoading, reduceMotion])\n\n  /** If layout completion never fires (e.g. zero delta), still reveal the spinner. */\n  useEffect(() => {\n    if (!isLoading || reduceMotion) {\n      return\n    }\n    const id = window.setTimeout(() => {\n      setTrailingRevealReady(true)\n    }, 480)\n    return () => window.clearTimeout(id)\n  }, [isLoading, reduceMotion])\n\n  /** `disabled` + `pointer-events-none` while loading can strand `isHovered`; ignore hover then. */\n  const hoverAllowed = !(disabled || isLoading)\n  const rimEmphasis = isFocused || (isHovered && hoverAllowed)\n\n  const borderGlow = useMemo(() => {\n    let idleOpacity: number\n    if (translucent) {\n      idleOpacity = rimEmphasis ? 0.92 : 0.72\n    } else {\n      idleOpacity = rimEmphasis ? 0.78 : 0.5\n    }\n    return {\n      opacityIdle: idleOpacity,\n      primaryBg: \"linear-gradient(90deg, #ff0080, #7928ca, #00d4ff, #0070f3)\",\n      secondaryBg: \"linear-gradient(90deg, #ff4d4d, #f9cb28, #ff0080)\",\n      tertiaryBg: \"linear-gradient(90deg, #0070f3, #00d4ff, #7928ca)\",\n      primaryLeft: [\"-5%\", \"75%\", \"-5%\"],\n      secondaryLeft: [\"65%\", \"10%\", \"65%\"],\n      tertiaryLeft: [\"25%\", \"55%\", \"25%\"],\n      durationMain: 6,\n      durationSec: 5,\n      durationTer: 4,\n    }\n  }, [translucent, rimEmphasis])\n\n  const labelIsString = typeof children === \"string\"\n  const showTrailing = isLoading\n\n  const labelContent = (() => {\n    if (isLoading) {\n      if (staggerLabel) {\n        return (\n          <StaggeredTextLabel\n            charDurationSec={loadingCharDurationSec}\n            reduceMotion={reduceMotion}\n            staggerSec={loadingStaggerSec}\n            text={loadingText}\n          />\n        )\n      }\n      return (\n        <span className=\"font-medium text-foreground text-sm tracking-tight antialiased\">\n          {loadingText}\n        </span>\n      )\n    }\n    if (labelIsString && staggerLabel) {\n      return <StaggeredTextLabel reduceMotion={reduceMotion} text={children} />\n    }\n    return (\n      <span className=\"font-medium text-foreground text-sm tracking-tight antialiased\">\n        {children}\n      </span>\n    )\n  })()\n\n  let fillBackground: string\n  if (translucent) {\n    if (isHovered && hoverAllowed) {\n      fillBackground =\n        \"linear-gradient(to bottom, color-mix(in oklch, var(--card) 88%, transparent) 0%, color-mix(in oklch, var(--card) 74%, transparent) 100%)\"\n    } else {\n      fillBackground =\n        \"linear-gradient(to bottom, color-mix(in oklch, var(--card) 82%, transparent) 0%, color-mix(in oklch, var(--card) 70%, transparent) 100%)\"\n    }\n  } else if (isHovered && hoverAllowed) {\n    fillBackground =\n      \"linear-gradient(to bottom, var(--card) 0%, color-mix(in oklch, var(--card) 90%, var(--muted)) 100%)\"\n  } else {\n    fillBackground =\n      \"linear-gradient(to bottom, var(--card) 0%, color-mix(in oklch, var(--card) 94%, var(--muted)) 100%)\"\n  }\n\n  return (\n    <motion.div\n      className={cn(\"relative inline-flex max-w-full\", className)}\n      layout={!reduceMotion}\n      transition={reduceMotion ? undefined : { layout: layoutSpring }}\n      onLayoutAnimationComplete={() => {\n        if (isLoadingRef.current && !reduceMotion) {\n          setTrailingRevealReady(true)\n        }\n      }}\n    >\n      <div className=\"relative rounded-full p-px\">\n        <div className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-full\">\n          <motion.div\n            animate={{\n              opacity: borderGlow.opacityIdle,\n            }}\n            className=\"absolute inset-x-0 bottom-0 h-1/2\"\n            transition={{ duration: 0.25, ease: \"easeOut\" }}\n          >\n            <motion.div\n              animate={{\n                left: borderGlow.primaryLeft,\n              }}\n              className=\"-bottom-4 absolute h-16 w-48 blur-xl\"\n              style={{\n                background: borderGlow.primaryBg,\n              }}\n              transition={{\n                duration: borderGlow.durationMain,\n                repeat: Number.POSITIVE_INFINITY,\n                ease: \"easeInOut\",\n              }}\n            />\n            <motion.div\n              animate={{\n                left: borderGlow.secondaryLeft,\n              }}\n              className=\"-bottom-3 absolute h-12 w-32 blur-lg\"\n              style={{\n                background: borderGlow.secondaryBg,\n              }}\n              transition={{\n                duration: borderGlow.durationSec,\n                repeat: Number.POSITIVE_INFINITY,\n                ease: \"easeInOut\",\n              }}\n            />\n            <motion.div\n              animate={{\n                left: borderGlow.tertiaryLeft,\n              }}\n              className=\"-bottom-2 absolute h-10 w-24 blur-lg\"\n              style={{\n                background: borderGlow.tertiaryBg,\n              }}\n              transition={{\n                duration: borderGlow.durationTer,\n                repeat: Number.POSITIVE_INFINITY,\n                ease: \"easeInOut\",\n              }}\n            />\n          </motion.div>\n        </div>\n\n        <ButtonPrimitive\n          aria-busy={isLoading || undefined}\n          className={cn(\n            \"relative flex min-h-11 min-w-0 select-none items-center gap-2.5 rounded-full px-6 py-2.5\",\n            \"border border-border/90\",\n            \"shadow-[0_1px_0_rgba(255,255,255,0.1)_inset,0_1px_2px_rgba(0,0,0,0.04),0_4px_14px_rgba(0,0,0,0.05)]\",\n            \"dark:border-border dark:shadow-[0_1px_0_rgba(255,255,255,0.06)_inset,0_1px_2px_rgba(0,0,0,0.35),0_4px_20px_rgba(0,0,0,0.35)]\",\n            \"outline-none focus-visible:border-ring/70 focus-visible:ring-2 focus-visible:ring-ring/30 dark:focus-visible:border-zinc-500/45\",\n            \"transition-[transform,background-color,backdrop-filter,border-color,box-shadow] duration-200 ease-out\",\n            \"fine-hover:hover:border-ring/50 fine-hover:hover:shadow-[0_1px_0_rgba(255,255,255,0.12)_inset,0_2px_6px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)]\",\n            \"dark:fine-hover:hover:border-zinc-500/60 dark:fine-hover:hover:shadow-[0_1px_0_rgba(255,255,255,0.08)_inset,0_4px_12px_rgba(0,0,0,0.45),0_12px_32px_rgba(0,0,0,0.45)]\",\n            \"enabled:active:scale-[0.96] motion-reduce:enabled:active:scale-100\",\n            \"disabled:pointer-events-none disabled:opacity-50\",\n            translucent && \"backdrop-blur-xl backdrop-saturate-150\"\n          )}\n          disabled={disabled || isLoading}\n          onBlur={(e) => {\n            setIsFocused(false)\n            onBlur?.(e)\n          }}\n          onFocus={(e) => {\n            setIsFocused(true)\n            onFocus?.(e)\n          }}\n          onMouseEnter={(e) => {\n            setIsHovered(true)\n            onMouseEnter?.(e)\n          }}\n          onMouseLeave={(e) => {\n            setIsHovered(false)\n            onMouseLeave?.(e)\n          }}\n          style={{\n            ...style,\n            background: fillBackground,\n          }}\n          {...props}\n        >\n          <span className=\"flex min-w-0 flex-1 items-center justify-center gap-2\">\n            {labelContent}\n          </span>\n\n          {showTrailing ? (\n            <div className=\"relative size-7 shrink-0\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <TrailingLoadingSlot\n                  reduceMotion={reduceMotion}\n                  reveal={trailingRevealReady}\n                />\n              </AnimatePresence>\n            </div>\n          ) : null}\n        </ButtonPrimitive>\n      </div>\n    </motion.div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ]
}