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 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 - cursor replaced with rocket */}
33
+ {/* Basic usage - rocket replaces cursor */}
34
34
  <RocketCursor />
35
-
36
- {/* Advanced usage - rocket centered on cursor with normal cursor visible */}
37
- <RocketCursor
38
- size={60}
39
- threshold={15}
40
- flameHideTimeout={300}
41
- hideCursor={false} // Keep normal cursor visible
42
- offsetX={-15} // Fine-tune rocket position
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
- | `offsetX` | number | `-15` | Horizontal offset in pixels from cursor position. |
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
  ![Rocket Cursor Demo](https://github.com/No898/RocketCursor/raw/main/assets/rocket-cursor-demo.gif)
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)
@@ -5,8 +5,7 @@ type Props = {
5
5
  flameHideTimeout?: number;
6
6
  isVisible?: boolean;
7
7
  hideCursor?: boolean;
8
- offsetX?: number;
9
- offsetY?: number;
8
+ followSpeed?: number;
10
9
  };
11
10
  declare const RocketCursor: React.FC<Props>;
12
11
  export default RocketCursor;
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef, useId, useMemo, useCallback, startTransition } from "react";
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, offsetX = -15, // Compensation for SVG geometry
22
- offsetY = 0, }) => {
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 !== 'undefined' ? window.innerWidth / 2 : 0,
28
- y: typeof window !== 'undefined' ? window.innerHeight / 2 : 0
27
+ x: typeof window !== "undefined" ? window.innerWidth / 2 : 0,
28
+ y: typeof window !== "undefined" ? window.innerHeight / 2 : 0,
29
29
  });
30
- const current = useRef({ x: target.current.x, y: target.current.y });
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 visibleRef = useRef(isVisible);
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 = 'none';
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
- // Cleanup: restore cursor when component unmounts
49
- document.body.style.cursor = '';
50
+ document.body.style.cursor = "";
50
51
  };
51
- }, [isVisible, hideCursor]);
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
- const onMouseMove = useCallback((e) => {
54
- // Toggle visibility if hovering excluded elements
55
- const t = e.target;
56
- const exclude = t && t.closest && t.closest(".no-rocket-cursor");
57
- const show = !exclude && visibleRef.current;
58
- if (wrapperRef.current)
59
- wrapperRef.current.style.display = show ? "block" : "none";
60
- if (!show)
61
- return;
62
- // Set rocket position on cursor with optional offset
63
- target.current.x = e.clientX + offsetX;
64
- target.current.y = e.clientY + offsetY;
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
- }, [offsetX, offsetY, threshold]);
77
- const onMouseOut = useCallback((e) => {
78
- const rel = e.relatedTarget;
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
- el.style.transform = `translate3d(${current.current.x}px, ${current.current.y}px, 0)`;
95
- // Rotation is now in SVG element
96
- const svg = el.querySelector('svg');
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 = `translate(-50%, -50%) rotate(${angleRef.current}deg)`;
125
+ svg.style.transform = `rotate(${angleRef.current}deg)`;
99
126
  }
100
127
  }
101
128
  if (flameRef.current) {
102
- flameRef.current.style.opacity = showFlame ? "1" : "0";
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", onMouseMove);
109
- document.removeEventListener("mouseout", onMouseOut);
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
- }, [flameHideTimeout, threshold, offsetX, offsetY]);
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
- }), [size]);
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.0.0",
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": "^22.15.3",
36
- "@types/react": "^19.0.0",
37
- "@types/react-dom": "^19.0.0",
38
- "react": "^19.0.0",
39
- "react-dom": "^19.0.0",
40
- "typescript": "^5.0.0"
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"