{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "github-stars-button",
  "type": "registry:ui",
  "title": "GitHub Stars Button",
  "description": "A clickable button that links to a GitHub repository and displays the number of stars.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://animate-ui.com/r/sliding-number"
  ],
  "files": [
    {
      "path": "registry/buttons/github-stars/index.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { Star } from 'lucide-react';\nimport {\n  motion,\n  AnimatePresence,\n  useMotionValue,\n  useSpring,\n  useInView,\n  type HTMLMotionProps,\n  type SpringOptions,\n  type UseInViewOptions,\n} from 'motion/react';\n\nimport { cn } from '@/lib/utils';\nimport { SlidingNumber } from '@/components/animate-ui/text/sliding-number';\n\ntype FormatNumberResult = { number: string[]; unit: string };\n\nfunction formatNumber(num: number, formatted: boolean): FormatNumberResult {\n  if (formatted) {\n    if (num < 1000) {\n      return { number: [num.toString()], unit: '' };\n    }\n    const units = ['k', 'M', 'B', 'T'];\n    let unitIndex = 0;\n    let n = num;\n    while (n >= 1000 && unitIndex < units.length) {\n      n /= 1000;\n      unitIndex++;\n    }\n    const finalNumber = Math.floor(n).toString();\n    return { number: [finalNumber], unit: units[unitIndex - 1] ?? '' };\n  } else {\n    return { number: num.toLocaleString('en-US').split(','), unit: '' };\n  }\n}\n\ntype GitHubStarsButtonProps = HTMLMotionProps<'a'> & {\n  username: string;\n  repo: string;\n  transition?: SpringOptions;\n  formatted?: boolean;\n  inView?: boolean;\n  inViewMargin?: UseInViewOptions['margin'];\n  inViewOnce?: boolean;\n};\n\nfunction GitHubStarsButton({\n  ref,\n  username,\n  repo,\n  transition = { stiffness: 90, damping: 50 },\n  formatted = false,\n  inView = false,\n  inViewOnce = true,\n  inViewMargin = '0px',\n  className,\n  ...props\n}: GitHubStarsButtonProps) {\n  const motionVal = useMotionValue(0);\n  const springVal = useSpring(motionVal, transition);\n  const motionNumberRef = React.useRef(0);\n  const isCompletedRef = React.useRef(false);\n  const [, forceRender] = React.useReducer((x) => x + 1, 0);\n  const [stars, setStars] = React.useState(0);\n  const [isCompleted, setIsCompleted] = React.useState(false);\n  const [displayParticles, setDisplayParticles] = React.useState(false);\n  const [isLoading, setIsLoading] = React.useState(true);\n\n  const repoUrl = React.useMemo(\n    () => `https://github.com/${username}/${repo}`,\n    [username, repo],\n  );\n\n  React.useEffect(() => {\n    fetch(`https://api.github.com/repos/${username}/${repo}`)\n      .then((response) => response.json())\n      .then((data) => {\n        if (data && typeof data.stargazers_count === 'number') {\n          setStars(data.stargazers_count);\n        }\n      })\n      .catch(console.error)\n      .finally(() => setIsLoading(false));\n  }, [username, repo]);\n\n  const handleDisplayParticles = React.useCallback(() => {\n    setDisplayParticles(true);\n    setTimeout(() => setDisplayParticles(false), 1500);\n  }, []);\n\n  const localRef = React.useRef<HTMLAnchorElement>(null);\n  React.useImperativeHandle(ref, () => localRef.current as HTMLAnchorElement);\n\n  const inViewResult = useInView(localRef, {\n    once: inViewOnce,\n    margin: inViewMargin,\n  });\n  const isComponentInView = !inView || inViewResult;\n\n  React.useEffect(() => {\n    const unsubscribe = springVal.on('change', (latest: number) => {\n      const newValue = Math.round(latest);\n      if (motionNumberRef.current !== newValue) {\n        motionNumberRef.current = newValue;\n        forceRender();\n      }\n      if (stars !== 0 && newValue >= stars && !isCompletedRef.current) {\n        isCompletedRef.current = true;\n        setIsCompleted(true);\n        handleDisplayParticles();\n      }\n    });\n    return () => unsubscribe();\n  }, [springVal, stars, handleDisplayParticles]);\n\n  React.useEffect(() => {\n    if (stars > 0 && isComponentInView) motionVal.set(stars);\n  }, [motionVal, stars, isComponentInView]);\n\n  const fillPercentage = Math.min(100, (motionNumberRef.current / stars) * 100);\n  const formattedResult = formatNumber(motionNumberRef.current, formatted);\n  const ghostFormattedNumber = formatNumber(stars, formatted);\n\n  const renderNumberSegments = (\n    segments: string[],\n    unit: string,\n    isGhost: boolean,\n  ) => (\n    <span\n      className={cn(\n        'flex items-center gap-px',\n        isGhost ? 'invisible' : 'absolute top-0 left-0',\n      )}\n    >\n      {segments.map((segment, index) => (\n        <React.Fragment key={index}>\n          {Array.from(segment).map((digit, digitIndex) => (\n            <SlidingNumber key={`${index}-${digitIndex}`} number={+digit} />\n          ))}\n        </React.Fragment>\n      ))}\n\n      {formatted && unit && <span className=\"leading-[1]\">{unit}</span>}\n    </span>\n  );\n\n  const handleClick = React.useCallback(\n    (e: React.MouseEvent<HTMLAnchorElement>) => {\n      e.preventDefault();\n      handleDisplayParticles();\n      setTimeout(() => window.open(repoUrl, '_blank'), 500);\n    },\n    [handleDisplayParticles, repoUrl],\n  );\n\n  if (isLoading) return null;\n\n  return (\n    <motion.a\n      ref={localRef}\n      href={repoUrl}\n      rel=\"noopener noreferrer\"\n      target=\"_blank\"\n      whileTap={{ scale: 0.95 }}\n      whileHover={{ scale: 1.05 }}\n      onClick={handleClick}\n      className={cn(\n        \"flex items-center gap-2 text-sm bg-primary text-primary-foreground rounded-lg px-4 py-2 h-10 has-[>svg]:px-3 cursor-pointer whitespace-nowrap font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-[18px] shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n        className,\n      )}\n      {...props}\n    >\n      <svg role=\"img\" viewBox=\"0 0 24 24\" fill=\"currentColor\">\n        <path d=\"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12\" />\n      </svg>\n      <span>GitHub Stars</span>\n      <div className=\"relative inline-flex size-[18px] shrink-0\">\n        <Star\n          className=\"fill-muted-foreground text-muted-foreground\"\n          size={18}\n          aria-hidden=\"true\"\n        />\n        <Star\n          className=\"absolute top-0 left-0 text-yellow-500 fill-yellow-500\"\n          aria-hidden=\"true\"\n          style={{\n            clipPath: `inset(${100 - (isCompleted ? fillPercentage : fillPercentage - 10)}% 0 0 0)`,\n          }}\n        />\n        <AnimatePresence>\n          {displayParticles && (\n            <>\n              <motion.div\n                className=\"absolute inset-0 rounded-full\"\n                style={{\n                  background:\n                    'radial-gradient(circle, rgba(255,215,0,0.4) 0%, rgba(255,215,0,0) 70%)',\n                }}\n                initial={{ scale: 1.2, opacity: 0 }}\n                animate={{ scale: [1.2, 1.8, 1.2], opacity: [0, 0.3, 0] }}\n                transition={{ duration: 1.2, ease: 'easeInOut' }}\n              />\n              <motion.div\n                className=\"absolute inset-0 rounded-full\"\n                style={{ boxShadow: '0 0 10px 2px rgba(255,215,0,0.6)' }}\n                initial={{ scale: 1, opacity: 0 }}\n                animate={{ scale: [1, 1.5], opacity: [0.8, 0] }}\n                transition={{ duration: 0.8, ease: 'easeOut' }}\n              />\n              {[...Array(6)].map((_, i) => (\n                <motion.div\n                  key={i}\n                  className=\"absolute w-1 h-1 rounded-full bg-yellow-500\"\n                  initial={{ x: '50%', y: '50%', scale: 0, opacity: 0 }}\n                  animate={{\n                    x: `calc(50% + ${Math.cos((i * Math.PI) / 3) * 30}px)`,\n                    y: `calc(50% + ${Math.sin((i * Math.PI) / 3) * 30}px)`,\n                    scale: [0, 1, 0],\n                    opacity: [0, 1, 0],\n                  }}\n                  transition={{\n                    duration: 0.8,\n                    delay: i * 0.05,\n                    ease: 'easeOut',\n                  }}\n                />\n              ))}\n            </>\n          )}\n        </AnimatePresence>\n      </div>\n      <span className=\"relative inline-flex\">\n        {renderNumberSegments(\n          ghostFormattedNumber.number,\n          ghostFormattedNumber.unit,\n          true,\n        )}\n        {renderNumberSegments(\n          formattedResult.number,\n          formattedResult.unit,\n          false,\n        )}\n      </span>\n    </motion.a>\n  );\n}\n\nexport { GitHubStarsButton, type GitHubStarsButtonProps };\n",
      "type": "registry:ui",
      "target": "components/animate-ui/buttons/github-stars.tsx"
    }
  ]
}