GitHub Activity
Interactive GitHub contribution graph with beautiful animations
Overview
A beautifully animated GitHub contribution graph component that displays user activity over the past year. Features smooth motion effects, interactive tooltips, and fully customizable themes.
Features
- Customizable Themes - Dark and light modes with custom color schemes
- Smooth Animations - Built with Motion (Framer Motion) for fluid interactions
- Interactive Tooltips - Hover to see contribution details
- Real GitHub Data - Fetches actual contribution data via API
- Fallback Mock Data - Automatic demo data generation
- Fully Responsive - Works perfectly on all screen sizes
- TypeScript - Full type safety and IntelliSense support
Demo
Installation
Copy the component code to your project:
# Make sure you have Motion installed
npm install motionCreate the file at components/aesthe-ui/github-activity/github-activity.tsx and paste the component code.
'use client'
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
interface ContributionData {
date: string
count: number
level: number
}
interface ThemeColors {
dark: string[]
light: string[]
}
interface GithubActivityProps {
username?: string
colorScheme?: 'dark' | 'light'
fontSize?: number
blockSize?: number
theme?: ThemeColors
showTooltip?: boolean
showTotalCount?: boolean
borderRadius?: number
spacing?: number
onDateClick?: (contribution: ContributionData) => void
className?: string
}
interface TooltipState {
visible: boolean
x: number
y: number
data: ContributionData | null
}
const defaultTheme: ThemeColors = {
dark: ['#1b1b1b', '#006064', '#00838f', '#0097a7', '#00adb5'],
light: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'],
}
export default function GithubActivity({
username = '',
colorScheme = 'dark',
fontSize = 12,
blockSize = 12,
theme = defaultTheme,
showTooltip = true,
showTotalCount = true,
borderRadius = 3,
spacing = 2,
onDateClick,
className,
}: GithubActivityProps) {
const [contributions, setContributions] = useState<ContributionData[]>([])
const [totalContributions, setTotalContributions] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [tooltip, setTooltip] = useState<TooltipState>({
visible: false,
x: 0,
y: 0,
data: null,
})
// Fetch GitHub contributions
const fetchContributions = async () => {
if (!username) return
setLoading(true)
setError(null)
try {
// Using GitHub contributions API via proxy to avoid CORS
const response = await fetch(
`https://github-contributions-api.jogruber.de/v4/${username}?y=last`
)
if (!response.ok)
throw new Error('User not found or API limit exceeded')
const data = await response.json()
setContributions(data.contributions)
setTotalContributions(data.total.lastYear)
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
// Fallback: Generate mock data for demonstration
generateMockData()
} finally {
setLoading(false)
}
}
// Generate mock data for demo purposes
const generateMockData = () => {
const mockData: ContributionData[] = []
const now = new Date()
const oneYearAgo = new Date(
now.getFullYear() - 1,
now.getMonth(),
now.getDate()
)
const currentDate = new Date(oneYearAgo)
let total = 0
while (currentDate <= now) {
const count = Math.floor(Math.random() * 20)
total += count
mockData.push({
date: currentDate.toISOString().split('T')[0],
count,
level: Math.min(4, Math.floor(count / 5)),
})
currentDate.setDate(currentDate.getDate() + 1)
}
setContributions(mockData)
setTotalContributions(total)
}
// Group contributions by week
const groupByWeeks = (): ContributionData[][] => {
const weeks: ContributionData[][] = []
let currentWeek: ContributionData[] = []
contributions.forEach((contribution, index) => {
const date = new Date(contribution.date)
const day = date.getDay() // 0 = Sunday, 1 = Monday, etc.
if (day === 0 && currentWeek.length > 0) {
weeks.push([...currentWeek])
currentWeek = []
}
currentWeek.push(contribution)
// Push the last week
if (index === contributions.length - 1 && currentWeek.length > 0) {
weeks.push([...currentWeek])
}
})
return weeks
}
// Get color based on contribution level
const getColor = (level: number): string => {
const colors = theme[colorScheme] || theme.dark
return colors[level] || colors[0]
}
// Format date for display
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
// Handle block mouse enter
const handleBlockMouseEnter = (
contribution: ContributionData,
event: React.MouseEvent<HTMLDivElement>
) => {
if (!showTooltip) return
const rect = event.currentTarget.getBoundingClientRect()
setTooltip({
visible: true,
x: rect.left + window.scrollX + rect.width / 2,
y: rect.top + window.scrollY - 10,
data: contribution,
})
}
// Handle block mouse leave
const handleBlockMouseLeave = () => {
setTooltip({ visible: false, x: 0, y: 0, data: null })
}
// Handle block click
const handleBlockClick = (contribution: ContributionData) => {
if (onDateClick) {
onDateClick(contribution)
}
}
useEffect(() => {
fetchContributions()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [username])
const weeks = groupByWeeks()
const colors = theme[colorScheme] || theme.dark
if (loading) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn(
'flex items-center justify-center gap-3 rounded-xl border p-10',
colorScheme === 'dark'
? 'border-border bg-card text-card-foreground'
: 'border-gray-200 bg-white text-gray-900',
className
)}
style={{ fontSize: `${fontSize}px` }}
>
<motion.div
className="h-5 w-5 rounded-full border-2 border-t-transparent"
style={{ borderColor: 'currentColor', borderTopColor: 'transparent' }}
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
/>
<span>Loading contributions...</span>
</motion.div>
)
}
if (error && contributions.length === 0) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
'rounded-xl border p-10 text-center',
colorScheme === 'dark'
? 'border-border bg-card text-card-foreground'
: 'border-gray-200 bg-white text-gray-900',
className
)}
style={{ fontSize: `${fontSize}px` }}
>
<span className="text-destructive">Error: {error}</span>
</motion.div>
)
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className={cn(
'rounded-xl border p-5 transition-all duration-300',
colorScheme === 'dark'
? 'border-border bg-card text-card-foreground shadow-lg'
: 'border-gray-200 bg-white text-gray-900 shadow-md',
className
)}
style={{ fontSize: `${fontSize}px` }}
>
{/* Header */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="mb-4 flex flex-wrap items-center justify-between gap-4"
>
{showTotalCount && (
<motion.div
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
className="text-sm opacity-80"
>
<strong className="font-semibold">
{totalContributions.toLocaleString()}
</strong>{' '}
contributions in the last year
</motion.div>
)}
<motion.div
initial={{ x: 20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
className="flex items-center gap-1 text-xs opacity-70"
>
<span>Less</span>
{colors.map((color, index) => (
<motion.div
key={index}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.4 + index * 0.05 }}
className="transition-transform hover:scale-110"
style={{
backgroundColor: color,
width: blockSize,
height: blockSize,
borderRadius: `${borderRadius}px`,
}}
/>
))}
<span>More</span>
</motion.div>
</motion.div>
{/* Calendar Grid */}
<div className="flex gap-3">
{/* Day labels */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="flex min-w-[40px] flex-col gap-0.5 pr-2 pt-5 text-right text-[0.7em] opacity-60"
>
<span style={{ height: blockSize, margin: `${spacing}px 0` }}>Mon</span>
<span style={{ height: blockSize, margin: `${spacing}px 0` }}></span>
<span style={{ height: blockSize, margin: `${spacing}px 0` }}>Wed</span>
<span style={{ height: blockSize, margin: `${spacing}px 0` }}></span>
<span style={{ height: blockSize, margin: `${spacing}px 0` }}>Fri</span>
<span style={{ height: blockSize, margin: `${spacing}px 0` }}></span>
<span style={{ height: blockSize, margin: `${spacing}px 0` }}>Sun</span>
</motion.div>
{/* Contribution grid */}
<div className="flex gap-0.5 overflow-x-auto pb-2">
{weeks.map((week, weekIndex) => (
<motion.div
key={weekIndex}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 + weekIndex * 0.01 }}
className="flex flex-col gap-0.5"
>
{week.map((contribution, dayIndex) => (
<motion.div
key={`${weekIndex}-${dayIndex}`}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
delay: 0.5 + weekIndex * 0.01 + dayIndex * 0.005,
type: 'spring',
stiffness: 500,
damping: 30,
}}
whileHover={{
scale: 1.2,
zIndex: 10,
transition: { duration: 0.2 },
}}
whileTap={{ scale: 0.95 }}
className={cn(
'cursor-pointer transition-all duration-200',
contribution.count === 0 && 'opacity-30'
)}
style={{
width: blockSize,
height: blockSize,
backgroundColor: getColor(contribution.level),
borderRadius: `${borderRadius}px`,
margin: spacing,
}}
onMouseEnter={(e) => handleBlockMouseEnter(contribution, e)}
onMouseLeave={handleBlockMouseLeave}
onClick={() => handleBlockClick(contribution)}
data-count={contribution.count}
/>
))}
</motion.div>
))}
</div>
</div>
{/* Tooltip */}
<AnimatePresence>
{tooltip.visible && tooltip.data && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.8 }}
transition={{ duration: 0.15 }}
className="pointer-events-none fixed z-50"
style={{
left: tooltip.x,
top: tooltip.y,
transform: 'translateX(-50%) translateY(-100%)',
}}
>
<div className="relative">
<div className="rounded-md bg-black px-3 py-2 text-xs text-white shadow-lg dark:bg-gray-900">
<div className="whitespace-nowrap">
<strong>{tooltip.data.count} contributions</strong>
<br />
<span className="text-gray-300">
{formatDate(tooltip.data.date)}
</span>
</div>
</div>
<div
className="absolute left-1/2 h-0 w-0 -translate-x-1/2"
style={{
borderLeft: '4px solid transparent',
borderRight: '4px solid transparent',
borderTop: '4px solid black',
}}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
Usage
Basic Usage
import GithubActivity from '@/components/aesthe-ui/github-activity/github-activity'
export default function MyPage() {
return <GithubActivity username="Arnab7456" />
}With Custom Theme
<GithubActivity
username="Arnab7456"
colorScheme="light"
theme={{
dark: ["#1b1b1b", "#006064", "#00838f", "#0097a7", "#00adb5"],
light: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"]
}}
/>Custom Styling
<GithubActivity
username="Arnab7456"
blockSize={14}
borderRadius={4}
spacing={3}
fontSize={13}
className="max-w-4xl mx-auto"
/>With Click Handler
<GithubActivity
username="Arnab7456"
onDateClick={(contribution) => {
console.log(`${contribution.count} contributions on ${contribution.date}`)
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
username | string | "" | GitHub username to fetch contributions for |
colorScheme | "dark" | "light" | "dark" | Color scheme for the component |
fontSize | number | 12 | Base font size in pixels |
blockSize | number | 12 | Size of each contribution block in pixels |
theme | ThemeColors | Default theme | Custom color scheme object |
showTooltip | boolean | true | Show tooltip on hover |
showTotalCount | boolean | true | Show total contribution count |
borderRadius | number | 3 | Border radius of blocks in pixels |
spacing | number | 2 | Spacing between blocks in pixels |
onDateClick | (contribution: ContributionData) => void | undefined | Callback when a date is clicked |
className | string | undefined | Additional CSS classes |
Theme Colors Type
interface ThemeColors {
dark: string[] // Array of 5 colors for dark mode
light: string[] // Array of 5 colors for light mode
}Contribution Data Type
interface ContributionData {
date: string // ISO date string
count: number // Number of contributions
level: number // Intensity level (0-4)
}Examples
Different Color Schemes
Custom Sizes
{/* Small */}
<GithubActivity
username="octocat"
blockSize={10}
fontSize={11}
spacing={1}
/>
{/* Medium (default) */}
<GithubActivity
username="Arnab7456"
blockSize={12}
fontSize={12}
spacing={2}
/>
{/* Large */}
<GithubActivity
username="Arnab7456"
blockSize={16}
fontSize={14}
spacing={3}
/>Custom Colors
<GithubActivity
username="octocat"
theme={{
dark: [
"#161b22",
"#0e4429",
"#006d32",
"#26a641",
"#39d353"
],
light: [
"#ebedf0",
"#9be9a8",
"#40c463",
"#30a14e",
"#216e39"
]
}}
/>API Details
The component uses the GitHub Contributions API to fetch contribution data. If the API fails or no username is provided, the component will automatically generate mock data for demonstration purposes.
The API endpoint used:
https://github-contributions-api.jogruber.de/v4/{username}?y=lastAnimation Details
The component uses Motion (Framer Motion) for smooth animations:
- Entry Animation: Fade in from bottom with 0.5s duration
- Grid Stagger: Each week column animates with a slight delay
- Block Animation: Spring animation with scale effect
- Hover Effect: Scale up to 1.2x with smooth transition
- Tooltip: Fade in/out with scale and position animation
Accessibility
- All interactive elements have proper cursor styles
- Tooltips provide detailed information on hover
- Semantic HTML structure
- Keyboard accessible (click events)
- ARIA-friendly markup
Browser Support
Works in all modern browsers that support:
- CSS Grid
- Flexbox
- ES6+ JavaScript
- React 18+
- Motion/Framer Motion
Notes
- The component fetches data client-side using
'use client' - Mock data is automatically generated if the API fails
- The contribution grid shows the last year of activity
- Each block represents one day of contributions
- Color intensity increases with more contributions
Complete Component Code
'use client'
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
// ... (full component code as shown in the file)Troubleshooting
API Rate Limiting
If you're experiencing API rate limiting, the component will automatically fall back to mock data. For production use, consider:
- Caching the API response
- Using server-side rendering with data fetching
- Implementing your own GitHub API integration with authentication
Performance
For better performance with large datasets:
- Consider memoizing the
groupByWeeksfunction - Use
React.memofor the component if it doesn't need frequent updates - Implement virtualization for very large grids (multiple years)
Styling Conflicts
If you experience styling conflicts:
- Ensure Tailwind CSS is properly configured
- Check that the
cnutility function is available - Verify that Motion is installed correctly