Aesthe UI

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

0 contributions in the last year
Less
More
MonWedFriSun

Installation

Copy the component code to your project:

# Make sure you have Motion installed
npm install motion

Create 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

PropTypeDefaultDescription
usernamestring""GitHub username to fetch contributions for
colorScheme"dark" | "light""dark"Color scheme for the component
fontSizenumber12Base font size in pixels
blockSizenumber12Size of each contribution block in pixels
themeThemeColorsDefault themeCustom color scheme object
showTooltipbooleantrueShow tooltip on hover
showTotalCountbooleantrueShow total contribution count
borderRadiusnumber3Border radius of blocks in pixels
spacingnumber2Spacing between blocks in pixels
onDateClick(contribution: ContributionData) => voidundefinedCallback when a date is clicked
classNamestringundefinedAdditional 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

0 contributions in the last year
Less
More
MonWedFriSun

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=last

Animation 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:

  1. Caching the API response
  2. Using server-side rendering with data fetching
  3. Implementing your own GitHub API integration with authentication

Performance

For better performance with large datasets:

  • Consider memoizing the groupByWeeks function
  • Use React.memo for the component if it doesn't need frequent updates
  • Implement virtualization for very large grids (multiple years)

Styling Conflicts

If you experience styling conflicts:

  1. Ensure Tailwind CSS is properly configured
  2. Check that the cn utility function is available
  3. Verify that Motion is installed correctly
  • Alerts - Notification components
  • Buttons - Interactive button components
  • AI Input - AI-powered input components