feat: enhance github stars button to be better looking and more compact (#7464)

* feat: enhance github stars button to be better looking and more compact to make mobile compatibility easier in the future

* feat: introduce a new Button component
This commit is contained in:
Bram Suurd
2025-09-08 08:42:31 +02:00
committed by GitHub
parent c5d23dc883
commit 8ea4829e8a
13 changed files with 1168 additions and 25 deletions
@@ -0,0 +1,206 @@
"use client";
import type { HTMLMotionProps } from "motion/react";
import { motion } from "motion/react";
import * as React from "react";
import type { SlidingNumberProps } from "@/components/animate-ui/primitives/texts/sliding-number";
import type { ParticlesEffectProps } from "@/components/animate-ui/primitives/effects/particles";
import type { WithAsChild } from "@/components/animate-ui/primitives/animate/slot";
import type { UseIsInViewOptions } from "@/hooks/use-is-in-view";
import { Particles, ParticlesEffect } from "@/components/animate-ui/primitives/effects/particles";
import { SlidingNumber } from "@/components/animate-ui/primitives/texts/sliding-number";
import { Slot } from "@/components/animate-ui/primitives/animate/slot";
import { getStrictContext } from "@/lib/get-strict-context";
import { useIsInView } from "@/hooks/use-is-in-view";
import { cn } from "@/lib/utils";
type GithubStarsContextType = {
stars: number;
setStars: (stars: number) => void;
currentStars: number;
setCurrentStars: (stars: number) => void;
isCompleted: boolean;
isLoading: boolean;
};
const [GithubStarsProvider, useGithubStars] = getStrictContext<GithubStarsContextType>("GithubStarsContext");
type GithubStarsProps = WithAsChild<
{
children: React.ReactNode;
username?: string;
repo?: string;
value?: number;
delay?: number;
} & UseIsInViewOptions
& HTMLMotionProps<"div">
>;
function GithubStars({
ref,
children,
username,
repo,
value,
delay = 0,
inView = false,
inViewMargin = "0px",
inViewOnce = true,
asChild = false,
...props
}: GithubStarsProps) {
const { ref: localRef, isInView } = useIsInView(ref as React.Ref<HTMLDivElement>, {
inView,
inViewOnce,
inViewMargin,
});
const [stars, setStars] = React.useState(value ?? 0);
const [currentStars, setCurrentStars] = React.useState(0);
const [isLoading, setIsLoading] = React.useState(true);
const isCompleted = React.useMemo(() => currentStars === stars, [currentStars, stars]);
const Component = asChild ? Slot : motion.div;
React.useEffect(() => {
if (value !== undefined && username && repo)
return;
if (!isInView) {
setStars(0);
setIsLoading(true);
return;
}
const timeout = setTimeout(() => {
fetch(`https://api.github.com/repos/${username}/${repo}`)
.then(response => response.json())
.then((data) => {
if (data && typeof data.stargazers_count === "number") {
setStars(data.stargazers_count);
}
})
.catch(console.error)
.finally(() => setIsLoading(false));
}, delay);
return () => clearTimeout(timeout);
}, [username, repo, value, isInView, delay]);
return (
<GithubStarsProvider
value={{
stars,
currentStars,
isCompleted,
isLoading,
setStars,
setCurrentStars,
}}
>
{!isLoading && (
<Component ref={localRef} {...props}>
{children}
</Component>
)}
</GithubStarsProvider>
);
}
type GithubStarsNumberProps = Omit<SlidingNumberProps, "number" | "fromNumber">;
function GithubStarsNumber({ padStart = true, ...props }: GithubStarsNumberProps) {
const { stars, setCurrentStars } = useGithubStars();
return (
<SlidingNumber number={stars} fromNumber={0} onNumberChange={setCurrentStars} padStart={padStart} {...props} />
);
}
type GithubStarsIconProps<T extends React.ElementType> = {
icon: React.ReactElement<T>;
color?: string;
activeClassName?: string;
} & React.ComponentProps<T>;
function GithubStarsIcon<T extends React.ElementType>({
icon: Icon,
color = "currentColor",
activeClassName,
className,
...props
}: GithubStarsIconProps<T>) {
const { stars, currentStars, isCompleted } = useGithubStars();
const fillPercentage = (currentStars / stars) * 100;
return (
<div style={{ position: "relative" }}>
<Icon aria-hidden="true" className={cn(className)} {...props} />
<Icon
aria-hidden="true"
style={{
position: "absolute",
top: 0,
left: 0,
fill: color,
stroke: color,
clipPath: `inset(${100 - (isCompleted ? fillPercentage : fillPercentage - 10)}% 0 0 0)`,
}}
className={cn(className, activeClassName)}
{...props}
/>
</div>
);
}
type GithubStarsParticlesProps = ParticlesEffectProps & {
children: React.ReactElement;
size?: number;
};
function GithubStarsParticles({ children, size = 4, style, ...props }: GithubStarsParticlesProps) {
const { isCompleted } = useGithubStars();
return (
<Particles animate={isCompleted}>
{children}
<ParticlesEffect
style={{
backgroundColor: "currentcolor",
borderRadius: "50%",
width: size,
height: size,
...style,
}}
{...props}
/>
</Particles>
);
}
type GithubStarsLogoProps = React.SVGProps<SVGSVGElement>;
function GithubStarsLogo(props: GithubStarsLogoProps) {
return (
<svg role="img" viewBox="0 0 24 24" fill="currentColor" aria-label="GitHub" {...props}>
<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"></path>
</svg>
);
}
export {
GithubStars,
type GithubStarsContextType,
GithubStarsIcon,
type GithubStarsIconProps,
GithubStarsLogo,
type GithubStarsLogoProps,
GithubStarsNumber,
type GithubStarsNumberProps,
GithubStarsParticles,
type GithubStarsParticlesProps,
type GithubStarsProps,
useGithubStars,
};
@@ -0,0 +1,101 @@
"use client";
import type { HTMLMotionProps } from "motion/react";
import { isMotionComponent, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
"ref"
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object>
= | (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
children?: any;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref)
return;
if (typeof ref === "function") {
ref(node);
}
else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion
= typeof children.type === "object"
&& children.type !== null
&& isMotionComponent(children.type);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
if (!React.isValidElement(children))
return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
type AnyProps,
type DOMMotionProps,
Slot,
type SlotProps,
type WithAsChild,
};