animated-icons.tsx178 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { motion, type MotionProps } from 'motion/react'; |
| 4 | import { useEffect, useState } from 'react'; |
| 5 | |
| 6 | /** |
| 7 | * Animated SVG icons in the lucide-animated.com style — stateless between |
| 8 | * renders, replay the entrance tween on hover. Each icon is wrapped in a |
| 9 | * `<motion.span>` that owns the hover state, so the caller just drops the |
| 10 | * component in. |
| 11 | * |
| 12 | * Sizing: icons respect `className` width/height utilities (size-5, size-6, |
| 13 | * etc.). Stroke scales with currentColor. |
| 14 | */ |
| 15 | |
| 16 | interface IconProps { |
| 17 | className?: string; |
| 18 | // Force the hover animation to replay — useful for driving from a parent |
| 19 | // (e.g. sidebar-collapse trigger). |
| 20 | animate?: boolean; |
| 21 | } |
| 22 | |
| 23 | function useHoverKey(animate?: boolean): number { |
| 24 | // Changing `key` re-mounts the motion children, replaying the animation. |
| 25 | const [tick, setTick] = useState(0); |
| 26 | useEffect(() => { |
| 27 | if (animate) setTick((t) => t + 1); |
| 28 | }, [animate]); |
| 29 | return tick; |
| 30 | } |
| 31 | |
| 32 | const iconBase = { |
| 33 | xmlns: 'http://www.w3.org/2000/svg', |
| 34 | viewBox: '0 0 24 24', |
| 35 | fill: 'none', |
| 36 | stroke: 'currentColor', |
| 37 | strokeWidth: 2, |
| 38 | strokeLinecap: 'round' as const, |
| 39 | strokeLinejoin: 'round' as const, |
| 40 | }; |
| 41 | |
| 42 | function HoverWrap({ |
| 43 | children, |
| 44 | className, |
| 45 | }: { |
| 46 | children: (hover: boolean) => React.ReactNode; |
| 47 | className?: string; |
| 48 | }) { |
| 49 | const [hover, setHover] = useState(false); |
| 50 | return ( |
| 51 | <span |
| 52 | className={className} |
| 53 | onMouseEnter={() => setHover(true)} |
| 54 | onMouseLeave={() => setHover(false)} |
| 55 | onFocus={() => setHover(true)} |
| 56 | onBlur={() => setHover(false)} |
| 57 | > |
| 58 | {children(hover)} |
| 59 | </span> |
| 60 | ); |
| 61 | } |
| 62 | |
| 63 | /** Folder icon — flap lifts on hover. */ |
| 64 | export function FolderIcon({ className, animate }: IconProps) { |
| 65 | const key = useHoverKey(animate); |
| 66 | return ( |
| 67 | <HoverWrap className={className}> |
| 68 | {(hover) => ( |
| 69 | <svg key={`folder-${key}-${hover ? 1 : 0}`} {...iconBase} className="size-full"> |
| 70 | <motion.path |
| 71 | d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7z" |
| 72 | initial={{ pathLength: 0 }} |
| 73 | animate={{ pathLength: 1 }} |
| 74 | transition={{ duration: 0.35, ease: 'easeOut' }} |
| 75 | /> |
| 76 | <motion.path |
| 77 | d="M4 10h16" |
| 78 | initial={{ pathLength: 0, opacity: 0 }} |
| 79 | animate={hover ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }} |
| 80 | transition={{ duration: 0.25, ease: 'easeOut' }} |
| 81 | /> |
| 82 | </svg> |
| 83 | )} |
| 84 | </HoverWrap> |
| 85 | ); |
| 86 | } |
| 87 | |
| 88 | /** Settings gear — rotates 45° on hover. */ |
| 89 | export function SettingsIcon({ className, animate }: IconProps) { |
| 90 | const key = useHoverKey(animate); |
| 91 | return ( |
| 92 | <HoverWrap className={className}> |
| 93 | {(hover) => ( |
| 94 | <motion.svg |
| 95 | key={`settings-${key}`} |
| 96 | {...iconBase} |
| 97 | className="size-full" |
| 98 | animate={{ rotate: hover ? 45 : 0 }} |
| 99 | transition={{ type: 'spring', stiffness: 200, damping: 15 }} |
| 100 | > |
| 101 | <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /> |
| 102 | <circle cx="12" cy="12" r="3" /> |
| 103 | </motion.svg> |
| 104 | )} |
| 105 | </HoverWrap> |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | /** Shield (admin) — scales + pulses briefly on hover. */ |
| 110 | export function ShieldIcon({ className, animate }: IconProps) { |
| 111 | const key = useHoverKey(animate); |
| 112 | return ( |
| 113 | <HoverWrap className={className}> |
| 114 | {(hover) => ( |
| 115 | <motion.svg |
| 116 | key={`shield-${key}`} |
| 117 | {...iconBase} |
| 118 | className="size-full" |
| 119 | animate={{ scale: hover ? 1.1 : 1 }} |
| 120 | transition={{ type: 'spring', stiffness: 250, damping: 12 }} |
| 121 | > |
| 122 | <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /> |
| 123 | <motion.path |
| 124 | d="m9 12 2 2 4-4" |
| 125 | initial={{ pathLength: 0, opacity: 0 }} |
| 126 | animate={hover ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0.7 }} |
| 127 | transition={{ duration: 0.35, ease: 'easeOut' }} |
| 128 | /> |
| 129 | </motion.svg> |
| 130 | )} |
| 131 | </HoverWrap> |
| 132 | ); |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Copy icon that morphs into a checkmark when `copied` is true. |
| 137 | * The caller owns the `copied` state (set on click, clear on timeout). |
| 138 | */ |
| 139 | export function CopyIcon({ className, copied = false }: IconProps & { copied?: boolean }) { |
| 140 | return ( |
| 141 | <span className={className}> |
| 142 | <svg {...iconBase} className="size-full"> |
| 143 | {/* the two-rectangle copy glyph, faded out when copied */} |
| 144 | <motion.g animate={{ opacity: copied ? 0 : 1 }} transition={{ duration: 0.15 }}> |
| 145 | <rect x="9" y="9" width="13" height="13" rx="2" ry="2" /> |
| 146 | <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /> |
| 147 | </motion.g> |
| 148 | {/* the checkmark, strokes in on copy */} |
| 149 | <motion.path |
| 150 | d="M5 12l5 5L20 7" |
| 151 | initial={{ pathLength: 0, opacity: 0 }} |
| 152 | animate={copied ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }} |
| 153 | transition={{ duration: 0.25, ease: 'easeOut' }} |
| 154 | /> |
| 155 | </svg> |
| 156 | </span> |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | /** Chevron pointing left/right — used by the sidebar collapse toggle. */ |
| 161 | export function ChevronIcon({ |
| 162 | className, |
| 163 | direction, |
| 164 | ...rest |
| 165 | }: IconProps & { direction: 'left' | 'right' } & MotionProps) { |
| 166 | const rotate = direction === 'left' ? 180 : 0; |
| 167 | return ( |
| 168 | <motion.svg |
| 169 | {...iconBase} |
| 170 | className={className} |
| 171 | animate={{ rotate }} |
| 172 | transition={{ type: 'spring', stiffness: 300, damping: 22 }} |
| 173 | {...rest} |
| 174 | > |
| 175 | <path d="m9 18 6-6-6-6" /> |
| 176 | </motion.svg> |
| 177 | ); |
| 178 | } |