Skip to content

Instantly share code, notes, and snippets.

@baktiaditya
Last active January 15, 2024 17:26
Show Gist options
  • Save baktiaditya/41d83e04972cca950b59bb7ac83e7930 to your computer and use it in GitHub Desktop.
Save baktiaditya/41d83e04972cca950b59bb7ac83e7930 to your computer and use it in GitHub Desktop.
Modified version of react-animated-cursor
// Next.js _app.tsx
const router = useRouter();
const cursorRef = React.useRef<CursorRef>(null);
React.useEffect(() => {
router.events.on('routeChangeComplete', progressDone);
router.events.on('routeChangeError', progressDone);
return () => {
router.events.off('routeChangeComplete', progressDone);
router.events.off('routeChangeError', progressDone);
};
}, []);
const progressDone = () => {
const { current: cursor } = cursorRef;
if (cursor) {
cursor.update();
}
};
return (
<Cursor ref={cursorRef} />
);
import React, { Fragment } from 'react';
import { isDevice, usePrevious } from './helpers';
type CursorProps = {
color?: string;
hideNativeCursor?: boolean;
innerAlpha?: number;
innerScale?: number;
innerSize?: number;
outerAlpha?: number;
outerScale?: number;
outerSize?: number;
trailingSpeed?: number;
zIndex?: number;
};
export type CursorRef = {
update: () => void;
};
const Cursor = React.forwardRef<CursorRef, CursorProps>((props, ref) => {
const {
color = '#dc5a5a',
hideNativeCursor = true,
innerAlpha = 1,
innerScale = 0.7,
innerSize = 8,
outerAlpha = 0.3,
outerScale = 5,
outerSize = 18,
trailingSpeed = 8,
zIndex = 999,
} = props;
const clickableTargets = [
'a',
'input[type="text"]',
'input[type="email"]',
'input[type="number"]',
'input[type="submit"]',
'input[type="image"]',
'label[for]',
'select',
'textarea',
'button',
'.link',
];
const cursorOuterRef = React.useRef<HTMLDivElement>(null);
const cursorInnerRef = React.useRef<HTMLDivElement>(null);
const requestRef = React.useRef<number>();
const previousTimeRef = React.useRef();
const [coords, setCoords] = React.useState({ x: 0, y: 0 });
const [isVisible, setIsVisible] = React.useState(false);
const [isActive, setIsActive] = React.useState(false);
const [isActiveClickable, setIsActiveClickable] = React.useState(false);
const endX = React.useRef(0);
const endY = React.useRef(0);
const prevProps = usePrevious<CursorProps>(props);
// Expose ref
React.useImperativeHandle(ref, () => ({
update: () => {
removeClickableEvents();
addClickableEvents();
// Check if the cursor should inactive or not
const elementMouseIsOver = document.elementFromPoint(coords.x, coords.y);
const clickables = document.querySelectorAll(clickableTargets.join(','));
let shouldInactive = true;
for (let index = 0; index < clickables.length; index++) {
const element = clickables[index];
if (element.contains(elementMouseIsOver)) {
shouldInactive = false;
break;
}
}
shouldInactive && setIsActive(false);
},
}));
// Did mount
React.useEffect(() => {
if (isDevice && isDevice.any()) {
return;
}
// Hide / Show global cursor
if (hideNativeCursor) {
document.body.style.cursor = 'none';
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('mouseover', onMouseEnterViewport);
window.addEventListener('mouseout', onMouseLeaveViewport);
addClickableEvents();
return () => {
document.body.style.removeProperty('cursor');
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('mouseover', onMouseEnterViewport);
window.removeEventListener('mouseout', onMouseLeaveViewport);
removeClickableEvents();
};
}, []);
// Did update
React.useEffect(() => {
if (prevProps && prevProps.hideNativeCursor !== hideNativeCursor) {
if (hideNativeCursor) {
document.body.style.cursor = 'none';
} else {
document.body.style.removeProperty('cursor');
}
}
}, [hideNativeCursor]);
// Outer Cursor Animation Delay
const animateOuterCursor = React.useCallback(
time => {
if (previousTimeRef.current !== undefined) {
coords.x += (endX.current - coords.x) / trailingSpeed;
coords.y += (endY.current - coords.y) / trailingSpeed;
const { current: cursorOuter } = cursorOuterRef;
if (!cursorOuter) return;
cursorOuter.style.top = `${coords.y}px`;
cursorOuter.style.left = `${coords.x}px`;
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animateOuterCursor);
},
[requestRef],
);
// RAF for animateOuterCursor
React.useEffect(() => {
requestRef.current = requestAnimationFrame(animateOuterCursor);
return () => {
if (typeof requestRef.current === 'number') {
cancelAnimationFrame(requestRef.current);
}
};
}, [animateOuterCursor]);
// Mouse Events State updates
// Primary Mouse Move event
const onMouseMove = React.useCallback(({ clientX, clientY }) => {
setCoords({ x: clientX, y: clientY });
const { current: cursorInner } = cursorInnerRef;
if (!cursorInner) return;
cursorInner.style.top = `${clientY}px`;
cursorInner.style.left = `${clientX}px`;
endX.current = clientX;
endY.current = clientY;
}, []);
const onMouseDown = React.useCallback(() => {
setIsActive(true);
}, []);
const onMouseUp = React.useCallback(() => {
setIsActive(false);
}, []);
const onMouseEnterViewport = React.useCallback(() => {
setIsVisible(true);
}, []);
const onMouseLeaveViewport = React.useCallback(() => {
setIsVisible(false);
}, []);
// Cursors Hover/Active State
React.useEffect(() => {
const { current: cursorInner } = cursorInnerRef;
const { current: cursorOuter } = cursorOuterRef;
if (!cursorInner || !cursorOuter) return;
if (isActive) {
cursorInner.style.transform = `translate(-50%, -50%) scale(${innerScale})`;
cursorOuter.style.transform = `translate(-50%, -50%) scale(${outerScale})`;
} else {
cursorInner.style.transform = 'translate(-50%, -50%) scale(1)';
cursorOuter.style.transform = 'translate(-50%, -50%) scale(1)';
}
}, [innerScale, outerScale, isActive]);
// Cursors Click States
React.useEffect(() => {
const { current: cursorInner } = cursorInnerRef;
const { current: cursorOuter } = cursorOuterRef;
if (!cursorInner || !cursorOuter) return;
if (isActiveClickable) {
cursorInner.style.transform = `translate(-50%, -50%) scale(${innerScale * 1.2})`;
cursorOuter.style.transform = `translate(-50%, -50%) scale(${outerScale * 1.4})`;
}
}, [innerScale, outerScale, isActiveClickable]);
// Cursor Visibility State
React.useEffect(() => {
const { current: cursorInner } = cursorInnerRef;
const { current: cursorOuter } = cursorOuterRef;
if (!cursorInner || !cursorOuter) return;
if (isVisible) {
cursorInner.style.opacity = String(innerAlpha);
cursorOuter.style.opacity = String(outerAlpha);
} else {
cursorInner.style.opacity = '0';
cursorOuter.style.opacity = '0';
}
}, [isVisible]);
// Target all possible clickables
const onClickableMouseOver = React.useCallback(() => {
setIsActive(true);
}, []);
const onClickableClick = React.useCallback(() => {
setIsActive(true);
setIsActiveClickable(false);
}, []);
const onClickableMouseDown = React.useCallback(() => {
setIsActiveClickable(true);
}, []);
const onClickableMouseUp = React.useCallback(() => {
setIsActive(true);
}, []);
const onClickableMouseOut = React.useCallback(() => {
setIsActive(false);
setIsActiveClickable(false);
}, []);
const addClickableEvents = React.useCallback(() => {
const clickables = document.querySelectorAll(clickableTargets.join(','));
clickables.forEach(el => {
if (hideNativeCursor) {
(el as HTMLElement).style.cursor = 'none';
} else {
(el as HTMLElement).style.removeProperty('cursor');
}
el.addEventListener('mouseover', onClickableMouseOver);
el.addEventListener('click', onClickableClick);
el.addEventListener('mousedown', onClickableMouseDown);
el.addEventListener('mouseup', onClickableMouseUp);
el.addEventListener('mouseout', onClickableMouseOut);
});
}, []);
const removeClickableEvents = React.useCallback(() => {
const clickables = document.querySelectorAll(clickableTargets.join(','));
clickables.forEach(el => {
el.removeEventListener('mouseover', onClickableMouseOver);
el.removeEventListener('click', onClickableClick);
el.removeEventListener('mousedown', onClickableMouseDown);
el.removeEventListener('mouseup', onClickableMouseUp);
el.removeEventListener('mouseout', onClickableMouseOut);
});
}, []);
// Cursor Styles
const styles: { cursorInner: React.CSSProperties; cursorOuter: React.CSSProperties } = {
cursorInner: {
zIndex,
display: 'block',
position: 'fixed',
borderRadius: '50%',
width: innerSize,
height: innerSize,
pointerEvents: 'none',
backgroundColor: color,
transition: 'opacity 0.15s ease-in-out, transform 0.25s ease-in-out',
opacity: 0,
},
cursorOuter: {
zIndex,
display: 'block',
position: 'fixed',
borderRadius: '50%',
pointerEvents: 'none',
width: outerSize,
height: outerSize,
backgroundColor: color,
transition: 'opacity 0.15s ease-in-out, transform 0.15s ease-in-out',
willChange: 'transform',
opacity: 0,
},
};
return (
<Fragment>
<div ref={cursorOuterRef} style={styles.cursorOuter} />
<div ref={cursorInnerRef} style={styles.cursorInner} />
</Fragment>
);
});
export default Cursor;
import React from 'react';
export const isDevice = (() => {
if (typeof navigator === 'undefined') return;
const ua = navigator.userAgent;
return {
info: ua,
Android() {
return ua.match(/Android/i);
},
BlackBerry() {
return ua.match(/BlackBerry/i);
},
IEMobile() {
return ua.match(/IEMobile/i);
},
iOS() {
return ua.match(/iPhone|iPad|iPod/i);
},
OperaMini() {
return ua.match(/Opera Mini/i);
},
/**
* Any Device
*/
any() {
return (
this.Android() || this.BlackBerry() || this.iOS() || this.OperaMini() || this.IEMobile()
);
},
};
})();
/**
* @example const prevProps = usePrevious<PropsDefinition>(props);
*/
export const usePrevious = <T>(value: T) => {
const ref = React.useRef<T>();
React.useEffect(() => {
ref.current = value;
});
return ref.current;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment