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>
);
}