├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .parcelrc ├── .prettierrc ├── LICENSE ├── docs └── src │ ├── App.js │ ├── DemoContent.js │ ├── DemoCustomTest.js │ ├── DemoFooter.js │ ├── DemoHeader.js │ ├── demo-styles.css │ ├── index.html │ └── index.js ├── lib ├── AnimatedCursor.tsx ├── AnimatedCursor.types.ts ├── helpers │ └── find.ts ├── hooks │ ├── useEventListener.ts │ └── useIsTouchdevice.ts └── index.ts ├── package-lock.json ├── package.json ├── readme.md ├── rollup.config.mjs └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | *.json 2 | build 3 | config 4 | dist 5 | **node_modules** 6 | ./node_modules/** 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "browser": true, 5 | "es6": true, 6 | "commonjs": true 7 | }, 8 | "plugins": ["react", "react-hooks", "prettier"], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:react/recommended", 12 | "plugin:react-hooks/recommended", 13 | "plugin:prettier/recommended", 14 | "plugin:@typescript-eslint/recommended" 15 | ], 16 | "settings": { 17 | "react": { 18 | "version": "18.2.0" 19 | }, 20 | "import/resolver": { 21 | "node": { 22 | "paths": ["./"] 23 | } 24 | } 25 | }, 26 | "ignorePatterns": ["temp.js", "node_modules"], 27 | "parser": "@typescript-eslint/parser", 28 | "parserOptions": { 29 | "ecmaVersion": 9, 30 | "sourceType": "module", 31 | "requireConfigFile": false, 32 | "plugins": ["@typescript-eslint"], 33 | "ecmaFeatures": { 34 | "jsx": true, 35 | "experimentalObjectRestSpread": true, 36 | "modules": true 37 | } 38 | }, 39 | "rules": { 40 | "linebreak-style": 0, 41 | "no-underscore-dangle": 0, 42 | "no-nested-ternary": 0, 43 | "prettier/prettier": "error", 44 | "react-hooks/exhaustive-deps": "warn", 45 | "react/react-in-jsx-scope": "off", 46 | "react-hooks/rules-of-hooks": "error", 47 | "react/jsx-uses-react": "off", 48 | "react/jsx-uses-vars": "error", 49 | "react/no-unescaped-entities": 0, 50 | "react/prefer-stateless-function": 1 51 | }, 52 | "globals": { 53 | "grecaptcha": "readonly" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | npm-debug.log 4 | .DS_Store 5 | .cache 6 | .tmp 7 | *.log 8 | .parcel-cache 9 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .babelrc 3 | .cache 4 | .parcel-cache 5 | .tmp 6 | *.log 7 | .gitignore 8 | node_modules 9 | npm-debug.log 10 | demo 11 | docs 12 | lib 13 | src 14 | 15 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{js,mjs,jsx,cjs,ts,tsx}": [ 5 | "@parcel/transformer-js", 6 | "@parcel/transformer-react-refresh-wrap" 7 | ] 8 | } 9 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "semi": false, 7 | "jsxSingleQuote": false, 8 | "quoteProps": "as-needed", 9 | "bracketSpacing": true, 10 | "jsxBracketSameLine": false 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright 2021 Stephen Scaff 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | -------------------------------------------------------------------------------- /docs/src/App.js: -------------------------------------------------------------------------------- 1 | import { React, useState, useEffect } from 'react' 2 | import AnimatedCursor from '../../lib' 3 | import DemoContent from './DemoContent' 4 | import DemoCustomTest from './DemoCustomTest' 5 | import DemoHeader from './DemoHeader' 6 | import DemoFooter from './DemoFooter' 7 | import './demo-styles.css' 8 | 9 | export default function App() { 10 | const [state, setState] = useState('donut') 11 | const searchParams = new URLSearchParams(document.location.search) 12 | const cursorParam = searchParams.get('cursor') 13 | 14 | useEffect(() => { 15 | if (cursorParam) setState(cursorParam) 16 | }, [cursorParam]) 17 | 18 | return ( 19 |
20 | {state === 'default' && } 21 | {state === 'donut' && ( 22 | 37 | )} 38 | {state === 'blendmode' && ( 39 | 55 | )} 56 | {state === 'custom' && ( 57 | 116 | )} 117 | 118 | 119 | {state === 'custom' && } 120 | 121 |
122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /docs/src/DemoContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const s = { 4 | section: { 5 | paddingTop: '6em', 6 | width: '80%', 7 | maxWidth: '36em', 8 | margin: '0 auto 1em' 9 | }, 10 | title: { 11 | marginBottom: '1em', 12 | fontSize: '3em', 13 | fontWeight: 800, 14 | textAlign: 'center', 15 | lineHeight: 1 16 | }, 17 | pretitle: { 18 | textAlign: 'center' 19 | }, 20 | subtitle: { 21 | textAlign: 'center' 22 | }, 23 | sep: { 24 | border: 0, 25 | margin: '2em auto', 26 | height: 2, 27 | width: '3em', 28 | backgroundColor: 'rgba(255, 255, 255, 0.5)' 29 | } 30 | } 31 | 32 | export default function Content() { 33 | return ( 34 |
35 |

Demos

36 |

React Animated Cursor

37 |

38 | A component by Stephen Scaff 39 |

40 |
41 |

42 | React animated cursor is a React component that creates a custom cursor 43 | experience. You can craft a variety of cursor types, and animate 44 | movement, hover and clicking properties. 45 |

46 |

47 | Hover over these links and see how that animated cursor does it's 48 | thing. Kinda nifty, right? Not applicable to most projects, but a nice 49 | move for more interactive/immersive stuff... if you're into that kinda 50 | thing? Here's another link to nowhere. 51 |

52 |

Essentially, the cursor consists:

53 | 66 |

67 | Style props exist for in the inner and outer cursor allow you to easily 68 | create unique cursor types. Play with css variables to influence 69 | the cursor, cursor outline size, and amount of scale on target hover. 70 |

71 | 72 |

Demo Cursors

73 |

Here's a few cursor types you can create to test

74 | 88 | 89 |

Test Clickables

90 |

Here's a collection of test clickable elements to hover over:

91 | 123 |
124 | ) 125 | } 126 | -------------------------------------------------------------------------------- /docs/src/DemoCustomTest.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const s = { 4 | section: { 5 | paddingBottom: '6em', 6 | width: '80%', 7 | maxWidth: '36em', 8 | margin: '0 auto 1em' 9 | } 10 | } 11 | 12 | export default function Content() { 13 | return ( 14 |
15 |

Test custom Clickables

16 |

17 | Here's a collection of additional elements to test custom behaviors: 18 |

19 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /docs/src/DemoFooter.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const s = { 4 | footer: { 5 | position: 'relative', 6 | width: '100%', 7 | padding: '6em 0 3em', 8 | backgroundColor: '#2f2c2c', 9 | textAlign: 'center' 10 | }, 11 | footer__grid: { 12 | position: 'relative', 13 | maxWidth: '95%', 14 | margin: '0 auto' 15 | }, 16 | footer__border: { 17 | height: '1px', 18 | width: '100%', 19 | marginBottom: '4em', 20 | border: '0', 21 | backgroundColor: 'rgba(255,255,255,0.4)' 22 | }, 23 | footer__copy: { 24 | fontSize: '0.8em' 25 | }, 26 | footer__icon: { 27 | width: '2em', 28 | margin: '0 auto', 29 | textAlign: 'center' 30 | }, 31 | footer__icon_vector: { 32 | fill: '#fff' 33 | } 34 | } 35 | 36 | export default function DemoHeader() { 37 | return ( 38 |
39 |
40 |
41 |
42 | 47 | 48 | 49 | 50 |
51 |

52 | A little thing by{' '} 53 | 54 | Stephen Scaff 55 | 56 |

57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /docs/src/DemoHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const s = { 4 | header: { 5 | position: 'fixed', 6 | top: 0, 7 | left: 0, 8 | width: '100%', 9 | backgroundColor: '#2f2c2c' 10 | }, 11 | header__grid: { 12 | position: 'relative', 13 | display: 'flex', 14 | justifyContent: 'space-between', 15 | alignItems: 'center', 16 | width: '100%', 17 | height: '4em', 18 | maxWidth: '95%', 19 | margin: '0 auto' 20 | }, 21 | nav: { 22 | marginLeft: 'auto' 23 | }, 24 | nav__link: { 25 | marginLeft: '1em', 26 | fontSize: '0.8em', 27 | fontWeight: '400' 28 | }, 29 | brand: { 30 | display: 'flex', 31 | alignItems: 'center' 32 | }, 33 | brand__icon_inner: { 34 | position: 'relative', 35 | right: '-5px', 36 | display: 'block', 37 | height: '10px', 38 | width: '10px', 39 | borderRadius: '100%', 40 | backgroundColor: '#fff' 41 | }, 42 | brand__icon_outer: { 43 | position: 'relative', 44 | left: '-10px', 45 | top: '-8px', 46 | display: 'block', 47 | height: '6px', 48 | width: '6px', 49 | borderRadius: '100%', 50 | backgroundColor: 'rgba(255,255,255,0.4)' 51 | } 52 | } 53 | 54 | export default function DemoHeader() { 55 | return ( 56 |
57 |
58 |
59 | 60 | 61 |
62 | 82 |
83 |
84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /docs/src/demo-styles.css: -------------------------------------------------------------------------------- 1 | /* Cursor vars if you wanna use css over css in js */ 2 | 3 | :root { 4 | --cursor-color: #fff; 5 | } 6 | html, 7 | body { 8 | background-color: #2f2c2c; 9 | color: #fff; 10 | font-family: 'Inter', sans-serif; 11 | } 12 | 13 | /* Demo Content */ 14 | a { 15 | text-decoration: none; 16 | color: #fff; 17 | font-weight: 600; 18 | border-bottom: 1px solid rgba(255, 255, 255, 0.7); 19 | transition: 0.5s ease; 20 | } 21 | 22 | a:hover { 23 | color: rgba(255, 255, 255, 0.5); 24 | border-bottom-color: rgba(255, 255, 255, 0.1); 25 | } 26 | 27 | section { 28 | line-height: 1.7; 29 | font-weight: 300; 30 | } 31 | 32 | h3 { 33 | font-size: 1.3em; 34 | margin: 2em 0 1em; 35 | } 36 | 37 | ul { 38 | margin: 2em 0 1em; 39 | padding-left: 1em; 40 | } 41 | 42 | ul li { 43 | padding-bottom: 1.25em; 44 | padding-left: 0; 45 | } 46 | -------------------------------------------------------------------------------- /docs/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | React Animated Cursor - by Stephen Scaff 12 | 13 | 14 | 15 | 16 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App' 4 | 5 | const container = document.getElementById('app') 6 | const root = createRoot(container) 7 | root.render() 8 | -------------------------------------------------------------------------------- /lib/AnimatedCursor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useEffect, 4 | useCallback, 5 | useRef, 6 | CSSProperties, 7 | useMemo 8 | } from 'react' 9 | import { useEventListener } from './hooks/useEventListener' 10 | import type { 11 | AnimatedCursorProps, 12 | AnimatedCursorCoordinates, 13 | AnimatedCursorOptions, 14 | Clickable 15 | } from './AnimatedCursor.types' 16 | import find from './helpers/find' 17 | import useIsTouchdevice from './hooks/useIsTouchdevice' 18 | 19 | /** 20 | * Cursor Core 21 | * Replaces the native cursor with a custom animated cursor, consisting 22 | * of an inner and outer dot that scale inversely based on hover or click. 23 | * 24 | * @author Stephen Scaff (github.com/stephenscaff) 25 | * 26 | * @param {object} obj 27 | * @param {array} obj.clickables - array of clickable selectors 28 | * @param {string} obj.children - element that is shown instead of the inner dot 29 | * @param {string} obj.color - rgb color value 30 | * @param {number} obj.innerScale - inner cursor scale amount 31 | * @param {number} obj.innerSize - inner cursor size in px 32 | * @param {object} obj.innerStyle - style object for inner cursor 33 | * @param {number} obj.outerAlpha - level of alpha transparency for color 34 | * @param {number} obj.outerScale - outer cursor scale amount 35 | * @param {number} obj.outerSize - outer cursor size in px 36 | * @param {object} obj.outerStyle - style object for outer cursor 37 | * @param {bool} obj.showSystemCursor - show/hide system cursor1 38 | * @param {number} obj.trailingSpeed - speed the outer cursor trails at 39 | */ 40 | function CursorCore({ 41 | clickables = [ 42 | 'a', 43 | 'input[type="text"]', 44 | 'input[type="email"]', 45 | 'input[type="number"]', 46 | 'input[type="submit"]', 47 | 'input[type="image"]', 48 | 'label[for]', 49 | 'select', 50 | 'textarea', 51 | 'button', 52 | '.link' 53 | ], 54 | children, 55 | color = '220, 90, 90', 56 | innerScale = 0.6, 57 | innerSize = 8, 58 | innerStyle, 59 | outerAlpha = 0.4, 60 | outerScale = 6, 61 | outerSize = 8, 62 | outerStyle, 63 | showSystemCursor = false, 64 | trailingSpeed = 8 65 | }: AnimatedCursorProps) { 66 | const defaultOptions = useMemo( 67 | () => ({ 68 | children, 69 | color, 70 | innerScale, 71 | innerSize, 72 | innerStyle, 73 | outerAlpha, 74 | outerScale, 75 | outerSize, 76 | outerStyle 77 | }), 78 | [ 79 | children, 80 | color, 81 | innerScale, 82 | innerSize, 83 | innerStyle, 84 | outerAlpha, 85 | outerScale, 86 | outerSize, 87 | outerStyle 88 | ] 89 | ) 90 | 91 | const cursorOuterRef = useRef(null) 92 | const cursorInnerRef = useRef(null) 93 | const requestRef = useRef(null) 94 | const previousTimeRef = useRef(null) 95 | const [coords, setCoords] = useState({ 96 | x: 0, 97 | y: 0 98 | }) 99 | const [isVisible, setIsVisible] = useState(false) 100 | const [options, setOptions] = useState(defaultOptions) 101 | const [isActive, setIsActive] = useState( 102 | false 103 | ) 104 | const [isActiveClickable, setIsActiveClickable] = useState(false) 105 | const endX = useRef(0) 106 | const endY = useRef(0) 107 | 108 | /** 109 | * Primary Mouse move event 110 | * @param {number} clientX - MouseEvent.clientX 111 | * @param {number} clientY - MouseEvent.clientY 112 | */ 113 | const onMouseMove = useCallback((event: MouseEvent) => { 114 | const { clientX, clientY } = event 115 | setCoords({ x: clientX, y: clientY }) 116 | if (cursorInnerRef.current !== null) { 117 | cursorInnerRef.current.style.top = `${clientY}px` 118 | cursorInnerRef.current.style.left = `${clientX}px` 119 | } 120 | endX.current = clientX 121 | endY.current = clientY 122 | }, []) 123 | 124 | // Outer Cursor Animation Delay 125 | const animateOuterCursor = useCallback( 126 | (time: number) => { 127 | if (previousTimeRef.current !== undefined) { 128 | coords.x += (endX.current - coords.x) / trailingSpeed 129 | coords.y += (endY.current - coords.y) / trailingSpeed 130 | if (cursorOuterRef.current !== null) { 131 | cursorOuterRef.current.style.top = `${coords.y}px` 132 | cursorOuterRef.current.style.left = `${coords.x}px` 133 | } 134 | } 135 | previousTimeRef.current = time 136 | requestRef.current = requestAnimationFrame(animateOuterCursor) 137 | }, 138 | [requestRef] // eslint-disable-line 139 | ) 140 | 141 | // Outer cursor RAF setup / cleanup 142 | useEffect(() => { 143 | requestRef.current = requestAnimationFrame(animateOuterCursor) 144 | return () => { 145 | if (requestRef.current !== null) { 146 | cancelAnimationFrame(requestRef.current) 147 | } 148 | } 149 | }, [animateOuterCursor]) 150 | 151 | /** 152 | * Calculates amount to scale cursor in px3 153 | * @param {number} orignalSize - starting size 154 | * @param {number} scaleAmount - Amount to scale 155 | * @returns {String} Scale amount in px 156 | */ 157 | const getScaleAmount = (orignalSize: number, scaleAmount: number) => { 158 | return `${parseInt(String(orignalSize * scaleAmount))}px` 159 | } 160 | 161 | // Scales cursor by HxW 162 | const scaleBySize = useCallback( 163 | ( 164 | cursorRef: HTMLDivElement | null, 165 | orignalSize: number, 166 | scaleAmount: number 167 | ) => { 168 | if (cursorRef) { 169 | cursorRef.style.height = getScaleAmount(orignalSize, scaleAmount) 170 | cursorRef.style.width = getScaleAmount(orignalSize, scaleAmount) 171 | } 172 | }, 173 | [] 174 | ) 175 | 176 | // Mouse Events State updates 177 | const onMouseDown = useCallback(() => setIsActive(true), []) 178 | const onMouseUp = useCallback(() => setIsActive(false), []) 179 | const onMouseEnterViewport = useCallback(() => setIsVisible(true), []) 180 | const onMouseLeaveViewport = useCallback(() => setIsVisible(false), []) 181 | 182 | useEventListener('mousemove', onMouseMove) 183 | useEventListener('mousedown', onMouseDown) 184 | useEventListener('mouseup', onMouseUp) 185 | useEventListener('mouseover', onMouseEnterViewport) 186 | useEventListener('mouseout', onMouseLeaveViewport) 187 | 188 | // Cursors Hover/Active State 189 | useEffect(() => { 190 | if (isActive) { 191 | scaleBySize(cursorInnerRef.current, options.innerSize, options.innerScale) 192 | scaleBySize(cursorOuterRef.current, options.outerSize, options.outerScale) 193 | } else { 194 | scaleBySize(cursorInnerRef.current, options.innerSize, 1) 195 | scaleBySize(cursorOuterRef.current, options.outerSize, 1) 196 | } 197 | }, [ 198 | options.innerSize, 199 | options.innerScale, 200 | options.outerSize, 201 | options.outerScale, 202 | scaleBySize, 203 | isActive 204 | ]) 205 | 206 | // Cursors Click States 207 | useEffect(() => { 208 | if (isActiveClickable) { 209 | scaleBySize( 210 | cursorInnerRef.current, 211 | options.innerSize, 212 | options.innerScale * 1.2 213 | ) 214 | scaleBySize( 215 | cursorOuterRef.current, 216 | options.outerSize, 217 | options.outerScale * 1.4 218 | ) 219 | } 220 | }, [ 221 | options.innerSize, 222 | options.innerScale, 223 | options.outerSize, 224 | options.outerScale, 225 | scaleBySize, 226 | isActiveClickable 227 | ]) 228 | 229 | // Cursor Visibility Statea 230 | useEffect(() => { 231 | if (cursorInnerRef.current == null || cursorOuterRef.current == null) return 232 | 233 | if (isVisible) { 234 | cursorInnerRef.current.style.opacity = '1' 235 | cursorOuterRef.current.style.opacity = '1' 236 | } else { 237 | cursorInnerRef.current.style.opacity = '0' 238 | cursorOuterRef.current.style.opacity = '0' 239 | } 240 | }, [isVisible]) 241 | 242 | // Click event state updates 243 | useEffect(() => { 244 | const clickableEls = document.querySelectorAll( 245 | clickables 246 | .map((clickable) => 247 | typeof clickable === 'object' && clickable?.target 248 | ? clickable.target 249 | : clickable ?? '' 250 | ) 251 | .join(',') 252 | ) 253 | 254 | clickableEls.forEach((el) => { 255 | if (!showSystemCursor) el.style.cursor = 'none' 256 | 257 | const clickableOptions = 258 | typeof clickables === 'object' 259 | ? find( 260 | clickables, 261 | (clickable: Clickable) => 262 | typeof clickable === 'object' && el.matches(clickable.target) 263 | ) 264 | : {} 265 | 266 | const options = { 267 | ...defaultOptions, 268 | ...clickableOptions 269 | } 270 | 271 | el.addEventListener('mouseover', () => { 272 | setIsActive(true) 273 | setOptions(options) 274 | }) 275 | el.addEventListener('click', () => { 276 | setIsActive(true) 277 | setIsActiveClickable(false) 278 | }) 279 | el.addEventListener('mousedown', () => { 280 | setIsActiveClickable(true) 281 | }) 282 | el.addEventListener('mouseup', () => { 283 | setIsActive(true) 284 | }) 285 | el.addEventListener('mouseout', () => { 286 | setIsActive(false) 287 | setIsActiveClickable(false) 288 | setOptions(defaultOptions) 289 | }) 290 | }) 291 | 292 | return () => { 293 | clickableEls.forEach((el) => { 294 | const clickableOptions = 295 | typeof clickables === 'object' 296 | ? find( 297 | clickables, 298 | (clickable: Clickable) => 299 | typeof clickable === 'object' && el.matches(clickable.target) 300 | ) 301 | : {} 302 | 303 | const options = { 304 | ...defaultOptions, 305 | ...clickableOptions 306 | } 307 | 308 | el.removeEventListener('mouseover', () => { 309 | setIsActive(true) 310 | setOptions(options) 311 | }) 312 | el.removeEventListener('click', () => { 313 | setIsActive(true) 314 | setIsActiveClickable(false) 315 | }) 316 | el.removeEventListener('mousedown', () => { 317 | setIsActiveClickable(true) 318 | }) 319 | el.removeEventListener('mouseup', () => { 320 | setIsActive(true) 321 | }) 322 | el.removeEventListener('mouseout', () => { 323 | setIsActive(false) 324 | setIsActiveClickable(false) 325 | setOptions(defaultOptions) 326 | }) 327 | }) 328 | } 329 | }, [isActive, clickables, showSystemCursor, defaultOptions]) 330 | 331 | useEffect(() => { 332 | if (typeof window === 'object' && !showSystemCursor) { 333 | document.body.style.cursor = 'none' 334 | } 335 | }, [showSystemCursor]) 336 | 337 | const coreStyles: CSSProperties = { 338 | zIndex: 999, 339 | display: 'flex', 340 | justifyContent: 'center', 341 | alignItems: 'center', 342 | position: 'fixed', 343 | borderRadius: '50%', 344 | pointerEvents: 'none', 345 | transform: 'translate(-50%, -50%)', 346 | transition: 347 | 'opacity 0.15s ease-in-out, height 0.2s ease-in-out, width 0.2s ease-in-out' 348 | } 349 | 350 | // Cursor Styles 351 | const styles = { 352 | cursorInner: { 353 | width: !options.children ? options.innerSize : 'auto', 354 | height: !options.children ? options.innerSize : 'auto', 355 | backgroundColor: !options.children 356 | ? `rgba(${options.color}, 1)` 357 | : 'transparent', 358 | ...coreStyles, 359 | ...(options.innerStyle && options.innerStyle) 360 | }, 361 | cursorOuter: { 362 | width: options.outerSize, 363 | height: options.outerSize, 364 | backgroundColor: `rgba(${options.color}, ${options.outerAlpha})`, 365 | ...coreStyles, 366 | ...(options.outerStyle && options.outerStyle) 367 | } 368 | } 369 | 370 | return ( 371 | <> 372 |
373 |
374 |
380 | {options.children} 381 |
382 |
383 | 384 | ) 385 | } 386 | 387 | /** 388 | * AnimatedCursor 389 | * Calls and passes props to CursorCore if not a touch/mobile device. 390 | */ 391 | function AnimatedCursor({ 392 | children, 393 | clickables, 394 | color, 395 | innerScale, 396 | innerSize, 397 | innerStyle, 398 | outerAlpha, 399 | outerScale, 400 | outerSize, 401 | outerStyle, 402 | showSystemCursor, 403 | trailingSpeed 404 | }: AnimatedCursorProps) { 405 | const isTouchdevice = useIsTouchdevice() 406 | if (typeof window !== 'undefined' && isTouchdevice) { 407 | return <> 408 | } 409 | return ( 410 | 423 | {children} 424 | 425 | ) 426 | } 427 | 428 | export default AnimatedCursor 429 | -------------------------------------------------------------------------------- /lib/AnimatedCursor.types.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react' 2 | 3 | export interface AnimatedCursorOptions { 4 | children?: ReactNode 5 | color?: string 6 | innerScale?: number 7 | innerSize?: number 8 | innerStyle?: CSSProperties 9 | outerAlpha?: number 10 | outerScale?: number 11 | outerSize?: number 12 | outerStyle?: CSSProperties 13 | } 14 | 15 | export type Clickable = string | ({ target: string } & AnimatedCursorOptions) 16 | 17 | export interface AnimatedCursorProps extends AnimatedCursorOptions { 18 | clickables?: Clickable[] 19 | showSystemCursor?: boolean 20 | trailingSpeed?: number 21 | } 22 | 23 | export interface AnimatedCursorCoordinates { 24 | x: number 25 | y: number 26 | } 27 | -------------------------------------------------------------------------------- /lib/helpers/find.ts: -------------------------------------------------------------------------------- 1 | export default function findInArray( 2 | arr: T[], 3 | callback: (element: T, index: number, array: T[]) => boolean, 4 | ...args 5 | ): T | undefined { 6 | if (typeof callback !== 'function') { 7 | throw new TypeError('callback must be a function') 8 | } 9 | 10 | const list = Object(arr) 11 | // Makes sure it always has a positive integer as length. 12 | const length = list.length >>> 0 13 | const thisArg = args[2] 14 | 15 | for (let i = 0; i < length; i++) { 16 | const element = list[i] 17 | if (callback.call(thisArg, element, i, list)) { 18 | return element 19 | } 20 | } 21 | 22 | return undefined 23 | } 24 | -------------------------------------------------------------------------------- /lib/hooks/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | type AllEventMaps = HTMLElementEventMap & DocumentEventMap & WindowEventMap 4 | 5 | export function useEventListener( 6 | type: K, 7 | listener: (event: HTMLElementEventMap[K]) => void, 8 | element: HTMLElement 9 | ): void 10 | 11 | export function useEventListener( 12 | type: K, 13 | listener: (event: DocumentEventMap[K]) => void, 14 | element: Document 15 | ): void 16 | 17 | export function useEventListener( 18 | type: K, 19 | listener: (event: WindowEventMap[K]) => void, 20 | element?: Window 21 | ): void 22 | 23 | export function useEventListener( 24 | type: K, 25 | listener: (event: AllEventMaps[K]) => void, 26 | element?: HTMLElement | Document | Window | null 27 | ) { 28 | const listenerRef = useRef(listener) 29 | 30 | useEffect(() => { 31 | listenerRef.current = listener 32 | }) 33 | 34 | useEffect(() => { 35 | const el = element === undefined ? window : element 36 | 37 | const internalListener = (ev: AllEventMaps[K]) => { 38 | return listenerRef.current(ev) 39 | } 40 | 41 | el?.addEventListener( 42 | type, 43 | internalListener as EventListenerOrEventListenerObject 44 | ) 45 | 46 | return () => { 47 | el?.removeEventListener( 48 | type, 49 | internalListener as EventListenerOrEventListenerObject 50 | ) 51 | } 52 | }, [type, element]) 53 | } 54 | -------------------------------------------------------------------------------- /lib/hooks/useIsTouchdevice.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useIsTouchdevice = (): boolean => { 4 | const [isTouchdevice, setIsTouchdevice] = useState() 5 | 6 | useEffect(() => { 7 | if (typeof window !== 'undefined') { 8 | setIsTouchdevice(window.matchMedia('(hover: none)').matches) 9 | } 10 | }, []) 11 | 12 | return isTouchdevice 13 | } 14 | 15 | export default useIsTouchdevice 16 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import AnimatedCursor from './AnimatedCursor' 2 | export default AnimatedCursor 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-animated-cursor", 3 | "version": "2.11.1", 4 | "description": "An animated custom cursor component in React.", 5 | "author": "Stephen Scaff ", 6 | "homepage": "https://stephenscaff.github.io/react-animated-cursor/", 7 | "main": "dist/index.js", 8 | "module": "dist/index.es.js", 9 | "types": "dist/index.d.ts", 10 | "browser": "dist/index.umd.js", 11 | "files": [ 12 | "dist/index.js", 13 | "dist/index.es.js", 14 | "dist/index.umd.js", 15 | "dist/index.d.ts" 16 | ], 17 | "targets": { 18 | "main": false, 19 | "module": false, 20 | "browser": false, 21 | "types": false 22 | }, 23 | "scripts": { 24 | "clean": "rm -rf ./dist", 25 | "build": "rollup -c", 26 | "dev": "parcel ./docs/src/index.html --dist-dir ./docs/dist", 27 | "demo:clean": "rm -rf ./docs/dist", 28 | "demo:start": "parcel ./docs/src/index.html --dist-dir ./docs/dist", 29 | "demo:build": "parcel build ./docs/src/index.html --dist-dir ./docs/dist --public-url ./", 30 | "demo:deploy": "npm run demo:build && gh-pages -d ./docs/dist", 31 | "prepare": "npm run build", 32 | "prepublish": "rm -rf ./dist && npm run build", 33 | "lint": "eslint \"lib/**/*.+(ts|tsx)\" --fix ", 34 | "format": "prettier --write \"lib/**/*.+(ts|tsx)\"" 35 | }, 36 | "keywords": [ 37 | "react cursor", 38 | "custom cursor", 39 | "animated cursor" 40 | ], 41 | "license": "ISC", 42 | "repository": { 43 | "type": "git", 44 | "url": "https://github.com/stephenscaff/react-animated-cursor" 45 | }, 46 | "bugs": { 47 | "url": "https://github.com/stephenscaff/react-animated-cursor/issues" 48 | }, 49 | "peerDependencies": { 50 | "react": "^18.2.0", 51 | "react-dom": "^18.2.0" 52 | }, 53 | "devDependencies": { 54 | "@rollup/plugin-commonjs": "^25.0.0", 55 | "@rollup/plugin-node-resolve": "^15.0.2", 56 | "@rollup/plugin-replace": "^5.0.2", 57 | "@rollup/plugin-typescript": "^11.1.1", 58 | "@types/react": "^18.2.6", 59 | "@types/react-dom": "^18.2.4", 60 | "@typescript-eslint/eslint-plugin": "^5.59.7", 61 | "@typescript-eslint/parser": "^5.36.2", 62 | "eslint": "^8.41.0", 63 | "eslint-config-prettier": "^8.8.0", 64 | "eslint-plugin-prettier": "^4.2.1", 65 | "eslint-plugin-react": "^7.32.2", 66 | "eslint-plugin-react-hooks": "^4.6.0", 67 | "gh-pages": "^5.0.0", 68 | "parcel": "^2.3.2", 69 | "prettier": "^2.0.5", 70 | "process": "^0.11.10", 71 | "react": "18.2.0", 72 | "react-dom": "18.2.0", 73 | "rollup": "^3.22.0", 74 | "rollup-plugin-dts": "^5.3.0", 75 | "rollup-plugin-peer-deps-external": "^2.2.2", 76 | "typescript": "^5.0.4" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # React Animated Cursor 2 | 3 | A React component that replaces the native cursor with a custom animated [jawn](https://www.urbandictionary.com/define.php?term=Jawn). Available options and props allow you to easily craft a unique cursor experience. 4 | 5 | ## Contents 6 | 7 | 1. [📌 Features](#-features) 8 | 2. [🎯 Quickstart](#-quickstart) 9 | 3. [🤖 Commands](#-commands) 10 | 4. [🧬 Options](#-options) 11 | 5. [🕹️ Usage](#-usage) 12 | 6. [🎨 Cursor Types](#-cursor-types) 13 | 7. [📓 Notes](#-notes) 14 | 8. [📅 To Dos](#-to-dos) 15 | 16 |
17 | 18 | ## 📌 Features 19 | 20 | ### The custom cursor is comprised of 21 | 22 | - An inner dot (`cursorInner`) 23 | - An outer, outlining circle (`cursorOuter`), with slight opacity based on the dot/primary color 24 | - A slight trailing animation of the outer outline 25 | - An inversely scaling effect between the inner and outer cursor parts on click or link hover 26 | 27 | Options exist for modifying the color and scaling of the cursor elements (see props/options below). Style props for in the inner and outer cursor allow you to easily create unique cursor types. 28 | 29 | [Live Demo→](https://stephenscaff.github.io/react-animated-cursor/) 30 | 31 |
32 | 33 | ## 🎯 Quickstart 34 | 35 | ### Install package from npm 36 | 37 | `npm i react-animated-cursor` 38 | 39 | ### Add to you project 40 | 41 | Add to a global location, like `_app.js` 42 | 43 | ``` 44 | import React from "react"; 45 | import AnimatedCursor from "react-animated-cursor" 46 | 47 | export default function App() { 48 | return ( 49 |
50 | 51 |
52 | ); 53 | } 54 | ``` 55 | 56 |
57 | 58 | ## 🤖 Commands 59 | 60 | **Install** `npm i react-animated-cursor`
61 | **Build**: `npm run build`
62 | **Dev**: `npm run dev`
63 | **Demo Run**: `npm run demo:start`
64 | **Demo Build**: `npm run demo:build`
65 | **Demo Clean**: `npm run demo:clean`
66 | 67 | ### Demo 68 | 69 | The demo is bundled with [`Parcel.js`](https://parceljs.org/) and served up at [http://localhost:1234/](http://localhost:1234/). 70 | 71 | ### Dist 72 | 73 | On build, `lib` populates `dist` with commonjs, es, umd versions of the component. 74 | 75 |
76 | 77 | ## 🕹️ Usage 78 | 79 | ``` 80 | import React from "react"; 81 | import AnimatedCursor from "react-animated-cursor" 82 | 83 | 84 | export default function App() { 85 | return ( 86 |
87 | 88 |
89 | ); 90 | } 91 | ``` 92 | 93 | ### Example Usage - with options 94 | 95 | ``` 96 | import React from "react"; 97 | import AnimatedCursor from "react-animated-cursor" 98 | 99 | export default function App() { 100 | return ( 101 |
102 | 123 |
124 | ); 125 | } 126 | ``` 127 | 128 | ### Example Usage - with simple options and custom config for one class 129 | 130 | ``` 131 | import React from "react"; 132 | import AnimatedCursor from "react-animated-cursor" 133 | 134 | export default function App() { 135 | return ( 136 |
137 | 169 |
170 | ); 171 | } 172 | ``` 173 | 174 | ### Client Components, Next.js, SSR 175 | 176 | In previous versions of the component, integration with Next's SSR environment required using a `Dynamic Import`. 177 | However, as of version `2.10.1`, **you _should_ be good to go with a simple `import`.** 178 | 179 | Relevant updates: 180 | 181 | - Included module directive `'use client'` to indicate a client side component. 182 | - Updated `useEventListener` hook with `window` checks. 183 | - Wrapped the `document` use in a check. 184 | 185 | However, if you do run into any issues, you could try including with Dynamic Import. 186 | 187 | **Next's Dynamic Import** 188 | 189 | ``` 190 | 'use client'; // indicates Client Component 191 | 192 | // Import with next's dynamic import 193 | import dynamic from 'next/dynamic'; 194 | 195 | const AnimatedCursor = dynamic(() => import('react-animated-cursor'), { 196 | ssr: false, 197 | }); 198 | 199 | 200 | ``` 201 | 202 |
203 | 204 | ## 🧬 Options 205 | 206 | 207 | | Option | Type | Description | Default | 208 | | ---- | ---- | -------- | -------| 209 | | `clickables` | array | Collection of selectors cursor that trigger cursor interaction or object with single target and possibly the rest of the options listed below | `['a', 'input[type="text"]', 'input[type="email"]', 'input[type="number"]', 'input[type="submit"]', 'input[type="image"]', 'label[for]', 'select', 'textarea', 'button', '.link']` | 210 | | `color` | string | rgb value | `220, 90, 90` | 211 | | `innerScale` | number | amount dot scales on click or link hover | `0.7` | 212 | | `innerSize` | number | Size (px) of inner cursor dot | `8` | 213 | | `innerStyle` | object | provides custom styles / css to inner cursor | `null` | 214 | | `outerAlpha` | number | amount of alpha transparency for outer cursor dot | `0.4` | 215 | | `outerScale` | number | amount outer dot scales on click or link hover | `5` | 216 | | `outerSize` | number | Size (px) of outer cursor outline | `8` | 217 | | `outerStyle` | object | provides custom styles / css to outer cursor | `null` | 218 | | `showSystemCursor` | boolean | Show system/brower cursor | `false` | 219 | | `trailingSpeed` | number | Outer dot's trailing speed | `8` | 220 | 221 |
222 | 223 | ## 🎨 Cursor Types 224 | 225 | You can use the `innerStyle` and `outerStyle` props to provide custom styles and create a variery of custom cursor types. Additionally, you can pass custom styles and css vars to create unique cursors or update style based on events. 226 | 227 | ### Dynamic Styles 228 | 229 | Use CSS variables with `innerStyle` and `outerStyle` props to create dynamic styles that you can easily update. 230 | For example, perhaps you have a light and dark mode experience and what your cursor to also adapt it's colors. 231 | 232 | **CSS Vars** 233 | 234 | ``` 235 | html { 236 | --cursor-color: #333 237 | } 238 | 239 | html.dark-mode { 240 | --cursor-color: #fff 241 | } 242 | ``` 243 | 244 | **Pass CSS Var as Style Props** 245 | 246 | ``` 247 | 260 | ``` 261 | 262 | ### Donut Cursor 263 | 264 | A donut style cursor basically resembles a donut. You can easily create on by applying using the `outerStyle` props to apply an outer border 265 | 266 | ``` 267 | 281 | ``` 282 | 283 | [Donut Demo→](https://stephenscaff.github.io/react-animated-cursor?cursor=donut) 284 | 285 |
286 | 287 | ### Blend Mode Cursor 288 | 289 | You can use CSS mix-blend-mode with the style props to create an intersting cursor effect on hover that inverts the content's color. Works best with white / black cursors. 290 | 291 | ``` 292 | 303 | ``` 304 | 305 | [Blend Mode Demo→](https://stephenscaff.github.io/react-animated-cursor?cursor=blendmode) 306 | 307 |
308 | 309 | ## 📓 Notes 310 | 311 | ### Mobile / Touch 312 | 313 | `helpers/isDevice.js` uses UA sniffing to determine if on a common device so we can avoid rendering cursors. Yes... I know, there are other and probably better ways to handle this. Whatevers. 314 | 315 |
316 | 317 | ## 📅 To Dos 318 | 319 | - ~~Either remove on mobile, or provide touch events.~~ 320 | - ~~Separate click and hover scalings to provide a different scaling when clicking on links/clickables~~ 321 | - ~~Fix transform blur in Safari, which may mean migrating from `scale` to a `width` &`height` update~~ 4/4/23 322 | - ~~Make clickables (cursor targets / selectors) a prop~~ 323 | - ~~Add PropType checks~~ 324 | - ~~Open cursor styles as props~~ 325 | - ~~Add ability to maintain system cursor for the squeamish~~ 4/4/23 326 | - ~~Migrate to TS~~ 327 | - ~~Allow for different behavior based on the element hovered~~ 328 | - Options to control cursor transition speed and bezier 329 | - Solution for impacting state during route changes 330 | 331 | - Add some proper tests 332 | 333 |
334 | 335 | Have fun ya'll. 336 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import external from 'rollup-plugin-peer-deps-external' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import typescript from '@rollup/plugin-typescript' 5 | import dts from 'rollup-plugin-dts' 6 | import pkg from './package.json' assert { type: 'json' } 7 | 8 | const umdGlobals = { 9 | react: 'React', 10 | 'react-animated-cursor': 'AnimatedCursor', 11 | 'react/jsx-runtime': 'jsxRuntime' 12 | } 13 | 14 | const config = [ 15 | { 16 | external: ['react', 'react-dom'], 17 | input: 'lib/index.ts', 18 | output: [ 19 | { 20 | file: pkg.main, 21 | format: 'cjs', 22 | banner: "'use client';" 23 | }, 24 | { 25 | file: pkg.module, 26 | format: 'esm', 27 | banner: "'use client';" 28 | }, 29 | { 30 | file: pkg.browser, 31 | format: 'umd', 32 | name: 'AnimatedCursor', 33 | globals: umdGlobals, 34 | banner: "'use client';" 35 | } 36 | ], 37 | plugins: [ 38 | external(), 39 | resolve(), 40 | commonjs(), 41 | typescript({ 42 | exclude: 'node_modules' 43 | }) 44 | ] 45 | }, 46 | { 47 | external: ['react', 'react-dom'], 48 | input: 'lib/index.ts', 49 | output: [{ file: pkg.types, format: 'es' }], 50 | plugins: [external(), resolve(), dts()] 51 | } 52 | ] 53 | 54 | export default config 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "moduleResolution": "node", 5 | "target": "es5", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "allowJs": true 9 | }, 10 | "include": ["lib"], 11 | "exclude": ["node_modules"] 12 | } 13 | --------------------------------------------------------------------------------