Skip to content

Commit b07bc34

Browse files
committed
improve gradient animation
1 parent e479403 commit b07bc34

File tree

2 files changed

+72
-65
lines changed

2 files changed

+72
-65
lines changed

src/GradientHover.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ The component supports multiple color stops. The more colors added, the higher t
3030
<GradientHover
3131
colors={["#E95A45", "#FCF2C1", "#539C99"]}
3232
style={{ marginBottom: 10 }}
33+
animationSpeed={8}
3334
>
3435
<div style={{ padding: 20 }}>Three-color gradient</div>
3536
</GradientHover>
@@ -42,8 +43,8 @@ Use the controls below to dynamically experiment with all the component props:
4243
```jsx
4344
function InteractiveDemo() {
4445
const [colors, setColors] = React.useState(["#0dc3e7", "#fc42ff", "#E6FB46"]);
45-
const [animationSpeed, setAnimationSpeed] = React.useState(5);
46-
const [transitionDuration, setTransitionDuration] = React.useState(1);
46+
const [animationSpeed, setAnimationSpeed] = React.useState(9);
47+
const [transitionDuration, setTransitionDuration] = React.useState(3);
4748
const [shouldAlwaysShowGradient, setShouldAlwaysShowGradient] =
4849
React.useState(true);
4950

@@ -108,7 +109,7 @@ function InteractiveDemo() {
108109
</label>
109110
<input
110111
type="range"
111-
min="0"
112+
min="1"
112113
max="10"
113114
step="1"
114115
value={animationSpeed}

src/GradientHover.tsx

Lines changed: 68 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const GradientHover: React.FC<GradientHoverProps> = ({
2222
className = "",
2323
style = {},
2424
onClick,
25-
animationSpeed = 3,
25+
animationSpeed = 5,
2626
transitionDuration = 1,
2727
shouldAlwaysShowGradient = true,
2828
}) => {
@@ -35,26 +35,27 @@ const GradientHover: React.FC<GradientHoverProps> = ({
3535
{ top: number; left: number; width: number; height: number } | undefined
3636
>(undefined);
3737

38-
// Animation state for smooth trailing
38+
// Animation state
3939
const targetPosition = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
4040
const currentPosition = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
4141
const animationFrame = useRef<number | undefined>(undefined);
4242
const isAnimatingToCenter = useRef(false);
4343
const isAnimationActive = useRef(false);
44+
const lastTime = useRef<number>(0);
4445

4546
// Ensure we have at least 2 colors for the gradient
4647
const validColors = colors && colors.length >= 2 ? colors : DEFAULT_COLORS;
4748

48-
// Convert user-friendly speed (1-10) to internal speed (0.01-0.1)
49-
const internalAnimationSpeed =
50-
Math.max(1, Math.min(10, animationSpeed)) * 0.01;
49+
// Calculate smoothing speed using an exponential curve
50+
// This gives us a nice progression from very slow to fast
51+
const smoothingSpeed = 0.00025 * Math.pow(animationSpeed, 1.3);
5152

52-
// Generate gradient string for CSS
53+
// Generate gradient string
5354
const generateGradient = (x: string, y: string) => {
5455
return `radial-gradient(circle at ${x} ${y}, ${validColors.join(", ")})`;
5556
};
5657

57-
// Generate CSS variables for all gradient stops
58+
// CSS variables for gradient
5859
const colorStyles = {
5960
"--gradient-colors": validColors.join(", "),
6061
"--gradient-stop-last": validColors[validColors.length - 1],
@@ -74,7 +75,7 @@ const GradientHover: React.FC<GradientHoverProps> = ({
7475
} as CSSProperties;
7576
}
7677

77-
// Helper function to get fresh bounds
78+
// Get fresh bounds for accurate positioning
7879
const getFreshBounds = useCallback(() => {
7980
if (ref.current) {
8081
const rect = ref.current.getBoundingClientRect();
@@ -92,41 +93,55 @@ const GradientHover: React.FC<GradientHoverProps> = ({
9293
return null;
9394
}, []);
9495

95-
// Continuous animation function - runs while hovering or animating to center
96-
const animate = useCallback(() => {
97-
if (!isAnimationActive.current) {
98-
return;
99-
}
100-
101-
const distX = targetPosition.current.x - currentPosition.current.x;
102-
const distY = targetPosition.current.y - currentPosition.current.y;
96+
// Animation loop using exponential smoothing
97+
const animate = useCallback(
98+
(currentTime: number) => {
99+
if (!isAnimationActive.current) {
100+
return;
101+
}
103102

104-
// Check if we're close enough to the target to stop animating to center
105-
if (
106-
isAnimatingToCenter.current &&
107-
Math.abs(distX) < 1 &&
108-
Math.abs(distY) < 1
109-
) {
110-
isAnimatingToCenter.current = false;
111-
isAnimationActive.current = false;
112-
setMousePosition(undefined);
113-
if (animationFrame.current) {
114-
cancelAnimationFrame(animationFrame.current);
115-
animationFrame.current = undefined;
103+
// Calculate delta time for frame-independent animation
104+
const deltaTime = lastTime.current
105+
? Math.min((currentTime - lastTime.current) / 16.67, 2)
106+
: 1;
107+
lastTime.current = currentTime;
108+
109+
const distX = targetPosition.current.x - currentPosition.current.x;
110+
const distY = targetPosition.current.y - currentPosition.current.y;
111+
const distance = Math.sqrt(distX * distX + distY * distY);
112+
113+
// Stop animating when close to center
114+
if (distance < 0.5 && isAnimatingToCenter.current) {
115+
isAnimatingToCenter.current = false;
116+
isAnimationActive.current = false;
117+
setMousePosition(undefined);
118+
if (animationFrame.current) {
119+
cancelAnimationFrame(animationFrame.current);
120+
animationFrame.current = undefined;
121+
}
122+
return;
116123
}
117-
return;
118-
}
119124

120-
currentPosition.current.x += distX * internalAnimationSpeed;
121-
currentPosition.current.y += distY * internalAnimationSpeed;
125+
// Use faster speed when returning to center
126+
const effectiveSpeed = isAnimatingToCenter.current
127+
? Math.max(smoothingSpeed * 3, 0.003)
128+
: smoothingSpeed;
122129

123-
setMousePosition({
124-
x: currentPosition.current.x,
125-
y: currentPosition.current.y,
126-
});
130+
const smoothingFactor = 1 - Math.pow(1 - effectiveSpeed * 10, deltaTime);
127131

128-
animationFrame.current = requestAnimationFrame(animate);
129-
}, [internalAnimationSpeed]);
132+
// Move toward target by a fraction of the distance
133+
currentPosition.current.x += distX * smoothingFactor;
134+
currentPosition.current.y += distY * smoothingFactor;
135+
136+
setMousePosition({
137+
x: currentPosition.current.x,
138+
y: currentPosition.current.y,
139+
});
140+
141+
animationFrame.current = requestAnimationFrame(animate);
142+
},
143+
[smoothingSpeed, isAnimatingToCenter]
144+
);
130145

131146
// Start/stop animation based on hover state
132147
useEffect(() => {
@@ -135,6 +150,7 @@ const GradientHover: React.FC<GradientHoverProps> = ({
135150
!animationFrame.current
136151
) {
137152
isAnimationActive.current = true;
153+
lastTime.current = 0;
138154
animationFrame.current = requestAnimationFrame(animate);
139155
} else if (
140156
!isHovering &&
@@ -147,6 +163,7 @@ const GradientHover: React.FC<GradientHoverProps> = ({
147163
}
148164
}, [isHovering, animate]);
149165

166+
// Debounced bounds updater
150167
const debouncedStoreElementBounds = useRef(
151168
debounce((element: HTMLElement) => {
152169
if (element) {
@@ -161,19 +178,15 @@ const GradientHover: React.FC<GradientHoverProps> = ({
161178
}, 100)
162179
);
163180

164-
// Update bounds on various events
181+
// Update bounds on resize/scroll
165182
useEffect(() => {
166183
const nodeEl = ref.current;
167-
if (!nodeEl) {
168-
return;
169-
}
184+
if (!nodeEl) return;
170185

171186
const updateBounds = () => debouncedStoreElementBounds.current(nodeEl);
172187

173-
// Initial bounds calculation
174188
updateBounds();
175189

176-
// Listen to various events that can change element position
177190
window.addEventListener("resize", updateBounds);
178191
window.addEventListener("scroll", updateBounds, { passive: true });
179192
window.addEventListener("orientationchange", updateBounds);
@@ -185,18 +198,16 @@ const GradientHover: React.FC<GradientHoverProps> = ({
185198
};
186199
}, []);
187200

188-
// Intersection observer for additional bounds updates
201+
// Intersection observer for visibility changes
189202
useEffect(() => {
190203
const nodeEl = ref.current;
191-
if (!nodeEl) {
192-
return;
193-
}
204+
if (!nodeEl) return;
205+
194206
const fn = () => debouncedStoreElementBounds.current(nodeEl);
195207
const observer = new IntersectionObserver(fn, { threshold: 0.1 });
196208
observer.observe(nodeEl);
197-
return () => {
198-
observer.disconnect();
199-
};
209+
210+
return () => observer.disconnect();
200211
}, []);
201212

202213
// Cleanup on unmount
@@ -215,7 +226,6 @@ const GradientHover: React.FC<GradientHoverProps> = ({
215226

216227
const handleMouseMove = (event: MouseEvent<HTMLElement>) => {
217228
const { clientX, clientY } = event;
218-
// Get fresh bounds to ensure accuracy
219229
const freshBounds = getFreshBounds();
220230
if (freshBounds) {
221231
targetPosition.current = {
@@ -236,10 +246,8 @@ const GradientHover: React.FC<GradientHoverProps> = ({
236246
setIsHovering(true);
237247

238248
const { clientX, clientY } = event;
239-
// Get fresh bounds to ensure accuracy
240249
const freshBounds = getFreshBounds();
241250
if (freshBounds) {
242-
// Update stored bounds
243251
setBounds({
244252
top: freshBounds.viewportTop,
245253
left: freshBounds.viewportLeft,
@@ -252,25 +260,22 @@ const GradientHover: React.FC<GradientHoverProps> = ({
252260
y: clientY - freshBounds.viewportTop,
253261
};
254262

255-
// Set the target position to the cursor
256263
targetPosition.current = targetPos;
257264

258-
// If we don't have a current position yet or if we're starting fresh,
259-
// start from center to ensure smooth animation
265+
// Start from center if no current position
260266
if (currentPosition.current.x === 0 && currentPosition.current.y === 0) {
261267
currentPosition.current = {
262268
x: freshBounds.width / 2,
263269
y: freshBounds.height / 2,
264270
};
265-
// Set initial mouse position to center as well
266271
setMousePosition({
267272
x: freshBounds.width / 2,
268273
y: freshBounds.height / 2,
269274
});
270275
}
271276

272-
// Start the animation to smoothly move to the target position
273277
isAnimationActive.current = true;
278+
lastTime.current = 0;
274279
if (!animationFrame.current) {
275280
animationFrame.current = requestAnimationFrame(animate);
276281
}
@@ -281,26 +286,27 @@ const GradientHover: React.FC<GradientHoverProps> = ({
281286
setIsHovering(false);
282287
const freshBounds = getFreshBounds();
283288
if (freshBounds) {
284-
// Update stored bounds
285289
setBounds({
286290
top: freshBounds.viewportTop,
287291
left: freshBounds.viewportLeft,
288292
width: freshBounds.width,
289293
height: freshBounds.height,
290294
});
291295

292-
// Set target to center and start animation back to center
296+
// Animate back to center
293297
targetPosition.current = {
294298
x: freshBounds.width / 2,
295299
y: freshBounds.height / 2,
296300
};
297301
isAnimatingToCenter.current = true;
298302
isAnimationActive.current = true;
303+
lastTime.current = 0;
304+
299305
if (!animationFrame.current) {
300306
animationFrame.current = requestAnimationFrame(animate);
301307
}
302308
} else {
303-
// Fallback if no bounds - just stop immediately
309+
// Fallback - stop immediately
304310
setMousePosition(undefined);
305311
if (animationFrame.current) {
306312
cancelAnimationFrame(animationFrame.current);

0 commit comments

Comments
 (0)