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 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,15 @@ 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
+
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)
@@ -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,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, 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 [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 = 'none';
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
- // Cleanup: restore cursor when component unmounts
49
- document.body.style.cursor = '';
49
+ document.body.style.cursor = "";
50
50
  };
51
- }, [isVisible, hideCursor]);
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
- 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
- });
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
- }, [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";
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
- 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');
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 = `translate(-50%, -50%) rotate(${angleRef.current}deg)`;
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", onMouseMove);
109
- document.removeEventListener("mouseout", onMouseOut);
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
- }, [flameHideTimeout, threshold, offsetX, offsetY]);
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
- }), [size]);
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.0.0",
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": "^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"