github-button

PreviousNext
Docs
reuiui

Preview

Loading preview…
registry/default/ui/github-button.tsx
'use client';

import React, { useCallback, useEffect, useState } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Star } from 'lucide-react';
import { motion, useInView, type SpringOptions, type UseInViewOptions } from 'motion/react';
import { cn } from '@/lib/utils';

const githubButtonVariants = cva(
  'cursor-pointer relative overflow-hidden will-change-transform backface-visibility-hidden transform-gpu transition-transform duration-200 ease-out hover:scale-105 group whitespace-nowrap focus-visible:outline-hidden inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background disabled:pointer-events-none disabled:opacity-60 [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        default:
          'bg-zinc-950 hover:bg-zinc-900 text-white border-gray-700 dark:bg-zinc-50 dark:border-gray-300 dark:text-zinc-950 dark:hover:bg-zinc-50',
        outline: 'bg-background text-accent-foreground border border-input hover:bg-accent',
      },
      size: {
        default: 'h-8.5 rounded-md px-3 gap-2 text-[0.8125rem] leading-none [&_svg]:size-4 gap-2',
        sm: 'h-7 rounded-md px-2.5 gap-1.5 text-xs leading-none [&_svg]:size-3.5 gap-1.5',
        lg: 'h-10 rounded-md px-4 gap-2.5 text-sm leading-none [&_svg]:size-5 gap-2.5',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  },
);

interface GithubButtonProps extends React.ComponentProps<'button'>, VariantProps<typeof githubButtonVariants> {
  /** Whether to round stars */
  roundStars?: boolean;
  /** Whether to show Github icon */
  fixedWidth?: boolean;
  /** Initial number of stars */
  initialStars?: number;
  /** Class for stars */
  starsClass?: string;
  /** Target number of stars to animate to */
  targetStars?: number;
  /** Animation duration in seconds */
  animationDuration?: number;
  /** Animation delay in seconds */
  animationDelay?: number;
  /** Whether to start animation automatically */
  autoAnimate?: boolean;
  /** Callback when animation completes */
  onAnimationComplete?: () => void;
  /** Whether to show Github icon */
  showGithubIcon?: boolean;
  /** Whether to show star icon */
  showStarIcon?: boolean;
  /** Whether to show separator */
  separator?: boolean;
  /** Whether stars should be filled */
  filled?: boolean;
  /** Repository URL for actual Github integration */
  repoUrl?: string;
  /** Button text label */
  label?: string;
  /** Use in-view detection to trigger animation */
  useInViewTrigger?: boolean;
  /** In-view options */
  inViewOptions?: UseInViewOptions;
  /** Spring transition options */
  transition?: SpringOptions;
}

