Components

GitHub Star Button

A premium GitHub star button with real-time data, rolling number animations, and interactive particle effects.

Last updated on

Edit on GitHub

Basic Usage

The GitHub Star Button provides a sleek way to showcase your repository's popularity. It fetches real-time data from the GitHub API and features smooth animations.

import { GitHubStarButton } from "@/components/ui/github-star";
 
export function GitHubStarDemo() {
  return (
    <div className="flex flex-col items-center justify-center gap-8 p-8">
      <div className="flex flex-wrap items-center justify-center gap-6">
        <GitHubStarButton owner="johuniq" repo="jolyui" stars={120} />
        <GitHubStarButton owner="facebook" repo="react" />
        <GitHubStarButton owner="vercel" repo="next.js" />
      </div>
 
      <div className="space-y-2 text-center">
        <p className="text-muted-foreground text-sm">
          Real-time GitHub stars with premium rolling animations and particle
          effects.
        </p>
      </div>
    </div>
  );
}

Features

  • Real-time Data: Fetches stargazers count directly from GitHub API.
  • Rolling Number Animation: Premium vertical rolling animation for digits.
  • Particle Effects: Interactive star particles on click.
  • Interactive Hover: Smooth scaling, rotation, and shimmer effects.
  • Responsive: Works great on all screen sizes.
  • Theme Aware: Automatically adjusts for light and dark modes.

Installation

CLI

npx shadcn@latest add "https://jolyui.dev/r/github-star"

Manual

Install the following dependencies:

npm install motion

Copy and paste the following code into your project. component/ui/github-star.tsx

"use client";
 
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
 
export interface GitHubStarButtonProps {
  /**
   * The owner of the GitHub repository
   * @example "johuniq"
   */
  owner: string;
  /**
   * The name of the GitHub repository
   * @example "jolyui"
   */
  repo: string;
  /**
   * Manual star count override. If provided, the component will not fetch from GitHub API.
   */
  stars?: number;
  /**
   * Additional CSS classes for the button
   */
  className?: string;
}
 
interface Particle {
  id: number;
  x: number;
  y: number;
  angle: number;
  scale: number;
}
 
const StarIcon = ({
  className,
  filled,
}: {
  className?: string;
  filled?: boolean;
}) => (
  <svg
    viewBox="0 0 16 16"
    className={className}
    fill={filled ? "currentColor" : "none"}
    stroke="currentColor"
    strokeWidth={filled ? 0 : 1.5}
    strokeLinecap="round"
    strokeLinejoin="round"
  >
    <path d="M8 1.5l1.85 4.1 4.65.55-3.5 3.15.95 4.6L8 11.7l-4 2.2.95-4.6-3.5-3.15 4.65-.55L8 1.5z" />
  </svg>
);
 
const GitHubIcon = ({ className }: { className?: string }) => (
  <svg viewBox="0 0 16 16" className={className} fill="currentColor">
    <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
  </svg>
);
 
function formatNumber(num: number): string {
  if (num >= 1000000) {
    return `${(num / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
  }
  if (num >= 1000) {
    return `${(num / 1000).toFixed(1).replace(/\.0$/, "")}k`;
  }
  return num.toLocaleString();
}
 
function useGitHubStars(owner: string, repo: string, manualStars?: number) {
  const [stars, setStars] = React.useState<number>(manualStars ?? 0);
  const [loading, setLoading] = React.useState(!manualStars);
 
  React.useEffect(() => {
    if (manualStars !== undefined) {
      setStars(manualStars);
      setLoading(false);
      return;
    }
 
    fetch(`https://api.github.com/repos/${owner}/${repo}`)
      .then((response) => response.json())
      .then((data) => {
        if (data && typeof data.stargazers_count === "number") {
          setStars(data.stargazers_count);
        }
      })
      .catch(console.error)
      .finally(() => setLoading(false));
  }, [owner, repo, manualStars]);
 
  return { stars, loading };
}
 
const AnimatedDigit = ({ digit }: { digit: string }) => {
  const isNumber = /\d/.test(digit);
 
  if (!isNumber) {
    return <span className="inline-block px-0.5">{digit}</span>;
  }
 
  const num = parseInt(digit, 10);
 
  return (
    <span className="relative inline-block h-[1em] w-[0.6em] overflow-hidden">
      <motion.span
        className="absolute top-0 left-0 flex w-full flex-col items-center"
        initial={{ y: 0 }}
        animate={{ y: `${-num * 10}%` }}
        transition={{
          type: "spring",
          stiffness: 100,
          damping: 15,
          mass: 1,
        }}
      >
        {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
          <span
            key={n}
            className="flex h-[1em] items-center justify-center leading-none"
          >
            {n}
          </span>
        ))}
      </motion.span>
    </span>
  );
};
 
function RollingNumber({ value }: { value: number }) {
  const formatted = formatNumber(value);
  const digits = formatted.split("");
 
  return (
    <div className="flex items-center">
      {digits.map((digit, i) => (
        <AnimatedDigit key={`${i}-${digit}`} digit={digit} />
      ))}
    </div>
  );
}
 
function StarParticles({ particles }: { particles: Particle[] }) {
  return (
    <AnimatePresence>
      {particles.map((particle) => (
        <motion.div
          key={particle.id}
          className="pointer-events-none absolute"
          initial={{
            opacity: 1,
            scale: particle.scale,
            x: 0,
            y: 0,
          }}
          animate={{
            opacity: 0,
            scale: 0,
            x: Math.cos(particle.angle) * 60,
            y: Math.sin(particle.angle) * 60,
          }}
          exit={{ opacity: 0 }}
          transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
          style={{
            left: particle.x,
            top: particle.y,
          }}
        >
          <StarIcon className="h-3 w-3 text-star" filled />
        </motion.div>
      ))}
    </AnimatePresence>
  );
}
 
