{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sparkle-button",
  "type": "registry:ui",
  "title": "Sparkle Button",
  "description": "Monochrome generate button with staggered per-character blur-in text, a width spring between labels, and a pulsing sparkle icon while loading.",
  "dependencies": [
    "clsx",
    "tailwind-merge",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/sparkle-button.tsx",
      "content": "\"use client\";\n\nimport React, {\n  useState,\n  useMemo,\n  useRef,\n  useLayoutEffect,\n  useEffect,\n} from \"react\";\nimport {\n  motion,\n  AnimatePresence,\n  useReducedMotion,\n  type HTMLMotionProps,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SparkleButtonSize = \"sm\" | \"default\" | \"lg\";\nexport type SparkleButtonVariant = \"default\" | \"outline\" | \"glass\";\nexport type BlurAnimateBy = \"letters\" | \"words\";\nexport type BlurDirection = \"top\" | \"bottom\";\n\nexport interface SparkleButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  variant?: SparkleButtonVariant;\n  text?: string;\n  activeText?: string;\n  loading?: boolean;\n  onLoadingChange?: (loading: boolean) => void;\n  size?: SparkleButtonSize;\n  icon?: React.ReactNode;\n  animateBy?: BlurAnimateBy;\n  direction?: BlurDirection;\n  delay?: number;\n  stepDuration?: number;\n  dissolveDuration?: number;\n  springStiffness?: number;\n  springDamping?: number;\n}\n\nconst sizeStyles: Record<SparkleButtonSize, string> = {\n  sm: \"h-8 px-4 text-xs gap-2 [&_svg]:size-3.5\",\n  default: \"h-10 px-5 text-sm gap-2.5 [&_svg]:size-4\",\n  lg: \"h-12 px-7 text-base gap-3 [&_svg]:size-5\",\n};\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nconst buildKeyframes = (\n  from: Record<string, string | number>,\n  steps: Array<Record<string, string | number>>,\n): Record<string, Array<string | number>> => {\n  const keys = new Set<string>([\n    ...Object.keys(from),\n    ...steps.flatMap((s) => Object.keys(s)),\n  ]);\n\n  const keyframes: Record<string, Array<string | number>> = {};\n  keys.forEach((k) => {\n    keyframes[k] = [from[k], ...steps.map((s) => s[k])];\n  });\n  return keyframes;\n};\n\nexport const SparkleButton = React.forwardRef<\n  HTMLButtonElement,\n  SparkleButtonProps\n>(\n  (\n    {\n      className,\n      variant = \"default\",\n      text = \"Generate Magic\",\n      activeText = \"Generating...\",\n      loading,\n      onLoadingChange,\n      size = \"default\",\n      icon,\n      animateBy = \"letters\",\n      direction = \"top\",\n      delay = 40,\n      stepDuration = 0.35,\n      dissolveDuration = 0.2,\n      springStiffness = 350,\n      springDamping = 28,\n      onClick,\n      disabled,\n      ...props\n    },\n    ref,\n  ) => {\n    const reduceMotion = useReducedMotion();\n    const [internalLoading, setInternalLoading] = useState(false);\n    const isControlled = loading !== undefined;\n    const isLoading = isControlled ? loading : internalLoading;\n\n    const idleMeasureRef = useRef<HTMLSpanElement>(null);\n    const activeMeasureRef = useRef<HTMLSpanElement>(null);\n    const [measuredWidths, setMeasuredWidths] = useState<{\n      idle: number;\n      active: number;\n    }>({ idle: 0, active: 0 });\n\n    const updateMeasurements = () => {\n      if (idleMeasureRef.current && activeMeasureRef.current) {\n        const idleW = Math.ceil(\n          idleMeasureRef.current.getBoundingClientRect().width,\n        );\n        const activeW = Math.ceil(\n          activeMeasureRef.current.getBoundingClientRect().width,\n        );\n        setMeasuredWidths({ idle: idleW, active: activeW });\n      }\n    };\n\n    useIsomorphicLayoutEffect(() => {\n      updateMeasurements();\n      if (typeof document !== \"undefined\" && \"fonts\" in document) {\n        document.fonts.ready.then(updateMeasurements);\n      }\n      window.addEventListener(\"resize\", updateMeasurements);\n      return () => window.removeEventListener(\"resize\", updateMeasurements);\n    }, [text, activeText, size]);\n\n    const targetWidth = isLoading ? measuredWidths.active : measuredWidths.idle;\n    const currentTargetText = isLoading ? activeText : text;\n\n    const segments = useMemo(() => {\n      if (animateBy === \"words\") {\n        return currentTargetText.split(\" \");\n      }\n      return currentTargetText.split(\"\");\n    }, [currentTargetText, animateBy]);\n\n    const defaultFrom = useMemo(\n      () =>\n        direction === \"top\"\n          ? { filter: \"blur(10px)\", opacity: 0, y: -12 }\n          : { filter: \"blur(10px)\", opacity: 0, y: 12 },\n      [direction],\n    );\n\n    const defaultTo = useMemo(\n      () => [\n        {\n          filter: \"blur(4px)\",\n          opacity: 0.65,\n          y: direction === \"top\" ? 2 : -2,\n        },\n        { filter: \"blur(0px)\", opacity: 1, y: 0 },\n      ],\n      [direction],\n    );\n\n    const stepCount = defaultTo.length + 1;\n    const times = useMemo(\n      () =>\n        Array.from({ length: stepCount }, (_, i) =>\n          stepCount === 1 ? 0 : i / (stepCount - 1),\n        ),\n      [stepCount],\n    );\n\n    const animateKeyframes = useMemo(\n      () => buildKeyframes(defaultFrom, defaultTo),\n      [defaultFrom, defaultTo],\n    );\n\n    const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {\n      if (disabled) return;\n      if (!isControlled) {\n        setInternalLoading((prev) => !prev);\n      }\n      onLoadingChange?.(!isLoading);\n      onClick?.(e);\n    };\n\n    const getVariantStyles = () => {\n      switch (variant) {\n        case \"outline\":\n          return cn(\n            \"bg-white text-zinc-900 border border-zinc-200 shadow-xs hover:bg-zinc-50 hover:border-zinc-300 hover:shadow-sm focus-visible:ring-zinc-400 focus-visible:ring-offset-background\",\n            \"dark:bg-zinc-950/80 dark:text-zinc-100 dark:border-white/15 dark:shadow-none dark:hover:bg-zinc-900 dark:hover:border-white/25 dark:focus-visible:ring-white/40 dark:focus-visible:ring-offset-black\",\n            \"after:opacity-0\",\n          );\n        case \"glass\":\n          return cn(\n            \"bg-zinc-100/80 text-zinc-900 border border-zinc-300/80 backdrop-blur-md shadow-[inset_0_1px_1px_rgba(255,255,255,0.9),0_4px_14px_rgba(0,0,0,0.06)] hover:bg-zinc-100 hover:border-zinc-400 focus-visible:ring-zinc-400 focus-visible:ring-offset-background\",\n            \"dark:bg-white/6 dark:text-white dark:border-white/15 dark:backdrop-blur-md dark:shadow-[inset_0_1px_1px_rgba(255,255,255,0.15),0_4px_16px_rgba(0,0,0,0.5)] dark:hover:bg-white/10 dark:hover:border-white/25 dark:focus-visible:ring-white/40 dark:focus-visible:ring-offset-black\",\n            \"after:opacity-0\",\n          );\n        default:\n          return cn(\n            \"bg-zinc-900 text-white border-zinc-800 shadow-[inset_0px_1px_1px_0px_rgba(255,255,255,0.25),0px_4px_14px_-2px_rgba(0,0,0,0.22),0px_1px_2px_0px_rgba(0,0,0,0.12)] hover:bg-black hover:border-zinc-700 hover:shadow-[inset_0px_1px_1.5px_0px_rgba(255,255,255,0.35),0px_8px_20px_-4px_rgba(0,0,0,0.28)] focus-visible:ring-zinc-950/40 focus-visible:ring-offset-background\",\n            \"dark:bg-[#0d0d0f] dark:text-white dark:border-white/15 dark:shadow-[inset_0px_1px_1px_0px_rgba(255,255,255,0.22),inset_0px_2px_3px_0px_rgba(255,255,255,0.1),inset_0px_-2px_4px_0px_rgba(0,0,0,0.5),0px_4px_16px_-2px_rgba(0,0,0,0.8),0px_1px_2px_0px_rgba(0,0,0,0.4)] dark:hover:border-white/30 dark:hover:shadow-[inset_0px_1px_1.5px_0px_rgba(255,255,255,0.35),inset_0px_2px_4px_0px_rgba(255,255,255,0.15),inset_0px_-2px_4px_0px_rgba(0,0,0,0.5),0px_8px_24px_-4px_rgba(0,0,0,0.9),0px_0px_16px_0px_rgba(255,255,255,0.06)] dark:hover:brightness-105 dark:focus-visible:ring-white/40 dark:focus-visible:ring-offset-black\",\n            \"after:bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.55),transparent)]\",\n          );\n      }\n    };\n\n    return (\n      <div className=\"relative inline-flex items-center justify-center p-1 select-none\">\n        <motion.button\n          ref={ref}\n          type=\"button\"\n          aria-busy={isLoading}\n          disabled={disabled}\n          onClick={handleClick}\n          whileTap={reduceMotion ? undefined : { scale: 0.98 }}\n          className={cn(\n            \"group relative inline-flex items-center justify-center rounded-full font-medium select-none overflow-hidden cursor-pointer\",\n            \"transition-[border-color,box-shadow,filter,background-color] duration-300 ease-out\",\n            \"disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed\",\n            \"after:absolute after:top-0 after:left-[12%] after:right-[12%] after:h-px after:pointer-events-none\",\n            getVariantStyles(),\n            sizeStyles[size],\n            className,\n          )}\n          {...props}\n        >\n          <span\n            ref={idleMeasureRef}\n            aria-hidden=\"true\"\n            className=\"invisible absolute pointer-events-none opacity-0 select-none whitespace-nowrap tracking-tight font-medium\"\n          >\n            {text}\n          </span>\n          <span\n            ref={activeMeasureRef}\n            aria-hidden=\"true\"\n            className=\"invisible absolute pointer-events-none opacity-0 select-none whitespace-nowrap tracking-tight font-medium\"\n          >\n            {activeText}\n          </span>\n\n          <span className=\"sr-only\">{currentTargetText}</span>\n\n          <span\n            aria-hidden=\"true\"\n            className=\"relative z-10 flex items-center justify-center gap-2.5\"\n          >\n            {icon ? (\n              <span className=\"shrink-0\">{icon}</span>\n            ) : (\n              <motion.svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                viewBox=\"0 0 24 24\"\n                animate={\n                  isLoading && !reduceMotion\n                    ? {\n                        scale: [1, 1.15, 1],\n                        filter: [\n                          \"drop-shadow(0 0 2px rgba(255,255,255,0.5))\",\n                          \"drop-shadow(0 0 9px rgba(255,255,255,0.95))\",\n                          \"drop-shadow(0 0 2px rgba(255,255,255,0.5))\",\n                        ],\n                      }\n                    : {\n                        scale: 1,\n                        filter: \"drop-shadow(0 0 2px rgba(255,255,255,0.4))\",\n                      }\n                }\n                transition={\n                  reduceMotion\n                    ? { duration: 0 }\n                    : isLoading\n                      ? {\n                          duration: 1.6,\n                          repeat: Infinity,\n                          ease: \"easeInOut\",\n                        }\n                      : { duration: 0.25 }\n                }\n                className={cn(\n                  \"shrink-0 transition-colors duration-300\",\n                  variant === \"outline\" || variant === \"glass\"\n                    ? \"fill-zinc-700 group-hover:fill-zinc-950 dark:fill-[#e8e8e8] dark:group-hover:fill-white\"\n                    : \"fill-[#e8e8e8] group-hover:fill-white\",\n                )}\n              >\n                <path\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  d=\"M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z\"\n                />\n              </motion.svg>\n            )}\n\n            <motion.div\n              animate={targetWidth > 0 ? { width: targetWidth } : undefined}\n              transition={\n                reduceMotion\n                  ? { duration: 0 }\n                  : {\n                      type: \"spring\",\n                      stiffness: springStiffness,\n                      damping: springDamping,\n                      mass: 0.8,\n                    }\n              }\n              style={{ willChange: \"width\" }}\n              className=\"relative inline-flex items-center justify-center overflow-hidden\"\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={isLoading ? \"active\" : \"idle\"}\n                  initial={reduceMotion ? false : { opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={\n                    reduceMotion\n                      ? { opacity: 0, transition: { duration: 0 } }\n                      : {\n                          opacity: 0,\n                          filter: \"blur(8px)\",\n                          y: direction === \"top\" ? 8 : -8,\n                          transition: {\n                            duration: dissolveDuration,\n                            ease: [0.16, 1, 0.3, 1] as const,\n                          },\n                        }\n                  }\n                  className={cn(\n                    \"inline-flex items-center justify-center tracking-tight font-medium whitespace-nowrap\",\n                    variant === \"outline\" || variant === \"glass\"\n                      ? \"text-zinc-900 dark:text-white\"\n                      : \"text-white\",\n                  )}\n                >\n                  {segments.map((segment, index) => (\n                    <motion.span\n                      key={`${isLoading ? \"act\" : \"idl\"}-${index}`}\n                      initial={reduceMotion ? false : defaultFrom}\n                      animate={\n                        reduceMotion\n                          ? { filter: \"blur(0px)\", opacity: 1, y: 0 }\n                          : animateKeyframes\n                      }\n                      transition={\n                        reduceMotion\n                          ? { duration: 0 }\n                          : {\n                              duration: stepDuration,\n                              times,\n                              delay: (index * delay) / 1000,\n                              ease: [0.16, 1, 0.3, 1] as const,\n                            }\n                      }\n                      style={{\n                        display: \"inline-block\",\n                        willChange: \"transform, filter, opacity\",\n                      }}\n                    >\n                      {segment === \" \" ? \"\\u00A0\" : segment}\n                      {animateBy === \"words\" &&\n                        index < segments.length - 1 &&\n                        \"\\u00A0\"}\n                    </motion.span>\n                  ))}\n                </motion.span>\n              </AnimatePresence>\n            </motion.div>\n          </span>\n        </motion.button>\n      </div>\n    );\n  },\n);\n\nSparkleButton.displayName = \"SparkleButton\";\n\nexport default SparkleButton;\n",
      "type": "registry:ui",
      "target": "@ui/sparkle-button.tsx"
    }
  ]
}
