github-stars-animation

PreviousNext

A GitHubStarsAnimation component for SmoothUI.

Docs
smoothuiui

Preview

Loading preview…
index.tsx
"use client";

import { cn } from "@repo/shadcn-ui/lib/utils";
import { Star } from "lucide-react";
import { motion, useSpring } from "motion/react";
import { useEffect, useState } from "react";

const TRANSITION_DURATION = 0.3;
const EASE_OUT_CUBIC = [0.215, 0.61, 0.355, 1] as const;
const COUNTDOWN_DURATION = 2000;
const AVATAR_COUNT = 5;
const STAGGER_DELAY = 0.05;

export type Stargazer = {
  login: string;
  avatar_url: string;
  html_url: string;
};

export type GitHubStarsAnimationProps = {
  owner?: string;
  repo?: string;
  stargazers?: Stargazer[];
  starCount?: number;
  apiEndpoint?: string;
  className?: string;
  avatarClassName?: string;
  countClassName?: string;
  showAvatars?: boolean;
  maxAvatars?: number;
};

export default function GitHubStarsAnimation({
  owner = "educlopez",
  repo = "smoothui",
  stargazers: providedStargazers,
  starCount: providedStarCount,
  apiEndpoint,
  className = "",
  avatarClassName = "",
  countClassName = "",
  showAvatars = true,
  maxAvatars = AVATAR_COUNT,
}: GitHubStarsAnimationProps) {
  const [stargazers, setStargazers] = useState<Stargazer[]>(
    providedStargazers || []
  );
  const [starCount, setStarCount] = useState(providedStarCount || 0);
  const [displayCount, setDisplayCount] = useState(0);
  const [isLoading, setIsLoading] = useState(!providedStargazers);
  const [error, setError] = useState(false);

  const countSpring = useSpring(0, {
    stiffness: 100,
    damping: 30,
  });

  // Fetch stargazers and star count
  useEffect(() => {
    if (providedStargazers && providedStarCount !== undefined) {
      setStargazers(providedStargazers);
      setStarCount(providedStarCount);
      setIsLoading(false);
      return;
    }

    const fetchData = async () => {
      try {
        setIsLoading(true);
        setError(false);

        // Try to fetch from custom API endpoint first
        if (apiEndpoint) {
          const response = await fetch(
            `${apiEndpoint}?owner=${owner}&repo=${repo}`
          );
          if (response.ok) {
            const data = await response.json();
            if (data.stargazers) {
              setStargazers(data.stargazers.slice(0, maxAvatars));
            }
            if (data.stars !== undefined) {
              setStarCount(data.stars);
            }
            setIsLoading(false);
            return;
          }
        }

        // Fallback to GitHub API directly (client-side)
        // Note: This has rate limits, so using a token is recommended
        const headers: HeadersInit = {
          Accept: "application/vnd.github.v3+json",
        };

        // Fetch repo info for star count
        try {
          const repoResponse = await fetch(
            `https://api.github.com/repos/${owner}/${repo}`,
            { headers }
          );

          if (repoResponse.ok) {
            const repoData = await repoResponse.json();
            setStarCount(repoData.stargazers_count || 0);
          }
        } catch {
          // Silently fail for star count
        }

        // Fetch stargazers
        try {
          const stargazersResponse = await fetch(
            `https://api.github.com/repos/${owner}/${repo}/stargazers?per_page=${maxAvatars}`,
            { headers }
          );

          if (stargazersResponse.ok) {
            const stargazersData =
              (await stargazersResponse.json()) as Stargazer[];
            setStargazers(stargazersData.slice(0, maxAvatars));
          }
        } catch {
          // Silently fail for stargazers
        }
      } catch {
        setError(true);
      } finally {
        setIsLoading(false);
      }
    };

    fetchData();
  }, [
    owner,
    repo,
    apiEndpoint,
    maxAvatars,
    providedStargazers,
    providedStarCount,
  ]);

  // Animate countdown
  useEffect(() => {
    if (starCount === 0) {
      return;
    }

    const startTime = Date.now();
    const startValue = 0;
    const endValue = starCount;

    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / COUNTDOWN_DURATION, 1);

      // Ease-out function
      const eased = 1 - (1 - progress) ** 3;
      const current = Math.floor(startValue + (endValue - startValue) * eased);

      setDisplayCount(current);
      countSpring.set(current);

      if (progress < 1) {
        requestAnimationFrame(animate);
      } else {
        setDisplayCount(endValue);
        countSpring.set(endValue);
      }
    };

    animate();
  }, [starCount, countSpring]);

  if (isLoading) {
    return (
      <div
        className={cn("flex items-center gap-3 text-foreground/60", className)}
      >
        <div className="h-10 w-10 animate-pulse rounded-full bg-foreground/20" />
        <div className="h-6 w-20 animate-pulse rounded bg-foreground/20" />
      </div>
    );
  }

  if (error && starCount === 0) {
    return null;
  }

  const visibleAvatars = stargazers.slice(0, maxAvatars);

  return (
    <div className={cn("flex items-center gap-3", className)}>
      {/* Avatars */}
      {showAvatars && visibleAvatars.length > 0 && (
        <div className="relative flex items-center">
          {visibleAvatars.map((stargazer, index) => (
            <motion.a
              animate={{
                opacity: 1,
                scale: 1,
                x: 0,
              }}
              aria-label={`${stargazer.login}'s GitHub profile`}
              className={cn(
                "relative z-10 h-10 w-10 overflow-hidden rounded-full border-2 border-background bg-background transition-transform hover:z-20 hover:scale-110",
                avatarClassName
              )}
              href={stargazer.html_url}
              initial={{
                opacity: 0,
                scale: 0.8,
                x: -20,
              }}
              key={stargazer.login}
              rel="noopener noreferrer"
              style={{
                marginLeft: index > 0 ? "-8px" : "0",
              }}
              target="_blank"
              transition={{
                duration: TRANSITION_DURATION,
                delay: index * STAGGER_DELAY,
                ease: EASE_OUT_CUBIC,
              }}
              whileHover={{ scale: 1.1, zIndex: 20 }}
            >
              {/* biome-ignore lint/performance/noImgElement: Using img for user avatars without Next.js Image optimizations */}
              <img
                alt={`${stargazer.login}'s avatar`}
                className="h-full w-full object-cover"
                src={stargazer.avatar_url}
              />
            </motion.a>
          ))}
        </div>
      )}

      {/* Star count */}
      <motion.div
        animate={{
          opacity: 1,
          scale: 1,
        }}
        className={cn("flex items-center gap-1.5 font-medium", countClassName)}
        initial={{
          opacity: 0,
          scale: 0.9,
        }}
        transition={{
          duration: TRANSITION_DURATION,
          ease: EASE_OUT_CUBIC,
        }}
      >
        <Star className="h-4 w-4 fill-current" />
        <motion.span
          animate={{
            scale: [1, 1.1, 1],
          }}
          transition={{
            duration: 0.3,
            ease: EASE_OUT_CUBIC,
          }}
          className="tabular-nums"
        >
          {displayCount.toLocaleString()}
        </motion.span>
        <span className="text-foreground/70 text-sm">
          {displayCount === 1 ? "star" : "stars"}
        </span>
      </motion.div>

      {/* Reduced motion fallback */}
      <style>
        {`
          @media (prefers-reduced-motion: reduce) {
            * {
              animation: none !important;
              transition: opacity 0.2s ease !important;
            }
          }
        `}
      </style>
    </div>
  );
}













Installation

npx shadcn@latest add @smoothui/github-stars-animation

Usage

import { GithubStarsAnimation } from "@/components/ui/github-stars-animation"
<GithubStarsAnimation />