animated-icons.tsx178 lines · main
1'use client';
2
3import { motion, type MotionProps } from 'motion/react';
4import { 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
16interface 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
23function 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
32const 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
42function 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. */
64export 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. */
89export 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. */
110export 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 */
139export 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. */
161export 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}