├── .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 |
54 |
55 | An inner dot (cursorInner
)
56 |
57 |
58 | An outer, outlining circle (cursorOuter
), with slight
59 | opacity based on the dot/primary color
60 |
61 |
62 | An inversely scaling effect between the inner and outer cursor parts
63 | on click or link hover
64 |
65 |
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 |
20 |
21 | Class name ="small"
22 |
23 |
24 | Class name ="big"
25 |
26 |
27 | Class name ="blue"
28 |
29 |
30 | Id ="blueDonut"
31 |
32 |
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 |
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 |
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 |
17 | You need to enable JavaScript to run this app.
18 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------