rocket-cursor-component 2.0.0 → 2.1.0
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 +19 -14
- package/dist/rocket.Cursor.d.ts +1 -2
- package/dist/rocket.Cursor.js +91 -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,15 @@ 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
|
+
|
|
85
90
|
### 2.0.0 (React 19+ Only)
|
|
86
91
|
- **BREAKING**: Now requires React 19.0.0 or higher
|
|
87
92
|
- **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,124 @@ 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 [visible, setVisible] = useState(isVisible);
|
|
36
|
+
const lastSignificantPosition = useRef(Object.assign({}, target.current));
|
|
37
|
+
const flameTimeoutRef = useRef(null);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setVisible(isVisible);
|
|
40
|
+
}, [isVisible]);
|
|
36
41
|
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
42
|
if (hideCursor) {
|
|
42
|
-
document.body.style.cursor =
|
|
43
|
+
document.body.style.cursor = "none";
|
|
43
44
|
}
|
|
44
45
|
else {
|
|
45
|
-
document.body.style.cursor =
|
|
46
|
+
document.body.style.cursor = "";
|
|
46
47
|
}
|
|
47
48
|
return () => {
|
|
48
|
-
|
|
49
|
-
document.body.style.cursor = '';
|
|
49
|
+
document.body.style.cursor = "";
|
|
50
50
|
};
|
|
51
|
-
}, [
|
|
51
|
+
}, [hideCursor]);
|
|
52
|
+
const handleMouseMove = useCallback((e) => {
|
|
53
|
+
const t = e.target;
|
|
54
|
+
const exclude = t && t.closest && t.closest(".no-rocket-cursor");
|
|
55
|
+
const shouldShow = !exclude && isVisible;
|
|
56
|
+
setVisible(shouldShow);
|
|
57
|
+
if (!shouldShow)
|
|
58
|
+
return;
|
|
59
|
+
target.current.x = e.clientX;
|
|
60
|
+
target.current.y = e.clientY;
|
|
61
|
+
lastMoveTs.current = Date.now();
|
|
62
|
+
const dx = target.current.x - lastSignificantPosition.current.x;
|
|
63
|
+
const dy = target.current.y - lastSignificantPosition.current.y;
|
|
64
|
+
const distance = Math.hypot(dx, dy);
|
|
65
|
+
if (distance > threshold) {
|
|
66
|
+
angleRef.current = Math.atan2(dy, dx) * (180 / Math.PI) + 45;
|
|
67
|
+
lastSignificantPosition.current = {
|
|
68
|
+
x: target.current.x,
|
|
69
|
+
y: target.current.y,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
setIsMoving(true);
|
|
73
|
+
if (flameTimeoutRef.current) {
|
|
74
|
+
window.clearTimeout(flameTimeoutRef.current);
|
|
75
|
+
}
|
|
76
|
+
flameTimeoutRef.current = window.setTimeout(() => setIsMoving(false), flameHideTimeout);
|
|
77
|
+
}, [threshold, flameHideTimeout, isVisible]);
|
|
78
|
+
const handleMouseOut = useCallback((e) => {
|
|
79
|
+
const rel = e.relatedTarget;
|
|
80
|
+
if (!rel || rel.nodeName === "HTML") {
|
|
81
|
+
setVisible(false);
|
|
82
|
+
setIsMoving(false);
|
|
83
|
+
}
|
|
84
|
+
}, []);
|
|
85
|
+
const handleVisibilityChange = useCallback(() => {
|
|
86
|
+
if (document.visibilityState === "visible") {
|
|
87
|
+
setVisible(true);
|
|
88
|
+
}
|
|
89
|
+
}, []);
|
|
52
90
|
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
|
-
});
|
|
91
|
+
window.addEventListener("mousemove", handleMouseMove, { passive: true });
|
|
92
|
+
document.addEventListener("mouseout", handleMouseOut);
|
|
93
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
94
|
+
const step = () => {
|
|
95
|
+
const lerp = Math.min(Math.max(followSpeed, 0), 1);
|
|
96
|
+
const dx = target.current.x - current.current.x;
|
|
97
|
+
const dy = target.current.y - current.current.y;
|
|
98
|
+
const distanceToTarget = Math.hypot(dx, dy);
|
|
99
|
+
// Snap to cursor when we are very close to avoid asymptotic lag/overshoot feeling
|
|
100
|
+
if (distanceToTarget < 0.5) {
|
|
101
|
+
current.current.x = target.current.x;
|
|
102
|
+
current.current.y = target.current.y;
|
|
75
103
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (!rel || rel.nodeName === "HTML") {
|
|
80
|
-
if (wrapperRef.current)
|
|
81
|
-
wrapperRef.current.style.display = "none";
|
|
104
|
+
else {
|
|
105
|
+
current.current.x += dx * lerp;
|
|
106
|
+
current.current.y += dy * lerp;
|
|
82
107
|
}
|
|
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
108
|
const showFlame = Date.now() - lastMoveTs.current < flameHideTimeout;
|
|
92
109
|
const el = wrapperRef.current;
|
|
93
110
|
if (el) {
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
const
|
|
111
|
+
// Shift rocket so the nose (not the center) meets the cursor
|
|
112
|
+
const dirRad = (angleRef.current - 45) * (Math.PI / 180); // remove the art's 45° offset
|
|
113
|
+
const noseOffset = size * 0.35; // distance from center to nose, scaled with size
|
|
114
|
+
const noseX = Math.cos(dirRad) * noseOffset;
|
|
115
|
+
const noseY = Math.sin(dirRad) * noseOffset;
|
|
116
|
+
el.style.transform = `translate3d(${current.current.x - noseX}px, ${current.current.y - noseY}px, 0) translate(-50%, -50%)`;
|
|
117
|
+
const svg = el.querySelector("svg");
|
|
97
118
|
if (svg) {
|
|
98
|
-
svg.style.transform = `
|
|
119
|
+
svg.style.transform = `rotate(${angleRef.current}deg)`;
|
|
99
120
|
}
|
|
100
121
|
}
|
|
101
122
|
if (flameRef.current) {
|
|
102
|
-
flameRef.current.style.opacity = showFlame ? "1" : "0";
|
|
123
|
+
flameRef.current.style.opacity = showFlame && isMoving ? "1" : "0";
|
|
103
124
|
}
|
|
104
125
|
rafRef.current = requestAnimationFrame(step);
|
|
105
126
|
};
|
|
106
127
|
rafRef.current = requestAnimationFrame(step);
|
|
107
128
|
return () => {
|
|
108
|
-
window.removeEventListener("mousemove",
|
|
109
|
-
document.removeEventListener("mouseout",
|
|
129
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
130
|
+
document.removeEventListener("mouseout", handleMouseOut);
|
|
131
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
110
132
|
if (rafRef.current)
|
|
111
133
|
cancelAnimationFrame(rafRef.current);
|
|
134
|
+
if (flameTimeoutRef.current) {
|
|
135
|
+
window.clearTimeout(flameTimeoutRef.current);
|
|
136
|
+
}
|
|
112
137
|
};
|
|
113
|
-
}, [
|
|
138
|
+
}, [handleMouseMove, handleMouseOut, handleVisibilityChange]);
|
|
114
139
|
const wrapperStyle = useMemo(() => ({
|
|
115
140
|
position: "fixed",
|
|
116
141
|
left: 0,
|
|
@@ -120,12 +145,16 @@ offsetY = 0, }) => {
|
|
|
120
145
|
width: `${size}px`,
|
|
121
146
|
height: `${size * 1.5}px`,
|
|
122
147
|
willChange: "transform",
|
|
123
|
-
|
|
148
|
+
display: visible ? "block" : "none",
|
|
149
|
+
}), [size, visible]);
|
|
124
150
|
const svgStyle = useMemo(() => ({
|
|
125
151
|
width: "100%",
|
|
126
152
|
height: "100%",
|
|
127
153
|
display: "block",
|
|
128
154
|
}), []);
|
|
155
|
+
if (!visible) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
129
158
|
return (React.createElement("div", { ref: wrapperRef, style: wrapperStyle },
|
|
130
159
|
React.createElement("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 416.449 516.449", style: svgStyle },
|
|
131
160
|
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.0",
|
|
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"
|