├── .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 |
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 | [](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 |
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 |
--------------------------------------------------------------------------------