{ "$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(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 \n {segments.map((segment, index) => (\n \n {Array.from(segment).map((digit, digitIndex) => (\n \n ))}\n \n ))}\n\n {formatted && unit && {unit}}\n \n );\n\n const handleClick = React.useCallback(\n (e: React.MouseEvent) => {\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 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 \n \n \n GitHub Stars\n
\n \n \n \n {displayParticles && (\n <>\n \n \n {[...Array(6)].map((_, i) => (\n \n ))}\n \n )}\n \n
\n \n {renderNumberSegments(\n ghostFormattedNumber.number,\n ghostFormattedNumber.unit,\n true,\n )}\n {renderNumberSegments(\n formattedResult.number,\n formattedResult.unit,\n false,\n )}\n \n \n );\n}\n\nexport { GitHubStarsButton, type GitHubStarsButtonProps };\n", "type": "registry:ui", "target": "components/animate-ui/buttons/github-stars.tsx" } ] }