├── .nvmrc ├── package.json ├── assets ├── 16.png ├── dot.png ├── mac.png ├── other.png ├── mac_retina.png ├── mac_pointer.png ├── transparent.png ├── other_pointer.png ├── mac_pointer_retina.png └── bg.svg ├── control-user-cursor.jpg ├── docs-assets ├── screenshot.png └── thumbnail.jpg ├── .editorconfig ├── .eslintrc.js ├── .prettierrc ├── .gitignore ├── README.md ├── LICENSE ├── setup.js ├── index.html ├── style.css └── index.js /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.15 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "eslint": "^8.17.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /assets/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/16.png -------------------------------------------------------------------------------- /assets/dot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/dot.png -------------------------------------------------------------------------------- /assets/mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/mac.png -------------------------------------------------------------------------------- /assets/other.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/other.png -------------------------------------------------------------------------------- /assets/mac_retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/mac_retina.png -------------------------------------------------------------------------------- /assets/mac_pointer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/mac_pointer.png -------------------------------------------------------------------------------- /assets/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/transparent.png -------------------------------------------------------------------------------- /control-user-cursor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/control-user-cursor.jpg -------------------------------------------------------------------------------- /assets/other_pointer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/other_pointer.png -------------------------------------------------------------------------------- /docs-assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/docs-assets/screenshot.png -------------------------------------------------------------------------------- /docs-assets/thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/docs-assets/thumbnail.jpg -------------------------------------------------------------------------------- /assets/mac_pointer_retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/javierbyte/control-user-cursor/HEAD/assets/mac_pointer_retina.png -------------------------------------------------------------------------------- /assets/bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | max_line_length = off 12 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: 'eslint:recommended', 7 | parserOptions: { 8 | ecmaVersion: 'latest', 9 | sourceType: 'module', 10 | }, 11 | rules: {}, 12 | }; 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 80, 4 | "editor.formatOnSave": true, 5 | "proseWrap": "always", 6 | "tabWidth": 2, 7 | "requireConfig": false, 8 | "useTabs": false, 9 | "trailingComma": "es5", 10 | "bracketSpacing": true, 11 | "jsxBracketSameLine": false, 12 | "semi": true 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /dist 5 | .vercel 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Control User Cursor 2 | 3 | Small experiment to 'control' the user cursor with JavaScript and CSS. 4 | 5 | [![control-user-cursor](docs-assets/thumbnail.jpg)](https://javier.xyz/control-user-cursor/) 6 | 7 | ## How it works? 8 | 9 | I make the user cursor invisible, and then paint my own cursor with JS! The 10 | `:hover` styles are also fake. 11 | 12 | Most of the math is here 13 | https://github.com/javierbyte/control-user-cursor/blob/master/index.js#L134 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Javier Bórquez 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | // EVENTS 2 | let showCursor = true; 3 | const toggleCursorEl = document.querySelector('[data-toggle-cursor]'); 4 | toggleCursorEl.addEventListener('click', () => { 5 | showCursor = !showCursor; 6 | 7 | if (showCursor) { 8 | toggleCursorEl.innerHTML = 'Show Real Cursor'; 9 | document.documentElement.style.cursor = 'none'; 10 | } else { 11 | toggleCursorEl.innerHTML = 'Hide Real Cursor'; 12 | document.documentElement.style.cursor = 'default'; 13 | } 14 | }); 15 | 16 | if ('ontouchstart' in document.documentElement) { 17 | document.querySelector('.info-description').innerHTML += 18 | "
Doesn't work with touchscreens tho... :(
"; 19 | } 20 | 21 | document.querySelector(`[data-new-random]`).addEventListener('click', (evt) => { 22 | evt.preventDefault(); 23 | 24 | const amountOfElements = 25 | (Math.random() > 0.8 ? 5 : 2) + Math.floor(Math.random() * 8); 26 | 27 | const newConfig = new Array(amountOfElements).fill('').map(() => { 28 | return Math.random() > 0.5 29 | ? { 30 | behavior: 'REPEL', 31 | innerHTML: 'Repel', 32 | className: ['clickme', '-nope'], 33 | position: [ 34 | Math.floor(Math.random() * 92) + 4, 35 | Math.floor(Math.random() * 92) + 4, 36 | ], 37 | } 38 | : { 39 | behavior: 'ATTRACT', 40 | innerHTML: 'Attract', 41 | className: ['clickme'], 42 | position: [ 43 | Math.floor(Math.random() * 92) + 4, 44 | Math.floor(Math.random() * 92) + 4, 45 | ], 46 | }; 47 | }); 48 | 49 | window.ControlUserCursor(newConfig); 50 | }); 51 | 52 | // INITIALIZE 53 | window.ControlUserCursor([ 54 | { 55 | behavior: 'REPEL', 56 | innerHTML: 'Repel', 57 | className: ['clickme', '-nope'], 58 | position: [60, 33], 59 | }, 60 | { 61 | behavior: 'ATTRACT', 62 | innerHTML: 'Attract', 63 | className: ['clickme'], 64 | position: [40, 66], 65 | }, 66 | ]); 67 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Control User Cursor 10 | 11 | 12 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 |
39 |