function GithubButton({
  initialStars = 0,
  targetStars = 0,
  starsClass = '',
  fixedWidth = true,
  animationDuration = 2,
  animationDelay = 0,
  autoAnimate = true,
  className,
  variant = 'default',
  size = 'default',
  showGithubIcon = true,
  showStarIcon = true,
  roundStars = false,
  separator = false,
  filled = false,
  repoUrl,
  onClick,
  label = '',
  useInViewTrigger = false,
  inViewOptions = { once: true },
  transition,
  ...props
}: GithubButtonProps) {
  const [currentStars, setCurrentStars] = useState(initialStars);
  const [isAnimating, setIsAnimating] = useState(false);
  const [starProgress, setStarProgress] = useState(filled ? 100 : 0);
  const [hasAnimated, setHasAnimated] = useState(false);

  // Format number with units
  const formatNumber = (num: number) => {
    const units = ['k', 'M', 'B', 'T'];

    if (roundStars && num >= 1000) {
      let unitIndex = -1;
      let value = num;

      while (value >= 1000 && unitIndex < units.length - 1) {
        value /= 1000;
        unitIndex++;
      }

      // Format to 1 decimal place if needed, otherwise show whole number
      const formatted = value % 1 === 0 ? value.toString() : value.toFixed(1);
      return `${formatted}${units[unitIndex]}`;
    }

    return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  };

  // Start animation
  const startAnimation = useCallback(() => {
    if (isAnimating || hasAnimated) return;

    setIsAnimating(true);
    const startTime = Date.now();
    const startValue = 0; // Always start from 0 for number animation
    const endValue = targetStars;
    const duration = animationDuration * 1000;

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

      // Easing function for smooth animation
      const easeOutQuart = 1 - Math.pow(1 - progress, 4);

      // Update star count from 0 to target with more frequent updates
      const newStars = Math.round(startValue + (endValue - startValue) * easeOutQuart);
      setCurrentStars(newStars);

      // Update star fill progress (0 to 100)
      setStarProgress(progress * 100);

      if (progress < 1) {
        requestAnimationFrame(animate);
      } else {
        setCurrentStars(endValue);
        setStarProgress(100);
        setIsAnimating(false);
        setHasAnimated(true);
      }
    };

    setTimeout(() => {
      requestAnimationFrame(animate);
    }, animationDelay * 1000);
  }, [isAnimating, hasAnimated, targetStars, animationDuration, animationDelay]);

  // Use in-view detection if enabled
  const ref = React.useRef(null);
  const isInView = useInView(ref, inViewOptions);

  // Reset animation state when targetStars changes
  useEffect(() => {
    setHasAnimated(false);
    setCurrentStars(initialStars);
  }, [targetStars, initialStars]);

  // Auto-start animation or use in-view trigger
  useEffect(() => {
    if (useInViewTrigger) {
      if (isInView && !hasAnimated) {
        startAnimation();
      }
    } else if (autoAnimate && !hasAnimated) {
      startAnimation();
    }
  }, [autoAnimate, useInViewTrigger, isInView, hasAnimated, startAnimation]);

  const navigateToRepo = () => {
    if (!repoUrl) {
      return;
    }

    // Next.js compatible navigation approach
    try {
      // Create a temporary anchor element for reliable navigation
      const link = document.createElement('a');
      link.href = repoUrl;
      link.target = '_blank';
      link.rel = 'noopener noreferrer';

      // Temporarily add to DOM and click
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
    } catch {
      // Fallback to window.open
      try {
        window.open(repoUrl, '_blank', 'noopener,noreferrer');
      } catch {
        // Final fallback
        window.location.href = repoUrl;
      }
    }
  };

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    if (onClick) {
      onClick(event);
      return;
    }

    if (repoUrl) {
      navigateToRepo();
    } else if (!hasAnimated) {
      startAnimation();
    }
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
    // Handle Enter and Space key presses for accessibility
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();

      if (repoUrl) {
        navigateToRepo();
      } else if (!hasAnimated) {
        startAnimation();
      }
    }
  };

  return (
    <button
      ref={ref}
      className={cn(githubButtonVariants({ variant, size, className }), separator && 'ps-0')}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      role="button"
      tabIndex={0}
      aria-label={repoUrl ? `Star ${label} on GitHub` : label}
      {...props}
    >
      {showGithubIcon && (
        <div
          className={cn(
            'h-full relative flex items-center justify-center',
            separator && 'w-9 bg-muted/60 border-e border-input',
          )}
        >
          <svg role="img" viewBox="0 0 24 24" fill="currentColor">
            <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
          </svg>
        </div>
      )}

      {label && <span>{label}</span>}

      {/* Animated Star Icon */}
      {showStarIcon && (
        <div className="relative inline-flex shrink-0">
          <Star className="fill-muted-foreground text-muted-foreground" aria-hidden="true" />
          <Star
            className="absolute top-0 start-0 text-yellow-400 fill-yellow-400"
            size={18}
            aria-hidden="true"
            style={{
              clipPath: `inset(${100 - starProgress}% 0 0 0)`,
            }}
          />
        </div>
      )}

      {/* Animated Number Counter with Ticker Effect */}
      <div className={cn('flex flex-col font-semibold relative overflow-hidden', starsClass)}>
        <motion.div
          animate={{ opacity: 1 }}
          transition={{
            type: 'spring',
            stiffness: 300,
            damping: 30,
            ...transition,
          }}
          className="tabular-nums"
        >
          <span>{currentStars > 0 && formatNumber(currentStars)}</span>
        </motion.div>
        {fixedWidth && <span className="opacity-0 h-0 overflow-hidden tabular-nums">{formatNumber(targetStars)}</span>}
      </div>
    </button>
  );
}

export { GithubButton, githubButtonVariants };
export type { GithubButtonProps };

Installation

npx shadcn@latest add @reui/github-button

Usage

import { GithubButton } from "@/components/ui/github-button"
<GithubButton />