ComponentsCreative
GitHub Contributors
GitHub contributors avatar grid for React. Displays contributor avatars with tooltips showing stats. Perfect for open-source project landing pages.
Last updated on
import { GitHubContributors } from "../ui/github-contributors";
export function GitHubContributorsDemo() {
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3">
<h3 className="font-medium text-sm">GitHub Contributors</h3>
<GitHubContributors repo="vercel/next.js" limit={12} />
</div>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/github-contributors"Manual
Install the following dependencies:
npm install @radix-ui/react-tooltip lucide-react motionCopy and paste the following code into your project.
"use client";
import { ExternalLink } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface Contributor {
id: number;
login: string;
avatar_url: string;
html_url: string;
contributions: number;
}
interface GitHubContributorsProps {
repo: string; // e.g. "vercel/next.js"
limit?: number; // number of avatars to show (not counting the +more tile)
className?: string;
token?: string; // optional GitHub token to increase rate limit
}
export function GitHubContributors({
repo,
limit = 12,
className = "",
token,
}: GitHubContributorsProps) {
const [contributors, setContributors] = useState<Contributor[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCount, setTotalCount] = useState<number | null>(null);
useEffect(() => {
if (!repo) return;
setLoading(true);
setError(null);
setTotalCount(null);
const headers: Record<string, string> = {
Accept: "application/vnd.github.v3+json",
};
if (token) headers.Authorization = `token ${token}`;
const fetchList = async () => {
try {
const listRes = await fetch(
`https://api.github.com/repos/${repo}/contributors?per_page=${limit}`,
{ headers },
);
if (!listRes.ok)
throw new Error(
`GitHub API: ${listRes.status} ${listRes.statusText}`,
);
const listData: Contributor[] = await listRes.json();
setContributors(listData.slice(0, limit));
// Probe for total contributors (per_page=1 -> last page = total count)
try {
const probeRes = await fetch(
`https://api.github.com/repos/${repo}/contributors?per_page=1`,
{ headers },
);
if (probeRes.ok) {
const link = probeRes.headers.get("link");
if (link) {
const m = link.match(
/<[^>]+[?&]page=(\d+)[^>]*>\s*;\s*rel="last"/,
);
if (m?.[1]) {
const lastPage = parseInt(m[1], 10);
if (Number.isFinite(lastPage)) setTotalCount(lastPage);
}
} else {
const probeData = await probeRes.json();
if (Array.isArray(probeData)) setTotalCount(probeData.length);
}
}
} catch {
// ignore probe errors
}
} catch (err: unknown) {
setError((err as Error).message || "Failed to load contributors");
setContributors([]);
} finally {
setLoading(false);
}
};
fetchList();
}, [repo, limit, token]);
const shown = contributors.length;
const remaining =
totalCount !== null ? Math.max(0, totalCount - shown) : null;
// compute max contributions among shown contributors to render the progress bar
const maxContrib = useMemo(() => {
if (!contributors || contributors.length === 0) return 1;
return Math.max(...contributors.map((c) => c.contributions), 1);
}, [contributors]);
const repoUrl = `https://github.com/${repo}`;
const contributorsUrl = `${repoUrl}/graphs/contributors`;
return (
<Card
className={`overflow-hidden rounded-lg border bg-background shadow-sm ${className}`}
aria-live="polite"
>
{/* Content */}
<CardContent className="px-4 py-3">
{error && (
<p className="mb-2 text-destructive text-sm">Failed: {error}</p>
)}
{loading ? (
<div className="grid grid-cols-5 gap-3 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-12">
{Array.from({ length: limit }).map((_, i) => (
<div key={i} className="flex items-center justify-center">
<Skeleton className="h-10 w-10 rounded-full" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-5 items-center gap-3 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-11">
{contributors.map((c, idx) => {
const pct = Math.round((c.contributions / maxContrib) * 100);
const isTop = idx === 0; // highlight the top contributor in shown list
return (
<div
key={c.id}
className="relative flex items-center justify-center"
>
{/* top badge */}
{isTop && (
<div className="-top-1 -right-1 absolute z-10">
<div className="flex h-4 w-4 items-center justify-center rounded-full border border-white bg-yellow-400/90 font-semibold text-[10px] text-white shadow-sm">
<span>★</span>
</div>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<motion.a
href={c.html_url}
target="_blank"
rel="noopener noreferrer"
title={`${c.login} — ${c.contributions} contributions`}
className="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-full border bg-muted/10 transition hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
whileHover={isTop ? { scale: 1.07 } : { scale: 1.04 }}
whileFocus={{ scale: 1.04 }}
onClick={(e) => e.stopPropagation()}
aria-label={`${c.login} GitHub profile`}
>
{/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
<img
src={c.avatar_url}
alt={c.login}
width={40}
height={40}
className="h-full w-full object-cover"
/>
{/* subtle ring on hover via pseudo element class; already handled by tailwind tokens */}
</motion.a>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="w-64 bg-transparent p-0 shadow-none"
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.12 }}
className="rounded-lg border bg-popover p-3 text-popover-foreground shadow-md"
role="dialog"
aria-label={`${c.login} contributor details`}
>
<div className="flex items-center gap-3">
<div className="h-12 w-12 flex-shrink-0 overflow-hidden rounded-md border">
{/* biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx */}
<img
src={c.avatar_url}
alt={c.login}
width={48}
height={48}
className="object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="truncate font-medium text-foreground">
{c.login}
</div>
<div className="ml-auto font-mono text-muted-foreground text-xs">
#{c.id}
</div>
</div>
<div className="mt-1 text-muted-foreground text-xs">
{c.contributions.toLocaleString()} contributions
</div>
{/* mini contribution bar */}
<div className="mt-2">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary transition-all duration-300"
style={{ width: `${pct}%` }}
aria-hidden
/>
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{pct}% of top contributor
</div>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<a
href={c.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md bg-secondary px-2 py-1 font-medium text-secondary-foreground text-sm transition-colors hover:bg-secondary/80"
onClick={(e) => e.stopPropagation()}
aria-label={`Open ${c.login} on GitHub`}
>
<ExternalLink className="h-4 w-4" />
<span className="font-medium text-sm">
View profile
</span>
</a>
<div className="text-muted-foreground text-xs">
Contributions:{" "}
<span className="font-medium text-foreground">
{c.contributions}
</span>
</div>
</div>
</motion.div>
</TooltipContent>
</Tooltip>
</div>
);
})}
{/* +N tile (or generic + tile) */}
{remaining !== null && remaining > 0 ? (
<a
href={contributorsUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-10 w-10 items-center justify-center rounded-full border bg-muted/5 text-muted-foreground text-xs transition hover:bg-muted"
aria-label={`View all ${totalCount} contributors`}
>
+{remaining}
</a>
) : remaining === null &&
!loading &&
contributors.length === limit ? (
<a
href={contributorsUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-10 w-10 items-center justify-center rounded-full border bg-muted/5 text-muted-foreground text-xs transition hover:bg-muted"
aria-label={`View more contributors`}
>
+
</a>
) : null}
</div>
)}
</CardContent>
{/* Footer CTA */}
<div className="flex items-center justify-between gap-3 border-t bg-background/50 px-4 py-3">
<div className="min-w-0">
<CardTitle className="m-0 truncate font-semibold text-sm">
{repo}
</CardTitle>
<div className="text-muted-foreground text-xs">
{loading ? (
<span>Loading…</span>
) : error ? (
<span className="text-destructive">Failed to load</span>
) : (
<span>
Showing <span className="font-medium">{shown}</span>
{totalCount ? (
<>
{" "}
of <span className="font-medium">{totalCount}</span>
</>
) : shown === limit ? (
<> (more)</>
) : null}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<a
href={repoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md px-2 py-1 font-medium text-xs transition hover:bg-muted"
aria-label={`Open ${repo} on GitHub`}
>
<ExternalLink className="h-4 w-4" />
<span>Open repo</span>
</a>
</div>
</div>
</Card>
);
}Usage
import { GitHubContributors } from "@/components/ui/github-contributors";
<GitHubContributors repo="vercel/next.js" limit={12} />API Reference
GitHubContributors
The main component that fetches and displays GitHub contributors.
Prop
Type
Notes
- The component uses GitHub's public API which has rate limits
- Without authentication: 60 requests per hour per IP
- With token: 5,000 requests per hour
- Contributors are sorted by contribution count
- The top contributor gets a special star badge
- Tooltips show detailed contribution statistics
How is this guide?
Expanded Map
Expandable location card with map preview for React. Click to reveal full map with 3D tilt animation. No Google Maps API required - uses OpenStreetMap tiles.
GitHub Star Button
Animated GitHub star counter for React. Real-time API data, rolling numbers, and confetti effects. Showcase your open-source project's popularity.