summaryrefslogtreecommitdiff
path: root/src/components/icons/animated
diff options
context:
space:
mode:
authorBertrand Yuan <bert.yuan@outlook.com>2025-12-15 23:48:10 +0800
committerBertrand Yuan <bert.yuan@outlook.com>2025-12-15 23:48:10 +0800
commit5b7ccf0b671e2999b62befc729a3e517a0433728 (patch)
tree8bf476dc7c75914c221042546840dc76267366df /src/components/icons/animated
initial commit -- the front-end prototype
The initial code is base on Anirudh's work. More to see at: https://github.com/techwithanirudh/shadcn-blog Therefore, the code in this commit is under MIT license.
Diffstat (limited to 'src/components/icons/animated')
-rw-r--r--src/components/icons/animated/check.tsx111
-rw-r--r--src/components/icons/animated/upload.tsx102
2 files changed, 213 insertions, 0 deletions
diff --git a/src/components/icons/animated/check.tsx b/src/components/icons/animated/check.tsx
new file mode 100644
index 0000000..24a246c
--- /dev/null
+++ b/src/components/icons/animated/check.tsx
@@ -0,0 +1,111 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import type { Variants } from 'motion/react';
+import { motion, useAnimation } from 'motion/react';
+import type { HTMLAttributes } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
+
+export interface CheckIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface CheckIconProps extends HTMLAttributes<HTMLDivElement> {
+ size?: number;
+}
+
+const pathVariants: Variants = {
+ normal: {
+ opacity: 1,
+ pathLength: 1,
+ scale: 1,
+ transition: {
+ duration: 0.3,
+ opacity: { duration: 0.1 },
+ },
+ },
+ animate: {
+ opacity: [0, 1],
+ pathLength: [0, 1],
+ scale: [0.5, 1],
+ transition: {
+ duration: 0.4,
+ opacity: { duration: 0.1 },
+ },
+ },
+};
+
+const CheckIcon = forwardRef<CheckIconHandle, CheckIconProps>(
+ ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+
+ return {
+ startAnimation: () => controls.start('animate'),
+ stopAnimation: () => controls.start('normal'),
+ };
+ });
+
+ const handleMouseEnter = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ if (!isControlledRef.current) {
+ controls.start('animate');
+ } else {
+ onMouseEnter?.(e);
+ }
+ },
+ [controls, onMouseEnter],
+ );
+
+ const handleMouseLeave = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ if (!isControlledRef.current) {
+ controls.start('normal');
+ } else {
+ onMouseLeave?.(e);
+ }
+ },
+ [controls, onMouseLeave],
+ );
+
+ return (
+ <div
+ className={cn(
+ 'flex cursor-pointer select-none items-center justify-center rounded-md p-2 transition-colors duration-200 hover:bg-accent',
+ className,
+ )}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ {...props}
+ >
+ {/* biome-ignore lint/a11y/noSvgWithoutTitle: <explanation> */}
+ <svg
+ xmlns='http://www.w3.org/2000/svg'
+ width={size}
+ height={size}
+ viewBox='0 0 24 24'
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ >
+ <motion.path
+ variants={pathVariants}
+ initial='normal'
+ animate={controls}
+ d='M4 12 9 17L20 6'
+ />
+ </svg>
+ </div>
+ );
+ },
+);
+
+CheckIcon.displayName = 'CheckIcon';
+
+export { CheckIcon };
diff --git a/src/components/icons/animated/upload.tsx b/src/components/icons/animated/upload.tsx
new file mode 100644
index 0000000..19ac51e
--- /dev/null
+++ b/src/components/icons/animated/upload.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import type { Variants } from 'motion/react';
+import { motion, useAnimation } from 'motion/react';
+import type { HTMLAttributes } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
+
+export interface UploadIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface UploadIconProps extends HTMLAttributes<HTMLDivElement> {
+ size?: number;
+}
+
+const arrowVariants: Variants = {
+ normal: { y: 0 },
+ animate: {
+ y: -2,
+ transition: {
+ type: 'spring',
+ stiffness: 200,
+ damping: 10,
+ mass: 1,
+ },
+ },
+};
+
+const UploadIcon = forwardRef<UploadIconHandle, UploadIconProps>(
+ ({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
+ const controls = useAnimation();
+ const isControlledRef = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlledRef.current = true;
+
+ return {
+ startAnimation: () => controls.start('animate'),
+ stopAnimation: () => controls.start('normal'),
+ };
+ });
+
+ const handleMouseEnter = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ if (!isControlledRef.current) {
+ controls.start('animate');
+ } else {
+ onMouseEnter?.(e);
+ }
+ },
+ [controls, onMouseEnter],
+ );
+
+ const handleMouseLeave = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ if (!isControlledRef.current) {
+ controls.start('normal');
+ } else {
+ onMouseLeave?.(e);
+ }
+ },
+ [controls, onMouseLeave],
+ );
+
+ return (
+ <div
+ className={cn(
+ 'flex cursor-pointer select-none items-center justify-center rounded-md p-2 transition-colors duration-200 hover:bg-accent',
+ className,
+ )}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ {...props}
+ >
+ {/* biome-ignore lint/a11y/noSvgWithoutTitle: <explanation> */}
+ <svg
+ xmlns='http://www.w3.org/2000/svg'
+ width={size}
+ height={size}
+ viewBox='0 0 24 24'
+ fill='none'
+ stroke='currentColor'
+ strokeWidth='2'
+ strokeLinecap='round'
+ strokeLinejoin='round'
+ >
+ <path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
+ <motion.g variants={arrowVariants} animate={controls}>
+ <polyline points='17 8 12 3 7 8' />
+ <line x1='12' x2='12' y1='3' y2='15' />
+ </motion.g>
+ </svg>
+ </div>
+ );
+ },
+);
+
+UploadIcon.displayName = 'UploadIcon';
+
+export { UploadIcon };