AnimatedCheckmark.tsx7.4 KBView on GitHub
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { useState } from 'react';

interface AnimatedCheckmarkProps {
  isDone: boolean;
  className?: string;
  circleClassName?: string;
  onClick?: (e: React.MouseEvent) => void;
  /**
   * Size of the checkmark. 'sm' = 14px, 'md' = 16px (default), 'lg' = 20px
   */
  size?: 'sm' | 'md' | 'lg';
}

/**
 * Animated checkmark component that transitions between:
 * - Hollow circle (idle state)
 * - Filled circle with checkmark (done state)
 *
 * Features:
 * - Idle state: Shows low opacity background on hover
 * - Done state: Fully opaque background with white checkmark
 * - Checkmark path draws in smoothly
 * - Spring animation for scale/rotation
 * - Hover effect with scale animation
 */
export function AnimatedCheckmark({
  isDone,
  className,
  circleClassName,
  onClick,
  size = 'md',
}: AnimatedCheckmarkProps) {
  const [isHovered, setIsHovered] = useState(false);

  // Size configurations
  const sizeConfig = {
    sm: {
      idle: 14,
      done: 16,
      viewBox: { idle: '0 0 14 14', done: '0 0 16 16' },
      radius: { idle: 6, done: 7 },
      center: { idle: 7, done: 8 },
    },
    md: {
      idle: 16,
      done: 20,
      viewBox: { idle: '0 0 16 16', done: '0 0 20 20' },
      radius: { idle: 7, done: 9 },
      center: { idle: 8, done: 10 },
    },
    lg: {
      idle: 20,
      done: 24,
      viewBox: { idle: '0 0 20 20', done: '0 0 24 24' },
      radius: { idle: 9, done: 11 },
      center: { idle: 10, done: 12 },
    },
  };
  const config = sizeConfig[size];

  const handleClick = (e: React.MouseEvent) => {
    e.stopPropagation();
    onClick?.(e);
  };

  // Extract color from circleClassName for done state
  // Maps the text-* classes to appropriate stroke/fill colors
  const getColorFromClassName = (classes: string | undefined) => {
    if (!classes)
      return {
        stroke: 'stroke-green-600 dark:stroke-green-500',
        fill: 'fill-green-600 dark:fill-green-500',
      };

    // Action color (blue) - used for draft tasks
    if (classes.includes('text-action')) {
      return {
        stroke: 'stroke-action',
        fill: 'fill-action',
      };
    }
    // Green draft state (legacy)
    if (classes.includes('text-green-500') || classes.includes('stroke-green-500')) {
      return {
        stroke: 'stroke-green-600 dark:stroke-green-500',
        fill: 'fill-green-600 dark:fill-green-500',
      };
    }
    // Blue unread state
    if (classes.includes('text-[#006FFE]')) {
      return { stroke: 'stroke-[#006FFE]', fill: 'fill-[#006FFE]' };
    }
    // Muted state
    if (classes.includes('text-muted-foreground')) {
      return {
        stroke: 'stroke-gray-500 dark:stroke-gray-400',
        fill: 'fill-gray-500 dark:fill-gray-400',
      };
    }

    // Task type colors (from TimelineTaskItem)
    if (classes.includes('text-blue-600')) {
      return {
        stroke: 'stroke-blue-600 dark:stroke-blue-500',
        fill: 'fill-blue-600 dark:fill-blue-500',
      };
    }
    if (classes.includes('text-green-600')) {
      return {
        stroke: 'stroke-green-600 dark:stroke-green-500',
        fill: 'fill-green-600 dark:fill-green-500',
      };
    }
    if (classes.includes('text-purple-600')) {
      return {
        stroke: 'stroke-purple-600 dark:stroke-purple-500',
        fill: 'fill-purple-600 dark:fill-purple-500',
      };
    }
    if (classes.includes('text-orange-600')) {
      return {
        stroke: 'stroke-orange-600 dark:stroke-orange-500',
        fill: 'fill-orange-600 dark:fill-orange-500',
      };
    }
    if (classes.includes('text-pink-600')) {
      return {
        stroke: 'stroke-pink-600 dark:stroke-pink-500',
        fill: 'fill-pink-600 dark:fill-pink-500',
      };
    }
    if (classes.includes('text-yellow-600')) {
      return {
        stroke: 'stroke-yellow-600 dark:stroke-yellow-500',
        fill: 'fill-yellow-600 dark:fill-yellow-500',
      };
    }

    // Gray (canvas mode idle state)
    if (classes.includes('text-gray-300')) {
      return {
        stroke: 'stroke-gray-300 dark:stroke-gray-600',
        fill: 'fill-gray-300 dark:fill-gray-600',
      };
    }

    // Default to green
    return {
      stroke: 'stroke-green-600 dark:stroke-green-500',
      fill: 'fill-green-600 dark:fill-green-500',
    };
  };

  const colors = getColorFromClassName(circleClassName);

  // Checkmark path scales with size
  const checkmarkPaths = {
    sm: 'M5 8L7 10L11 6',
    md: 'M6 10.5L8.5 13L14 7.5',
    lg: 'M7 12.5L10 15.5L17 8.5',
  };

  if (isDone) {
    return (
      <motion.div
        key=[redacted]
        initial={{ scale: 0, rotate: -180 }}
        animate={{ scale: isHovered ? 1.01 : 1, rotate: 0 }}
        whileHover={{ scale: 1.1 }}
        transition={{
          type: 'spring',
          stiffness: 500,
          damping: 25,
          duration: 0.5,
        }}
        onMouseEnter={() => setIsHovered(true)}
        onMouseLeave={() => setIsHovered(false)}
        onClick={handleClick}
        className={cn('cursor-pointer', className)}
      >
        <svg
          width={config.done}
          height={config.done}
          viewBox={config.viewBox.done}
          fill="none"
          xmlns="http://www.w3.org/2000/svg"
          className="shrink-0"
        >
          {/* Background circle with fade-in animation - fully opaque */}
          <motion.circle
            cx={config.center.done}
            cy={config.center.done}
            r={config.radius.done}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            transition={{ duration: 0.3, ease: 'easeOut' }}
            className={colors.fill}
          />

          {/* Checkmark path with draw-in animation - white for contrast */}
          <motion.path
            d={checkmarkPaths[size]}
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
            className="stroke-white dark:stroke-white"
            initial={{ pathLength: 0, opacity: 0 }}
            animate={{ pathLength: 1, opacity: 1 }}
            transition={{
              pathLength: { duration: 0.3, ease: 'easeOut', delay: 0.1 },
              opacity: { duration: 0.15, delay: 0.1 },
            }}
          />
        </svg>
      </motion.div>
    );
  }

  return (
    <motion.div
      key=[redacted]
      initial={{ scale: 0.8, opacity: 0 }}
      animate={{ scale: 1, opacity: 1 }}
      whileHover={{ scale: 1.05 }}
      transition={{ duration: 0.2 }}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      onClick={handleClick}
      className={cn('cursor-pointer', className)}
    >
      <svg
        width={config.idle}
        height={config.idle}
        viewBox={config.viewBox.idle}
        fill="none"
        xmlns="http://www.w3.org/2000/svg"
        className="shrink-0"
      >
        {/* Background circle - fades in on hover with low opacity */}
        <motion.circle
          cx={config.center.idle}
          cy={config.center.idle}
          r={config.radius.idle}
          animate={{ opacity: isHovered ? 0.15 : 0 }}
          transition={{ duration: 0.2, ease: 'easeOut' }}
          className={colors.fill}
        />

        {/* Outer circle stroke */}
        <circle
          cx={config.center.idle}
          cy={config.center.idle}
          r={config.radius.idle}
          className={colors.stroke}
          strokeWidth="1.5"
          fill="none"
        />
      </svg>
    </motion.div>
  );
}