rocket-cursor-component 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -14
- package/dist/rocket.Cursor.d.ts +1 -2
- package/dist/rocket.Cursor.js +98 -62
- package/package.json +12 -8
package/README.md
CHANGED
|
@@ -24,23 +24,22 @@ Here's an example of how to use the `RocketCursor` component in your React app:
|
|
|
24
24
|
|
|
25
25
|
```tsx
|
|
26
26
|
import React from "react";
|
|
27
|
-
import RocketCursor from "rocket-cursor-component";
|
|
27
|
+
import RocketCursor from "rocket-cursor-component";
|
|
28
28
|
|
|
29
29
|
function App() {
|
|
30
30
|
return (
|
|
31
31
|
<div>
|
|
32
32
|
<h1>Your app content here</h1>
|
|
33
|
-
{/* Basic usage -
|
|
33
|
+
{/* Basic usage - rocket replaces cursor */}
|
|
34
34
|
<RocketCursor />
|
|
35
|
-
|
|
36
|
-
{/*
|
|
37
|
-
<RocketCursor
|
|
38
|
-
size={60}
|
|
39
|
-
threshold={
|
|
40
|
-
flameHideTimeout={
|
|
41
|
-
hideCursor={false}
|
|
42
|
-
|
|
43
|
-
offsetY={0}
|
|
35
|
+
|
|
36
|
+
{/* Tuned usage - visible system cursor, snappier follow */}
|
|
37
|
+
<RocketCursor
|
|
38
|
+
size={60}
|
|
39
|
+
threshold={12}
|
|
40
|
+
flameHideTimeout={250}
|
|
41
|
+
hideCursor={false} // keep native cursor visible
|
|
42
|
+
followSpeed={0.35} // 0-1, higher = snappier
|
|
44
43
|
/>
|
|
45
44
|
</div>
|
|
46
45
|
);
|
|
@@ -58,14 +57,13 @@ export default App;
|
|
|
58
57
|
| `isVisible` | boolean | `true` | Initial visibility state of the rocket cursor. |
|
|
59
58
|
| `flameHideTimeout` | number | `300` | Time in milliseconds before the flame hides after stopping.|
|
|
60
59
|
| `hideCursor` | boolean | `false` | Whether to hide the normal cursor (true) or show both. |
|
|
61
|
-
| `
|
|
62
|
-
| `offsetY` | number | `0` | Vertical offset in pixels from cursor position. |
|
|
60
|
+
| `followSpeed` | number | `0.18` | Follow smoothing (0-1). Higher = faster/snappier following. |
|
|
63
61
|
|
|
64
62
|
## Features
|
|
65
63
|
|
|
66
64
|
- **React 19+ Optimized**: Built specifically for React 19+ with latest performance optimizations
|
|
67
65
|
- **Dual Cursor Mode**: Choose to replace cursor completely or show rocket alongside normal cursor
|
|
68
|
-
- **Custom Cursor**: Replaces the default mouse cursor with a rocket that follows the cursor
|
|
66
|
+
- **Custom Cursor**: Replaces the default mouse cursor with a rocket that follows the cursor and aligns its nose to the pointer
|
|
69
67
|
- **Smart Rotation**: The rocket rotates in the direction of cursor movement with configurable threshold
|
|
70
68
|
- **Flame Effect**: Dynamic flame animation when the cursor is moving
|
|
71
69
|
- **Collision-Free**: Uses React 19's `useId()` to prevent SVG gradient ID collisions
|
|
@@ -80,8 +78,18 @@ Here's a demo of the Rocket Cursor in action:
|
|
|
80
78
|
|
|
81
79
|

|
|
82
80
|
|
|
81
|
+
> Local demo (not published to npm): run `npm install` and `npm run dev`, then open the Vite dev server printed in the console.
|
|
82
|
+
|
|
83
83
|
## Changelog
|
|
84
84
|
|
|
85
|
+
### 2.1.0
|
|
86
|
+
- **NEW**: Added `followSpeed` prop for configurable smoothing (nose snaps to cursor when close)
|
|
87
|
+
- **Changed**: Rocket aligns by its nose to the cursor position (manual offsets removed)
|
|
88
|
+
- **Changed**: Demo cleaned up to match the new API (no offset sliders)
|
|
89
|
+
|
|
90
|
+
### 2.1.1
|
|
91
|
+
- **Fixed**: Flame visibility now updates reliably
|
|
92
|
+
|
|
85
93
|
### 2.0.0 (React 19+ Only)
|
|
86
94
|
- **BREAKING**: Now requires React 19.0.0 or higher
|
|
87
95
|
- **NEW**: Added `useId()` for unique SVG gradient IDs (prevents collisions)
|
package/dist/rocket.Cursor.d.ts
CHANGED
package/dist/rocket.Cursor.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useId, useMemo, useCallback,
|
|
1
|
+
import React, { useEffect, useRef, useId, useMemo, useCallback, useState, } from "react";
|
|
2
2
|
// SVG components for flame and rocket visuals
|
|
3
3
|
const FlameSvg = ({ gradientId }) => (React.createElement("g", { transform: "translate(1.2245, 350.449) rotate(45) scale(0.5, -0.5)" },
|
|
4
4
|
React.createElement("defs", null,
|
|
@@ -18,99 +18,131 @@ const RocketSvg = () => (React.createElement("g", { transform: "translate(0, 0)"
|
|
|
18
18
|
React.createElement("path", { style: { fill: "#F2D59F" }, d: "M177.35,106.509l-87.14,101.42l-66.33-16.88c7.24-12.49,16.21-24.24,26.89-34.93 C85.58,121.309,131.74,104.769,177.35,106.509z" }),
|
|
19
19
|
React.createElement("polygon", { style: { fill: "#E6B263" }, points: "149.37,267.089 109.72,306.739 89.3,286.309 119.79,237.509" }),
|
|
20
20
|
React.createElement("path", { style: { fill: "#E6B263" }, d: "M119.79,237.509l-30.49,48.8l-6.86-6.85c-8.27-8.28-10.98-20.6-6.94-31.58l14.71-39.95 L119.79,237.509z" })));
|
|
21
|
-
const RocketCursor = ({ size = 50, threshold = 10, flameHideTimeout = 300, isVisible = true, hideCursor = false,
|
|
22
|
-
|
|
21
|
+
const RocketCursor = ({ size = 50, threshold = 10, flameHideTimeout = 300, isVisible = true, hideCursor = false, followSpeed = 0.18, // default smoothing similar to původní chování
|
|
22
|
+
}) => {
|
|
23
23
|
const gradientId = useId();
|
|
24
24
|
const wrapperRef = useRef(null);
|
|
25
25
|
const flameRef = useRef(null);
|
|
26
26
|
const target = useRef({
|
|
27
|
-
x: typeof window !==
|
|
28
|
-
y: typeof window !==
|
|
27
|
+
x: typeof window !== "undefined" ? window.innerWidth / 2 : 0,
|
|
28
|
+
y: typeof window !== "undefined" ? window.innerHeight / 2 : 0,
|
|
29
29
|
});
|
|
30
|
-
const current = useRef({
|
|
30
|
+
const current = useRef(Object.assign({}, target.current));
|
|
31
31
|
const angleRef = useRef(0);
|
|
32
32
|
const lastMoveTs = useRef(Date.now());
|
|
33
|
-
const lastSignificantPosition = useRef({ x: target.current.x, y: target.current.y });
|
|
34
33
|
const rafRef = useRef(null);
|
|
35
|
-
const
|
|
34
|
+
const [isMoving, setIsMoving] = useState(false);
|
|
35
|
+
const isMovingRef = useRef(false);
|
|
36
|
+
const [visible, setVisible] = useState(isVisible);
|
|
37
|
+
const lastSignificantPosition = useRef(Object.assign({}, target.current));
|
|
38
|
+
const flameTimeoutRef = useRef(null);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setVisible(isVisible);
|
|
41
|
+
}, [isVisible]);
|
|
36
42
|
useEffect(() => {
|
|
37
|
-
visibleRef.current = isVisible;
|
|
38
|
-
if (wrapperRef.current)
|
|
39
|
-
wrapperRef.current.style.display = isVisible ? "block" : "none";
|
|
40
|
-
// Apply cursor hiding to body if requested
|
|
41
43
|
if (hideCursor) {
|
|
42
|
-
document.body.style.cursor =
|
|
44
|
+
document.body.style.cursor = "none";
|
|
43
45
|
}
|
|
44
46
|
else {
|
|
45
|
-
document.body.style.cursor =
|
|
47
|
+
document.body.style.cursor = "";
|
|
46
48
|
}
|
|
47
49
|
return () => {
|
|
48
|
-
|
|
49
|
-
document.body.style.cursor = '';
|
|
50
|
+
document.body.style.cursor = "";
|
|
50
51
|
};
|
|
51
|
-
}, [
|
|
52
|
+
}, [hideCursor]);
|
|
53
|
+
const handleMouseMove = useCallback((e) => {
|
|
54
|
+
const t = e.target;
|
|
55
|
+
const exclude = t && t.closest && t.closest(".no-rocket-cursor");
|
|
56
|
+
const shouldShow = !exclude && isVisible;
|
|
57
|
+
setVisible(shouldShow);
|
|
58
|
+
if (!shouldShow)
|
|
59
|
+
return;
|
|
60
|
+
target.current.x = e.clientX;
|
|
61
|
+
target.current.y = e.clientY;
|
|
62
|
+
lastMoveTs.current = Date.now();
|
|
63
|
+
const dx = target.current.x - lastSignificantPosition.current.x;
|
|
64
|
+
const dy = target.current.y - lastSignificantPosition.current.y;
|
|
65
|
+
const distance = Math.hypot(dx, dy);
|
|
66
|
+
if (distance > threshold) {
|
|
67
|
+
angleRef.current = Math.atan2(dy, dx) * (180 / Math.PI) + 45;
|
|
68
|
+
lastSignificantPosition.current = {
|
|
69
|
+
x: target.current.x,
|
|
70
|
+
y: target.current.y,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
setIsMoving(true);
|
|
74
|
+
isMovingRef.current = true;
|
|
75
|
+
if (flameTimeoutRef.current) {
|
|
76
|
+
window.clearTimeout(flameTimeoutRef.current);
|
|
77
|
+
}
|
|
78
|
+
flameTimeoutRef.current = window.setTimeout(() => {
|
|
79
|
+
setIsMoving(false);
|
|
80
|
+
isMovingRef.current = false;
|
|
81
|
+
}, flameHideTimeout);
|
|
82
|
+
}, [threshold, flameHideTimeout, isVisible]);
|
|
83
|
+
const handleMouseOut = useCallback((e) => {
|
|
84
|
+
const rel = e.relatedTarget;
|
|
85
|
+
if (!rel || rel.nodeName === "HTML") {
|
|
86
|
+
setVisible(false);
|
|
87
|
+
setIsMoving(false);
|
|
88
|
+
isMovingRef.current = false;
|
|
89
|
+
}
|
|
90
|
+
}, []);
|
|
91
|
+
const handleVisibilityChange = useCallback(() => {
|
|
92
|
+
if (document.visibilityState === "visible") {
|
|
93
|
+
setVisible(true);
|
|
94
|
+
}
|
|
95
|
+
}, []);
|
|
52
96
|
useEffect(() => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
lastMoveTs.current = Date.now();
|
|
66
|
-
// Update angle based on movement direction from previous position
|
|
67
|
-
const moveDx = target.current.x - lastSignificantPosition.current.x;
|
|
68
|
-
const moveDy = target.current.y - lastSignificantPosition.current.y;
|
|
69
|
-
const moveDist = Math.hypot(moveDx, moveDy);
|
|
70
|
-
if (moveDist > threshold) {
|
|
71
|
-
startTransition(() => {
|
|
72
|
-
angleRef.current = Math.atan2(moveDy, moveDx) * (180 / Math.PI) + 45;
|
|
73
|
-
lastSignificantPosition.current = { x: target.current.x, y: target.current.y };
|
|
74
|
-
});
|
|
97
|
+
window.addEventListener("mousemove", handleMouseMove, { passive: true });
|
|
98
|
+
document.addEventListener("mouseout", handleMouseOut);
|
|
99
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
100
|
+
const step = () => {
|
|
101
|
+
const lerp = Math.min(Math.max(followSpeed, 0), 1);
|
|
102
|
+
const dx = target.current.x - current.current.x;
|
|
103
|
+
const dy = target.current.y - current.current.y;
|
|
104
|
+
const distanceToTarget = Math.hypot(dx, dy);
|
|
105
|
+
// Snap to cursor when we are very close to avoid asymptotic lag/overshoot feeling
|
|
106
|
+
if (distanceToTarget < 0.5) {
|
|
107
|
+
current.current.x = target.current.x;
|
|
108
|
+
current.current.y = target.current.y;
|
|
75
109
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (!rel || rel.nodeName === "HTML") {
|
|
80
|
-
if (wrapperRef.current)
|
|
81
|
-
wrapperRef.current.style.display = "none";
|
|
110
|
+
else {
|
|
111
|
+
current.current.x += dx * lerp;
|
|
112
|
+
current.current.y += dy * lerp;
|
|
82
113
|
}
|
|
83
|
-
}, []);
|
|
84
|
-
window.addEventListener("mousemove", onMouseMove, { passive: true });
|
|
85
|
-
document.addEventListener("mouseout", onMouseOut);
|
|
86
|
-
const step = () => {
|
|
87
|
-
// Move rocket directly to cursor position (no easing)
|
|
88
|
-
current.current.x = target.current.x;
|
|
89
|
-
current.current.y = target.current.y;
|
|
90
|
-
// Show flame based on recent mouse movement
|
|
91
114
|
const showFlame = Date.now() - lastMoveTs.current < flameHideTimeout;
|
|
92
115
|
const el = wrapperRef.current;
|
|
93
116
|
if (el) {
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
const
|
|
117
|
+
// Shift rocket so the nose (not the center) meets the cursor
|
|
118
|
+
const dirRad = (angleRef.current - 45) * (Math.PI / 180); // remove the art's 45° offset
|
|
119
|
+
const noseOffset = size * 0.35; // distance from center to nose, scaled with size
|
|
120
|
+
const noseX = Math.cos(dirRad) * noseOffset;
|
|
121
|
+
const noseY = Math.sin(dirRad) * noseOffset;
|
|
122
|
+
el.style.transform = `translate3d(${current.current.x - noseX}px, ${current.current.y - noseY}px, 0) translate(-50%, -50%)`;
|
|
123
|
+
const svg = el.querySelector("svg");
|
|
97
124
|
if (svg) {
|
|
98
|
-
svg.style.transform = `
|
|
125
|
+
svg.style.transform = `rotate(${angleRef.current}deg)`;
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
128
|
if (flameRef.current) {
|
|
102
|
-
flameRef.current.style.opacity =
|
|
129
|
+
flameRef.current.style.opacity =
|
|
130
|
+
showFlame && isMovingRef.current ? "1" : "0";
|
|
103
131
|
}
|
|
104
132
|
rafRef.current = requestAnimationFrame(step);
|
|
105
133
|
};
|
|
106
134
|
rafRef.current = requestAnimationFrame(step);
|
|
107
135
|
return () => {
|
|
108
|
-
window.removeEventListener("mousemove",
|
|
109
|
-
document.removeEventListener("mouseout",
|
|
136
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
137
|
+
document.removeEventListener("mouseout", handleMouseOut);
|
|
138
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
110
139
|
if (rafRef.current)
|
|
111
140
|
cancelAnimationFrame(rafRef.current);
|
|
141
|
+
if (flameTimeoutRef.current) {
|
|
142
|
+
window.clearTimeout(flameTimeoutRef.current);
|
|
143
|
+
}
|
|
112
144
|
};
|
|
113
|
-
}, [
|
|
145
|
+
}, [handleMouseMove, handleMouseOut, handleVisibilityChange]);
|
|
114
146
|
const wrapperStyle = useMemo(() => ({
|
|
115
147
|
position: "fixed",
|
|
116
148
|
left: 0,
|
|
@@ -120,12 +152,16 @@ offsetY = 0, }) => {
|
|
|
120
152
|
width: `${size}px`,
|
|
121
153
|
height: `${size * 1.5}px`,
|
|
122
154
|
willChange: "transform",
|
|
123
|
-
|
|
155
|
+
display: visible ? "block" : "none",
|
|
156
|
+
}), [size, visible]);
|
|
124
157
|
const svgStyle = useMemo(() => ({
|
|
125
158
|
width: "100%",
|
|
126
159
|
height: "100%",
|
|
127
160
|
display: "block",
|
|
128
161
|
}), []);
|
|
162
|
+
if (!visible) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
129
165
|
return (React.createElement("div", { ref: wrapperRef, style: wrapperStyle },
|
|
130
166
|
React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 416.449 516.449", style: svgStyle },
|
|
131
167
|
React.createElement("g", { ref: flameRef },
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rocket-cursor-component",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "A customizable React component that replaces the cursor with an animated rocket, featuring rotation and flame effects.",
|
|
5
5
|
"main": "dist/rocket.Cursor.js",
|
|
6
6
|
"types": "dist/rocket.Cursor.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "tsc",
|
|
9
|
-
"prepublishOnly": "npm run build"
|
|
9
|
+
"prepublishOnly": "npm run build",
|
|
10
|
+
"dev": "vite",
|
|
11
|
+
"demo": "vite"
|
|
10
12
|
},
|
|
11
13
|
"files": [
|
|
12
14
|
"dist",
|
|
@@ -32,12 +34,14 @@
|
|
|
32
34
|
"react-dom": ">=19.0.0"
|
|
33
35
|
},
|
|
34
36
|
"devDependencies": {
|
|
35
|
-
"@types/node": "^
|
|
36
|
-
"@types/react": "^19.
|
|
37
|
-
"@types/react-dom": "^19.
|
|
38
|
-
"react": "^
|
|
39
|
-
"react
|
|
40
|
-
"
|
|
37
|
+
"@types/node": "^24.10.1",
|
|
38
|
+
"@types/react": "^19.2.7",
|
|
39
|
+
"@types/react-dom": "^19.2.3",
|
|
40
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
41
|
+
"react": "^19.2.1",
|
|
42
|
+
"react-dom": "^19.2.1",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"vite": "^7.2.6"
|
|
41
45
|
},
|
|
42
46
|
"engines": {
|
|
43
47
|
"node": ">=16.0.0"
|