diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ffd151 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.js from .nvmrc + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run quality checks + run: npm run check + + - name: Run package smoke test + run: npm run test:package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..63900a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.js from .nvmrc + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: npm + registry-url: "https://registry.npmjs.org" + + - name: Ensure manual releases run from main or master + if: github.event_name == 'workflow_dispatch' + run: | + if [ "${GITHUB_REF_NAME}" != "main" ] && [ "${GITHUB_REF_NAME}" != "master" ]; then + echo "Manual release must run from main or master." + exit 1 + fi + + - name: Verify tag matches package version + if: startsWith(github.ref, 'refs/tags/') + run: | + node -e "const pkg=require('./package.json'); const tag=process.env.GITHUB_REF_NAME; if (tag !== `v${pkg.version}`) { throw new Error(`Tag ${tag} does not match package.json version v${pkg.version}`); }" + + - name: Install dependencies + run: npm ci + + - name: Run quality checks + run: npm run check + + - name: Run package smoke test + run: npm run test:package + + - name: Publish to npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..1d9b783 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.12.0 diff --git a/README.md b/README.md index 4feac82..f2afc6c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # Rocket Cursor Component -A customizable React 19+ component that replaces the mouse cursor with an animated rocket that rotates based on movement and displays a flame effect when in motion. +`rocket-cursor-component` is a React cursor library with two layers: -## Installation +- `RocketCursor`: the built-in animated rocket. +- `CursorFollower`: the generic motion engine for your own SVG, HTML, or CSS cursor. + +If you only want the packaged rocket, use the default export. +If you want to build your own cursor, you do not need to fork this library. Use `CursorFollower` and render your own component inside it. -Install the package via npm: +## Installation ```bash npm install rocket-cursor-component @@ -12,119 +16,353 @@ npm install rocket-cursor-component ## Requirements -- **React >= 19.0.0** (Required) -- **React DOM >= 19.0.0** (Required) -- Node.js >= 16.0.0 +- React 18 or React 19 +- React DOM 18 or React DOM 19 +- Node.js 20.19+ or 22.12+ for local development in this repository -> This package is built specifically for React 19+ and uses the latest React features including `useId()` for better performance and collision prevention. +> The published package already ships ESM, CommonJS, and type definitions. -## Usage +## Quick Start -Here's an example of how to use the `RocketCursor` component in your React app: +### Use the built-in rocket ```tsx -import React from "react"; import RocketCursor from "rocket-cursor-component"; -function App() { +export default function App() { return ( -
-

Your app content here

- {/* Basic usage - rocket replaces cursor */} + <> +
Your app content
+ + ); +} +``` - {/* Tuned usage - visible system cursor, snappier follow */} - + ); +} +``` + +## Build Your Own Cursor + +Use `CursorFollower` when you want custom visuals and keep the same motion system. + +```tsx +import { CursorFollower } from "rocket-cursor-component"; + +function StarCursor({ isMoving }: { isMoving: boolean }) { + return ( +
+ ); } -export default App; +export default function App() { + return ( + + {({ isMoving }) => } + + ); +} +``` + +## How To Add Another SVG Cursor + +This is the intended extension path for users of the package. + +### 1. Create a React component that renders your SVG + +Your component can be as simple or as detailed as you want. +If you use gradients, filters, masks, or clip paths, prefer `useId()` so multiple cursors do not collide. + +```tsx +import { useId } from "react"; + +function MyShip({ isMoving }: { isMoving: boolean }) { + const gradientId = useId(); + + return ( + + ); +} +``` + +### 2. Render it inside `CursorFollower` + +```tsx +import { CursorFollower } from "rocket-cursor-component"; + + + {({ isMoving }) => } +; ``` -### Props - -| Prop | Type | Default | Description | -| ------------------ | ------- | ------- | ---------------------------------------------------------- | -| `size` | number | `50` | The size of the rocket cursor in pixels. | -| `threshold` | number | `10` | Minimum distance (pixels) to move before the rocket rotates. | -| `isVisible` | boolean | `true` | Initial visibility state of the rocket cursor. | -| `flameHideTimeout` | number | `300` | Time in milliseconds before the flame hides after stopping.| -| `hideCursor` | boolean | `false` | Whether to hide the normal cursor (true) or show both. | -| `followSpeed` | number | `0.18` | Follow smoothing (0-1). Higher = faster/snappier following. | - -## Features - -- **React 19+ Optimized**: Built specifically for React 19+ with latest performance optimizations -- **Dual Cursor Mode**: Choose to replace cursor completely or show rocket alongside normal cursor -- **Custom Cursor**: Replaces the default mouse cursor with a rocket that follows the cursor and aligns its nose to the pointer -- **Smart Rotation**: The rocket rotates in the direction of cursor movement with configurable threshold -- **Flame Effect**: Dynamic flame animation when the cursor is moving -- **Collision-Free**: Uses React 19's `useId()` to prevent SVG gradient ID collisions -- **Customizable**: Easily adjust size, rotation threshold, visibility, positioning, and flame duration -- **Element-Specific Visibility**: Automatically hides the rocket cursor over elements with the class `no-rocket-cursor` -- **Performance Optimized**: Uses `requestAnimationFrame` and hardware acceleration for smooth animations -- **TypeScript**: Full TypeScript support with proper type definitions +### 3. Tune the alignment + +The most important prop for custom SVGs is `anchorOffset`. + +- `anchorOffset.x`: how far the cursor anchor should move horizontally inside your artwork +- `anchorOffset.y`: how far the cursor anchor should move vertically inside your artwork + +Examples: + +- front-pointing ship: use a positive `x` +- centered icon like a star: use a smaller `x`, often close to half the width or less +- artwork with a nose above the centerline: use a negative `y` + +```tsx + + {({ isMoving }) => } + +``` + +### 4. Tune rotation + +If your SVG points to the right by default, you usually want: + +```tsx +rotateWithMovement={true} +rotationOffset={0} +``` + +If your SVG points up, left, or diagonally in its default drawing direction, adjust `rotationOffset`. + +Examples: + +- points up: `rotationOffset={-90}` +- points down: `rotationOffset={90}` +- points diagonally: use the angle that matches your artwork + +### 5. Use the motion state + +`CursorFollower` can pass a render function and gives you: + +- `isMoving`: useful for flame, glow, wake, streaks, or scaling +- `visible`: useful if you want to pause expensive effects when hidden + +```tsx + + {({ isMoving, visible }) => ( + + )} + +``` + +### 6. Add “speed” without smoke + +If you want motion to feel fast without using flame or smoke, the simplest patterns are: + +- air streaks behind the object +- a soft glow envelope around the object +- slight width stretch while moving +- subtle opacity changes on secondary details + +That is exactly the kind of effect used in the local demo star example. + +## API + +### `RocketCursor` props + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `className` | `string` | `undefined` | Optional class passed to the wrapper. | +| `disabled` | `boolean` | `false` | Disables the custom cursor entirely. | +| `disableOnCoarsePointer` | `boolean` | `true` | Disables the cursor on touch/coarse pointers. | +| `excludeSelector` | `string` | `".no-rocket-cursor"` | Selector for regions where the custom cursor should hide. | +| `respectReducedMotion` | `boolean` | `true` | Disables the cursor when the user prefers reduced motion. | +| `size` | `number` | `50` | Rocket size in pixels. | +| `threshold` | `number` | `10` | Minimum movement distance before rotation updates. | +| `isVisible` | `boolean` | `true` | Controls whether the custom cursor should render. | +| `flameHideTimeout` | `number` | `300` | Delay before the flame hides after movement stops. | +| `hideCursor` | `boolean` | `false` | Hides the native cursor when enabled. | +| `followSpeed` | `number` | `0.18` | Follow smoothing from `0` to `1`. Higher is snappier. | +| `zIndex` | `number` | `9999` | Wrapper stacking order. | + +### `CursorFollower` props + +`CursorFollower` includes the shared base props from `RocketCursor`: + +- `className` +- `disabled` +- `disableOnCoarsePointer` +- `excludeSelector` +- `followSpeed` +- `hideCursor` +- `isVisible` +- `respectReducedMotion` +- `threshold` +- `zIndex` + +Additional props: + +| Prop | Type | Default | Description | +| --- | --- | --- | --- | +| `anchorOffset` | `{ x: number; y: number }` | `{ x: 0, y: 0 }` | Moves the cursor anchor inside the artwork. | +| `children` | `ReactNode \| (state) => ReactNode` | required | Static node or render function that receives `{ isMoving, visible }`. | +| `movingTimeout` | `number` | `300` | Delay before `isMoving` flips back to `false`. | +| `rotateWithMovement` | `boolean` | `true` | Rotates the content to match movement direction. | +| `rotationOffset` | `number` | `0` | Fixed angle offset for artwork with a different default direction. | +| `width` | `number` | `48` | Wrapper width in pixels. | +| `height` | `number` | `48` | Wrapper height in pixels. | +| `wrapperProps` | `HTMLAttributes` | `undefined` | Extra wrapper attributes, including `data-*` hooks. | + +## Feature Summary + +- Built-in rocket cursor +- Generic cursor engine for custom SVG or HTML +- Motion-based rotation +- Optional native cursor hiding +- Reduced-motion and coarse-pointer safe defaults +- Exclusion zones via CSS selector +- TypeScript exports for both built-in and custom usage +- ESM and CommonJS builds + +## Development + +```bash +nvm use +npm install +npm run demo +``` + +Useful commands: + +```bash +npm run typecheck +npm run test +npm run test:package +npm run build +npm run check +npm run check:published +``` + +`npm install` also installs a local `pre-push` hook. When you push to `main` or `master`, the hook checks the latest npm version and blocks the push if publish-relevant files changed but `package.json` was not bumped ahead of what is already published. ## Demo -Here's a demo of the Rocket Cursor in action: +Local demo: + +```bash +npm install +npm run demo +``` -![Rocket Cursor Demo](https://github.com/No898/RocketCursor/raw/main/assets/rocket-cursor-demo.gif) +Then open the Vite URL printed in the terminal. -> Local demo (not published to npm): run `npm install` and `npm run dev`, then open the Vite dev server printed in the console. +## Release -## Changelog +Update the package version, then either: -### 2.1.0 -- **NEW**: Added `followSpeed` prop for configurable smoothing (nose snaps to cursor when close) -- **Changed**: Rocket aligns by its nose to the cursor position (manual offsets removed) -- **Changed**: Demo cleaned up to match the new API (no offset sliders) +- push a tag in the format `vX.Y.Z` +- or trigger the release workflow manually -### 2.1.1 -- **Fixed**: Flame visibility now updates reliably +The release workflow expects an `NPM_TOKEN` repository secret with publish access. -### 2.0.0 (React 19+ Only) -- **BREAKING**: Now requires React 19.0.0 or higher -- **NEW**: Added `useId()` for unique SVG gradient IDs (prevents collisions) -- **NEW**: Added `hideCursor` prop for dual cursor mode -- **NEW**: Added `offsetX` and `offsetY` props for precise positioning -- Fixed all TypeScript type issues and removed unnecessary type casting -- Improved performance with better dependency management -- Removed unused props (`followDistance`, `followSpeed`) -- Added SSR safety checks for `window` object -- Enhanced code structure with React 19 best practices +If you want to verify the version manually before tagging, run: -### 1.1.1 -- Fixed a typo in README.md. +```bash +npm run check:published +``` -### 1.1.0 -- Refactored SVG into separate components. -- Added `flameHideTimeout` prop for configurable flame duration. -- Improved code structure and efficiency. +## Changelog -### 1.0.9 +### Unreleased -- Added support to hide the Rocket Cursor on elements with the class `no-rocket-cursor`. +- Added `CursorFollower` as a public API for custom cursors +- Added smoke coverage for the generic API +- Expanded the demo with custom cursor examples +- Reworked the README around custom SVG usage -### 1.0.2 +### 2.1.1 -- Added demo GIF in the README file. +- Fixed flame visibility updates -### 1.0.1 +### 2.1.0 -- Initial release of the Rocket Cursor component. +- Added `followSpeed` +- Changed rocket alignment to use the nose position +- Updated the demo to match the new API -## Author +### 2.0.0 -[No898](https://github.com/No898) +- Added `useId()` for SVG gradient safety +- Added `hideCursor` +- Improved SSR safety +- Cleaned up types and removed dead props ## License -This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. +MIT. See [LICENSE](./LICENSE). diff --git a/demo/index.html b/demo/index.html index 9d049b9..9287e85 100644 --- a/demo/index.html +++ b/demo/index.html @@ -1,8 +1,15 @@ - + + + + + RocketCursor Demo @@ -10,4 +17,3 @@ - diff --git a/demo/main.tsx b/demo/main.tsx index edc1978..b091743 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -1,8 +1,95 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import RocketCursor from '../src/rocket.Cursor.tsx'; +import RocketCursor, { CursorFollower } from '../src'; import './styles.css'; +type CursorMode = 'rocket' | 'star'; + +function StarCursor({ isMoving }: { isMoving: boolean }) { + const starGradientId = React.useId(); + const coreGradientId = React.useId(); + const glowGradientId = React.useId(); + const streakGradientId = React.useId(); + const shadowId = React.useId(); + + return ( + + ); +} + function App() { const [size, setSize] = React.useState(50); const [threshold, setThreshold] = React.useState(10); @@ -10,112 +97,321 @@ function App() { const [isVisible, setIsVisible] = React.useState(true); const [hideCursor, setHideCursor] = React.useState(false); const [followSpeed, setFollowSpeed] = React.useState(0.15); + const [cursorMode, setCursorMode] = React.useState('rocket'); + const [activePreset, setActivePreset] = React.useState("Cruise"); + const cursorLabel = cursorMode === 'rocket' ? 'Rocket' : 'Star'; + const motionLabel = cursorMode === 'rocket' ? 'Flame' : 'Air wake'; + const presets = [ + { + name: "Arcade", + description: "Fast and playful", + values: { + flameHideTimeout: 220, + followSpeed: 0.42, + hideCursor: false, + isVisible: true, + size: 56, + threshold: 6, + }, + }, + { + name: "Cruise", + description: "Balanced default", + values: { + flameHideTimeout: 300, + followSpeed: 0.15, + hideCursor: false, + isVisible: true, + size: 50, + threshold: 10, + }, + }, + { + name: "Stealth", + description: "Rocket-only mode", + values: { + flameHideTimeout: 180, + followSpeed: 0.26, + hideCursor: true, + isVisible: true, + size: 42, + threshold: 14, + }, + }, + ]; + const telemetry = [ + { label: 'Mode', value: cursorLabel }, + { label: 'Size', value: `${size}px` }, + { label: 'Follow speed', value: `${Math.round(followSpeed * 100)}%` }, + { label: `${motionLabel} delay`, value: `${flameHideTimeout}ms` }, + ]; + const clearActivePreset = () => { + setActivePreset(null); + }; + const applyPreset = (preset: (typeof presets)[number]) => { + setSize(preset.values.size); + setThreshold(preset.values.threshold); + setFlameHideTimeout(preset.values.flameHideTimeout); + setIsVisible(preset.values.isVisible); + setHideCursor(preset.values.hideCursor); + setFollowSpeed(preset.values.followSpeed); + setActivePreset(preset.name); + }; return ( <> - - -
-

🚀 RocketCursor Demo

-

Pohybujte myší po obrazovce a sledujte raketu!

- -
-

Nastavení

- -
- -
- -
- -
- -
- -
- -
- - - Nižší hodnota = větší zpoždění, vyšší = rychlejší následování - -
- -
- -
- -
- -
-
- -
-

Testovací oblast

-

Pohybujte myší v této oblasti a sledujte, jak se raketa otáčí a zobrazuje plamen.

-
-

Tato oblast má třídu "no-rocket-cursor" - raketa se zde skryje

-
-
+ {cursorMode === 'rocket' ? ( + + ) : ( + + {({ isMoving }) => } + + )} + +
+