{
  "slug": "sparkle-button",
  "name": "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.",
  "summary": "A generation trigger with per-character blur-in text, a blur-and-fade swap between the idle label (\"Generate Magic\") and the loading label (\"Generating...\"), a width spring between them, and a sparkle icon that pulses while loading. Comes in default, outline and glass variants.",
  "category": "buttons",
  "tags": [
    "button",
    "sparkle",
    "ai",
    "generate",
    "letters",
    "stagger",
    "morph",
    "flicker",
    "tactile",
    "monochrome"
  ],
  "dependencies": [
    "clsx",
    "tailwind-merge",
    "motion"
  ],
  "version": "1.0.0",
  "createdDate": "2026-09-21",
  "updatedDate": "2026-09-21",
  "interactive": true,
  "supportsColor": false,
  "highlights": [
    "Staggered per-character blur-in when the label changes",
    "Old label exits with blur and fade over dissolveDuration while the width springs (stiffness 350, damping 28, mass 0.8) to the new label",
    "Sparkle icon pulses (scale 1 to 1.15 with a glow) on a 1.6s easeInOut loop while loading",
    "Monochromatic deep tactile bevel with multi-stop black and white composite inset shadows and upper specular reflection",
    "Three precision size scales (sm, default, lg) with automatic vector scaling",
    "Full keyboard focus ring and accessible ARIA state support"
  ],
  "anatomy": [
    "<div> (Ambient layout container for the interactive button)",
    "<button> (Focus-visible tactile pill container with monochromatic inset bevel lighting)",
    "<motion.svg> (Pulsing tri-star magic sparkle vector with soft ambient glow)",
    "<AnimatePresence> (Handles seamless blur and fade crossfade between idle and active states)",
    "<span> (Individual character glyph with a staggered blur-in)"
  ],
  "physics": {
    "engine": "Motion Glyphs & Monochromatic Optics",
    "description": "Staggered character blur-in, a width spring between labels, and a looping icon pulse while loading.",
    "parameters": [
      {
        "label": "Letter Delay",
        "value": "40ms per segment (delay prop)"
      },
      {
        "label": "Segment Blur-in",
        "value": "350ms (stepDuration), blur 10px to 4px to 0px"
      },
      {
        "label": "Loading Pulse",
        "value": "1600ms easeInOut, infinite"
      },
      {
        "label": "Exit Dissolve",
        "value": "200ms (dissolveDuration) cubic-bezier(0.16, 1, 0.3, 1)"
      },
      {
        "label": "Width Spring",
        "value": "stiffness: 350, damping: 28, mass: 0.8"
      },
      {
        "label": "Tactile Compression",
        "value": "whileTap scale 0.98"
      }
    ]
  },
  "accessibility": {
    "role": "button",
    "aria": "A native button. aria-busy is true while isLoading is set; the decorative sparkle layers are aria-hidden.",
    "reducedMotion": "With prefers-reduced-motion, the per-character blur-in, the loading pulse and the width spring are skipped.",
    "keyboard": [
      {
        "key": "Tab",
        "description": "Focus the button."
      },
      {
        "key": "Enter / Space",
        "description": "Activate. Uncontrolled: toggles the loading state. Controlled: calls onLoadingChange."
      }
    ]
  },
  "guidelines": {
    "recommended": [
      "Primary AI prompt submission and content generation triggers",
      "Creative workflow controls in image, video, or code generation interfaces",
      "Minimalist, monochromatic hero call-to-actions needing tactile, magical feedback"
    ],
    "bestPractices": [
      "Pair with real backend generation callbacks via loading and onLoadingChange props",
      "Keep the monochromatic palette intact for clean developer and dark-mode aesthetics",
      "Keep text and activeText short so the width spring stays tight"
    ]
  },
  "props": [
    {
      "name": "text",
      "type": "string",
      "defaultValue": "\"Generate Magic\"",
      "description": "Idle label rendered with BlurText staggered reveal animation."
    },
    {
      "name": "variant",
      "type": "\"default\" | \"outline\" | \"glass\"",
      "defaultValue": "\"default\"",
      "description": "Surface style of the button."
    },
    {
      "name": "activeText",
      "type": "string",
      "defaultValue": "\"Generating...\"",
      "description": "Active label displayed when generating with smooth fade & blur reveal."
    },
    {
      "name": "animateBy",
      "type": "\"letters\" | \"words\"",
      "defaultValue": "\"letters\"",
      "description": "Whether blur reveal staggers by individual letters or full words."
    },
    {
      "name": "direction",
      "type": "\"top\" | \"bottom\"",
      "defaultValue": "\"top\"",
      "description": "Direction from which blurred glyphs drift into place."
    },
    {
      "name": "delay",
      "type": "number",
      "defaultValue": "40",
      "description": "Stagger delay between sequential elements in milliseconds."
    },
    {
      "name": "stepDuration",
      "type": "number",
      "defaultValue": "0.35",
      "description": "Reveal transition duration for each letter or word in seconds."
    },
    {
      "name": "dissolveDuration",
      "type": "number",
      "defaultValue": "0.2",
      "description": "Fade and blur dissolve duration when exiting previous text state."
    },
    {
      "name": "springStiffness",
      "type": "number",
      "defaultValue": "350",
      "description": "Stiffness parameter for the button's layout resizing spring animation."
    },
    {
      "name": "springDamping",
      "type": "number",
      "defaultValue": "28",
      "description": "Damping parameter for the button's layout resizing spring animation."
    },
    {
      "name": "loading",
      "type": "boolean",
      "defaultValue": "undefined",
      "description": "Controlled generation state. When undefined, toggles automatically on click."
    },
    {
      "name": "size",
      "type": "\"sm\" | \"default\" | \"lg\"",
      "defaultValue": "\"default\"",
      "description": "Button dimensions, font sizes, and icon scaling."
    },
    {
      "name": "icon",
      "type": "ReactNode",
      "description": "Custom icon. Replaces the default sparkle icon."
    },
    {
      "name": "onLoadingChange",
      "type": "(loading: boolean) => void",
      "defaultValue": "undefined",
      "description": "Callback triggered when generation state changes."
    },
    {
      "name": "className",
      "type": "string",
      "defaultValue": "undefined",
      "description": "Additional CSS classes to override dimensions or margins."
    }
  ],
  "usage": "\"use client\";\n\nimport { useState } from \"react\";\nimport { SparkleButton } from \"@/components/ui/sparkle-button\";\n\nexport function SparkleButtonDemo() {\n  const [loading, setLoading] = useState(false);\n\n  return (\n    <SparkleButton\n      text=\"Generate\"\n      activeText=\"Generating...\"\n      loading={loading}\n      onLoadingChange={setLoading}\n    />\n  );\n}",
  "files": [
    {
      "name": "sparkle-button.tsx",
      "path": "registry/ui/sparkle-button.tsx",
      "code": "\"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",
      "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"
    }
  ],
  "type": "registry:ui",
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "title": "Sparkle Button"
}