export function GitHubStarButton({
  owner,
  repo,
  stars: manualStars,
  className,
}: GitHubStarButtonProps) {
  const { stars, loading } = useGitHubStars(owner, repo, manualStars);
  const [localStars, setLocalStars] = React.useState(0);
  const [isHovered, setIsHovered] = React.useState(false);
  const [isStarred, setIsStarred] = React.useState(false);
  const [particles, setParticles] = React.useState<Particle[]>([]);
  const buttonRef = React.useRef<HTMLAnchorElement>(null);
 
  React.useEffect(() => {
    if (stars > 0) {
      setLocalStars(stars);
    }
  }, [stars]);
 
  const handleClick = (e: React.MouseEvent) => {
    if (!isStarred) {
      e.preventDefault();
      setIsStarred(true);
      setLocalStars((prev) => prev + 1);
 
      const centerX = 20;
      const centerY = 20;
 
      const newParticles: Particle[] = Array.from({ length: 12 }, (_, i) => ({
        id: Date.now() + i,
        x: centerX,
        y: centerY,
        angle: (Math.PI * 2 * i) / 12 + Math.random() * 0.3,
        scale: 0.4 + Math.random() * 0.6,
      }));
 
      setParticles(newParticles);
      setTimeout(() => setParticles([]), 800);
 
      // After simulation, navigate to the repo
      setTimeout(() => {
        window.open(`https://github.com/${owner}/${repo}`, "_blank");
      }, 600);
    }
  };
 
  const repoUrl = `https://github.com/${owner}/${repo}`;
 
  if (loading && localStars === 0) {
    return (
      <div
        className={cn(
          "relative inline-flex animate-pulse items-center gap-3 rounded-xl border border-border bg-card px-4 py-2.5",
          className,
        )}
      >
        <div className="h-5 w-5 rounded-full bg-muted" />
        <div className="h-5 w-px bg-border" />
        <div className="h-5 w-20 rounded bg-muted" />
      </div>
    );
  }
 
  return (
    <motion.a
      ref={buttonRef}
      href={repoUrl}
      target="_blank"
      rel="noopener noreferrer"
      className={cn(
        "relative inline-flex items-center gap-3 rounded-xl px-4 py-2.5",
        "border border-border bg-card",
        "shadow-sm transition-all duration-300 hover:shadow-lg",
        "group cursor-pointer no-underline",
        className,
      )}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onClick={handleClick}
      whileHover={{ scale: 1.03, y: -2 }}
      whileTap={{ scale: 0.97 }}
      transition={{ type: "spring", stiffness: 400, damping: 17 }}
    >
      {/* GitHub Icon */}
      <GitHubIcon className="h-5 w-5 text-foreground transition-colors" />
 
      {/* Divider */}
      <div className="h-5 w-px bg-border" />
 
      {/* Star Section */}
      <div className="relative flex items-center gap-2">
        <StarParticles particles={particles} />
 
        <motion.div
          className="relative"
          animate={{
            rotate: isHovered || isStarred ? [0, -15, 15, -10, 10, 0] : 0,
            scale: isHovered || isStarred ? 1.2 : 1,
          }}
          transition={{
            rotate: { duration: 0.5, ease: "easeInOut" },
            scale: { type: "spring", stiffness: 300, damping: 15 },
          }}
        >
          <StarIcon
            className={cn(
              "h-5 w-5 transition-colors duration-300",
              isHovered || isStarred ? "text-star" : "text-muted-foreground",
            )}
            filled={isHovered || isStarred}
          />
 
          {/* Glow effect */}
          <motion.div
            className="absolute inset-0 blur-lg"
            initial={{ opacity: 0 }}
            animate={{ opacity: isHovered || isStarred ? 0.8 : 0 }}
            transition={{ duration: 0.3 }}
          >
            <StarIcon className="h-5 w-5 text-star-glow" filled />
          </motion.div>
        </motion.div>
 
        {/* Divider */}
 
        {/* Count */}
        <div className="min-w-[3rem] font-mono font-semibold text-foreground text-sm tabular-nums">
          <RollingNumber value={localStars} />
        </div>
      </div>
 
      {/* Hover shimmer effect */}
      <motion.div
        className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl"
        initial={{ opacity: 0 }}
        animate={{ opacity: isHovered ? 1 : 0 }}
        transition={{ duration: 0.3 }}
      >
        <motion.div
          className="absolute inset-0 bg-gradient-to-r from-transparent via-star/10 to-transparent"
          animate={{
            x: isHovered ? ["100%", "-100%"] : "100%",
          }}
          transition={{
            duration: 1.2,
            repeat: isHovered ? Infinity : 0,
            repeatDelay: 0.8,
            ease: "easeInOut",
          }}
        />
      </motion.div>
    </motion.a>
  );
}
 
export GitHubStarButton;

API Reference

Prop

Type

Customization

You can easily customize the button by passing a className prop to adjust its size, colors, or spacing. The component uses Tailwind CSS for all its styling.

Colors

The component uses a custom star and star-glow color. You can add these to your globals.css or Tailwind theme:

@theme {
  --color-star: #eab308;
  --color-star-glow: #facc15;
}

How is this guide?

On this page