Control User Cursor

40 | 41 |
42 | Small experiment to alter the cursor behavior. 43 | 47 | How it works?, 49 | 50 | Show Real Cursor 51 | 52 |
53 | 54 | 55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | position: relative; 5 | box-sizing: border-box; 6 | pointer-events: none; 7 | } 8 | 9 | html { 10 | cursor: none; 11 | -moz-user-select: none; 12 | user-select: none; 13 | -webkit-user-select: none; 14 | } 15 | 16 | body, 17 | html { 18 | font-size: 15px; 19 | } 20 | 21 | body { 22 | font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Arial, 23 | sans-serif; 24 | background-color: #f8f8f8; 25 | color: #000; 26 | background-image: url(/control-user-cursor/assets/bg.svg); 27 | background-repeat: repeat; 28 | } 29 | 30 | h1 { 31 | font-weight: 900; 32 | font-size: 2rem; 33 | line-height: 1; 34 | } 35 | 36 | a { 37 | color: #2980b9; 38 | } 39 | a:hover { 40 | color: #2980b9; 41 | } 42 | a.-hover { 43 | background: #000; 44 | color: #fff; 45 | } 46 | 47 | button { 48 | font-weight: 900; 49 | border: none; 50 | appearance: none; 51 | font-size: 16px; 52 | background-color: white; 53 | line-height: 1; 54 | padding: 0.75rem; 55 | box-shadow: rgba(0, 0, 0, 0.1) 0 1px 0, rgba(0, 0, 0, 0.1) 0 2px 16px; 56 | } 57 | button.-hover { 58 | background: #000; 59 | color: #fff; 60 | } 61 | 62 | .info { 63 | position: fixed; 64 | top: 1.5rem; 65 | left: 1rem; 66 | line-height: 1; 67 | } 68 | 69 | .info-description { 70 | padding: 0.5rem 0 1rem; 71 | } 72 | 73 | .-prevent-custom-cursor:hover { 74 | cursor: none; 75 | } 76 | 77 | #cursor { 78 | opacity: 0; 79 | margin-top: -2px; 80 | margin-left: -2px; 81 | position: fixed; 82 | z-index: 2; 83 | pointer-events: none; 84 | } 85 | 86 | .clickme { 87 | --border-width: 7px; 88 | --gravity-area: 200px; 89 | position: fixed; 90 | display: block; 91 | width: 64px; 92 | height: 64px; 93 | border-radius: 64px; 94 | background: #2980b9; 95 | text-align: center; 96 | line-height: 64px; 97 | top: 50%; 98 | left: 50%; 99 | text-transform: uppercase; 100 | font-weight: 700; 101 | text-decoration: none; 102 | font-size: 12px; 103 | color: #fff; 104 | font-weight: 500; 105 | } 106 | 107 | .clickme::before { 108 | content: ''; 109 | position: absolute; 110 | top: 50%; 111 | left: 50%; 112 | border-radius: 100%; 113 | display: block; 114 | height: calc(var(--gravity-area) - 2 * var(--border-width)); 115 | width: calc(var(--gravity-area) - 2 * var(--border-width)); 116 | margin-top: calc(var(--gravity-area) * -0.5); 117 | margin-left: calc(var(--gravity-area) * -0.5); 118 | border: var(--border-width) solid #2980b9; 119 | animation: 4s infinite radar reverse; 120 | filter: blur(4px); 121 | } 122 | 123 | .clickme::after { 124 | content: ''; 125 | position: absolute; 126 | top: 50%; 127 | left: 50%; 128 | border-radius: 100%; 129 | display: block; 130 | height: calc(var(--gravity-area) - 2 * var(--border-width)); 131 | width: calc(var(--gravity-area) - 2 * var(--border-width)); 132 | margin-top: calc(var(--gravity-area) * -0.5); 133 | margin-left: calc(var(--gravity-area) * -0.5); 134 | border: var(--border-width) solid #2980b9; 135 | animation: 4s -2s infinite radar reverse; 136 | filter: blur(4px); 137 | } 138 | 139 | @keyframes radar { 140 | 0% { 141 | transform: scale(0); 142 | opacity: 0.05; 143 | } 144 | 145 | 50% { 146 | opacity: 0.05; 147 | } 148 | 149 | 100% { 150 | transform: scale(2); 151 | opacity: 0; 152 | } 153 | } 154 | 155 | .clickme.-nope { 156 | background: #c0392b; 157 | } 158 | .clickme.-nope::before, 159 | .clickme.-nope::after { 160 | border-color: #c0392b; 161 | } 162 | .clickme.-nope::before { 163 | animation: 4s infinite radar; 164 | } 165 | .clickme.-nope::after { 166 | animation: 4s -2s infinite radar; 167 | } 168 | 169 | .clickme.-hover { 170 | opacity: 0.8; 171 | } 172 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // config 2 | 3 | const ASSET_CONFIG = { 4 | mac_retina: { 5 | normal: { 6 | src: '/control-user-cursor/assets/mac_retina.png', 7 | height: '22px', 8 | width: '15px', 9 | }, 10 | pointer: { 11 | src: '/control-user-cursor/assets/mac_pointer_retina.png', 12 | width: '18.5px', 13 | height: '19.5px', 14 | }, 15 | }, 16 | mac: { 17 | normal: { 18 | src: '/control-user-cursor/assets/mac.png', 19 | width: '15px', 20 | height: '22px', 21 | }, 22 | pointer: { 23 | src: '/control-user-cursor/assets/mac_pointer.png', 24 | width: '18px', 25 | height: '19px', 26 | }, 27 | }, 28 | other: { 29 | normal: { 30 | src: '/control-user-cursor/assets/other.png', 31 | width: '17px', 32 | height: '23px', 33 | }, 34 | pointer: { 35 | src: '/control-user-cursor/assets/other_pointer.png', 36 | width: '22px', 37 | height: '26px', 38 | }, 39 | }, 40 | }; 41 | 42 | function getCursorInfo() { 43 | let navigator = window.navigator.platform.indexOf('Mac') > -1 ? 'mac' : 'win'; 44 | 45 | if (window.devicePixelRatio > 1) { 46 | navigator += '_retina'; 47 | } 48 | 49 | if (Object.keys(ASSET_CONFIG).includes(navigator)) { 50 | return ASSET_CONFIG[navigator]; 51 | } 52 | 53 | return ASSET_CONFIG.other; 54 | } 55 | 56 | const global = { 57 | mouseX: 0, 58 | mouseY: 0, 59 | trackedAstros: [], 60 | hoverTrackedElements: [], 61 | cursorInfo: getCursorInfo(), 62 | isMouseVisible: false, 63 | }; 64 | const containerEl = document.querySelector('#container'); 65 | const cursorEl = document.querySelector('[data-cursor]'); 66 | 67 | setCursor(global.cursorInfo.normal); 68 | 69 | function setCursor(cursorConfig) { 70 | cursorEl.src = cursorConfig.src; 71 | cursorEl.style.width = cursorConfig.width; 72 | cursorEl.style.height = cursorConfig.height; 73 | } 74 | 75 | window.ControlUserCursor = function ControlUserCursor(config) { 76 | containerEl.innerHTML = ''; 77 | 78 | global.trackedAstros = config.map((newAstroConfig) => { 79 | const astroEl = document.createElement('div'); 80 | astroEl.className = newAstroConfig.className.join(' '); 81 | astroEl.innerHTML = newAstroConfig.innerHTML; 82 | astroEl.style.left = `${newAstroConfig.position[0]}%`; 83 | astroEl.style.top = `${newAstroConfig.position[1]}%`; 84 | containerEl.appendChild(astroEl); 85 | 86 | const clientRect = astroEl.getBoundingClientRect(); 87 | 88 | return { 89 | el: astroEl, 90 | center: { 91 | x: clientRect.left + clientRect.width / 2, 92 | y: clientRect.top + clientRect.height / 2, 93 | }, 94 | ...newAstroConfig, 95 | }; 96 | }); 97 | 98 | onUpdateElementSizes(); 99 | }; 100 | 101 | // UTILS 102 | function polar2cartesian({ distance, angle }) { 103 | return { 104 | x: distance * Math.cos(angle), 105 | y: distance * Math.sin(angle), 106 | }; 107 | } 108 | 109 | function cartesian2polar({ x, y }) { 110 | return { 111 | distance: Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)), 112 | angle: Math.atan2(y, x), 113 | }; 114 | } 115 | 116 | const SHORT_RANGE = 0; 117 | const LONG_RANGE = 320; 118 | 119 | function calculateNewCursorPosition(cursor, objects) { 120 | let newCursor = { ...cursor }; 121 | 122 | for (const object of objects) { 123 | const polar = cartesian2polar({ 124 | x: cursor.x - object.center.x, 125 | y: cursor.y - object.center.y, 126 | }); 127 | 128 | let intensity = 0; 129 | if (polar.distance < SHORT_RANGE) { 130 | intensity = 1; 131 | } else if (polar.distance > SHORT_RANGE + LONG_RANGE) { 132 | intensity = 0; 133 | } else { 134 | intensity = 1 - (polar.distance - SHORT_RANGE) / LONG_RANGE; 135 | } 136 | 137 | let newDistance = 0; 138 | if (object.behavior === 'REPEL') { 139 | newDistance = polar.distance + intensity * -40; 140 | } 141 | if (object.behavior === 'ATTRACT') { 142 | newDistance = 143 | polar.distance * (1 - intensity) + polar.distance * 1.5 * intensity; 144 | } 145 | 146 | const modifiedCartesian = polar2cartesian({ 147 | angle: polar.angle, 148 | distance: newDistance, 149 | }); 150 | const paddedModifiedCartesian = { 151 | x: cursor.x - modifiedCartesian.x - object.center.x, 152 | y: cursor.y - modifiedCartesian.y - object.center.y, 153 | }; 154 | 155 | newCursor = { 156 | x: newCursor.x + paddedModifiedCartesian.x, 157 | y: newCursor.y + paddedModifiedCartesian.y, 158 | }; 159 | } 160 | return newCursor; 161 | } 162 | 163 | // iterate over the elements to see if we need to hover anyone 164 | function calculateHover(newCursor) { 165 | let someHovering = false; 166 | global.hoverTrackedElements.forEach((trackedObj) => { 167 | let isHovering = false; 168 | 169 | if ( 170 | trackedObj.rect.x < newCursor.x && 171 | trackedObj.rect.x + trackedObj.rect.width > newCursor.x && 172 | trackedObj.rect.y < newCursor.y && 173 | trackedObj.rect.y + trackedObj.rect.height > newCursor.y 174 | ) { 175 | isHovering = true; 176 | } 177 | 178 | if (isHovering === true) { 179 | trackedObj.el.classList.add('-hover'); 180 | } else if (someHovering === false) { 181 | trackedObj.el.classList.remove('-hover'); 182 | } 183 | 184 | someHovering = someHovering || isHovering; 185 | }); 186 | 187 | if (someHovering === true) { 188 | setCursor(global.cursorInfo.pointer); 189 | } else if (someHovering === false) { 190 | setCursor(global.cursorInfo.normal); 191 | } 192 | } 193 | 194 | // remove the fake cursor when the user moves the real out of the window 195 | function onMouseOut() { 196 | cursorEl.style.opacity = 0; 197 | global.isMouseVisible = false; 198 | } 199 | 200 | // main function that calculates the fake cursor position 201 | function onMouseMove(evt) { 202 | global.mouseX = evt.clientX; 203 | global.mouseY = evt.clientY; 204 | } 205 | 206 | function onClick(evt) { 207 | if (!evt.isTrusted) return; 208 | 209 | const clickedEl = document.querySelector('.-hover'); 210 | 211 | if (!clickedEl) return; 212 | 213 | clickedEl.click(); 214 | } 215 | 216 | function onUpdateElementSizes() { 217 | global.hoverTrackedElements = [ 218 | ...document.querySelectorAll('.-prevent-custom-cursor'), 219 | ].map((el) => { 220 | return { 221 | el, 222 | rect: el.getBoundingClientRect(), 223 | }; 224 | }); 225 | 226 | global.trackedAstros = global.trackedAstros.map((astro) => { 227 | const clientRect = astro.el.getBoundingClientRect(); 228 | 229 | return { 230 | ...astro, 231 | center: { 232 | x: clientRect.left + clientRect.width / 2, 233 | y: clientRect.top + clientRect.height / 2, 234 | }, 235 | }; 236 | }); 237 | } 238 | 239 | window.addEventListener('click', onClick); 240 | window.addEventListener('resize', onUpdateElementSizes); 241 | window.addEventListener('mouseout', onMouseOut); 242 | window.addEventListener('contextmenu', (event) => event.preventDefault()); 243 | window.addEventListener('mousemove', onMouseMove); 244 | 245 | // RENDER 246 | function render() { 247 | if (global.isMouseVisible === false) { 248 | cursorEl.style.opacity = 1; 249 | global.isMouseVisible = true; 250 | } 251 | 252 | const calculatedCursor = calculateNewCursorPosition( 253 | { 254 | x: global.mouseX, 255 | y: global.mouseY, 256 | }, 257 | global.trackedAstros 258 | ); 259 | 260 | calculateHover(calculatedCursor); 261 | 262 | cursorEl.style.transform = 263 | 'translatex(' + 264 | calculatedCursor.x + 265 | 'px) translatey(' + 266 | calculatedCursor.y + 267 | 'px)'; 268 | 269 | window.requestAnimationFrame(render); 270 | } 271 | render(); 272 | --------------------------------------------------------------------------------