Button that runs from your cursor
There is something adorable about a cheeky button that runs away from your cursor. I wrote one about a year ago to hide in a side project.
Two distinct functions are running here. First, if the mouse starts getting close, the button slowly runs away from the cursor. Second, if the cursor passes a threshold or if the button is pushed out of the container, it jumps to a new position.
Component Setup
I wrote this in React but the concepts are the same for any framework.
First, we need to set up the states for the button. We'll need to keep track of the two offset types for the button and the disabled state. We also need to define the two functions that handle the two movement types.
export default function CheekyButton() {
// Snap offset for the button
const [x, setX] = useState(0);
const [y, setY] = useState(0);
// Slow move offset for the button
const [outerX, setOuterX] = useState(0);
const [outerY, setOuterY] = useState(0);
// For disabling the button when it's moving
const [disabled, setDisabled] = useState(false);
// Will slowly move the button away from the cursor when close
function onOuterMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
// ...
}
// Will reposition the mouse enters some threshold
function onMouseEnter(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
// ...
}
// ...
}
Next for the HTML. First, we need a wrapping container to define the space the button can move within. Then we need a wrapper that triggers the slow-run-away effect when cursor moves within it. Finally, we need a wrapper that snaps the button to a new position when entered.
export default function CheekyButton() {
// ... Setup ...
// Inner threshold wrapper to snap the button to a new position
const ref = useRef<HTMLDivElement>(null);
// Outer wrapper to move the button away from the cursor
const outerRef = useRef<HTMLDivElement>(null);
return (
<div className="flex h-full w-full items-center justify-center">
<div
className={`inline-block p-16 ${
disabled ? "pointer-events-none opacity-5" : ""
}`}
onMouseMove={onOuterMove}
ref={outerRef}
style={{
transform: `translate3d(${outerX + x}px, ${outerY + y}px, 0px)`,
transition: "transform 120ms ease",
}}
>
<div
ref={ref}
className="inline-block p-10"
onMouseEnter={onMouseEnter}
onMouseMove={onMouseEnter}
>
<button>Click Me</button>
</div>
</div>
</div>
);
}
Snap to a new position
When the mouse enters the inner threshold, we need to find a new position for the button. We can do that by generating a bunch of random positions inside the wrapper, keeping track of the farthest one from the current cursor position.
Then we can disable the button on a timeout so this function can't be triggered again until the button's done moving.
function randomBetween(min: number, max: number) {
return Math.random() * (max - min) + min;
}
function onMouseEnter(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (disabled) return;
setDisabled(true);
setTimeout(() => {
setDisabled(false);
}, 80);
if (!ref.current) return;
const buttonRect = ref.current.getBoundingClientRect();
const wrapperRect =
ref.current.parentElement?.parentElement?.getBoundingClientRect();
if (!wrapperRect || !buttonRect) return;
const cursorX = e.clientX - wrapperRect.left;
const cursorY = e.clientY - wrapperRect.top;
// Generate 10 random positions inside the wrapper
// and keep track of the farthest one
let maxDistance = 0;
let farthestPosition = { x: 0, y: 0 };
for (let i = 0; i < 10; i++) {
const randomX = randomBetween(
buttonRect.width / 2,
wrapperRect.width - buttonRect.width / 2,
);
const randomY = randomBetween(
buttonRect.height / 2,
wrapperRect.height - buttonRect.height / 2,
);
const distance = Math.sqrt(
Math.pow(randomX - cursorX, 2) + Math.pow(randomY - cursorY, 2),
);
if (distance > maxDistance) {
maxDistance = distance;
farthestPosition = { x: randomX, y: randomY };
}
}
// Set the button's translation to the farthest position
setX(farthestPosition.x - wrapperRect.width / 2);
setY(farthestPosition.y - wrapperRect.height / 2);
// Reset the slow move offset covered next
setOuterX(0);
setOuterY(0);
}
Slow move away
To move the button away from the cursor when it's close, we need to know the position of the cursor relative to the center of the button. We can take that offset and add a fraction of that to the button's position to move it away.
While the cursor is moving, we also need to check if the new button position is still inside the wrapper. In the case it isn't, we can call the onMouseEnter function defined above to reposition it.
function onOuterMove(e: React.MouseEvent<HTMLDivElement, MouseEvent>) {
if (!outerRef.current) return;
const outerRect = outerRef.current.getBoundingClientRect();
const innerRect = ref.current?.getBoundingClientRect();
const wrapperRect =
ref.current?.parentElement?.parentElement?.getBoundingClientRect();
// if innerRect is outside of wrapper, call onMouseEnter
if (!innerRect || !wrapperRect) return;
if (
innerRect?.left < wrapperRect?.left ||
innerRect?.right > wrapperRect?.right ||
innerRect?.top < wrapperRect?.top ||
innerRect?.bottom > wrapperRect?.bottom
) {
onMouseEnter(e);
return;
}
// Calculate the center of the element
const centerX = outerRect.left + outerRect.width / 2;
const centerY = outerRect.top + outerRect.height / 2;
// Calculate the distance from the cursor to the center of the element
const distanceX = e.clientX - centerX;
const distanceY = e.clientY - centerY;
// Define a fraction of the distance to offset with
const moveY = -distanceY / 140;
const moveX = -distanceX / 140;
// Update the element's position
setOuterX((prevX) => prevX + moveX);
setOuterY((prevY) => prevY + moveY);
}
Check for intersection
This is pretty good, but there are a few improvements to be made. The most obvious is that sometimes the button crosses the path of the cursor when it's moved. A quick fix for this is to drop the opacity to just over 0 when the button starts moving so it's not as noticeable. That is what I'm doing here. However, this visual effect might not be what some people want.
If you want the button visible at all times and for it still not to cross the cursor, you need to check if the new position will cross the cursor's path before updating the button's position.
You can use a line-circle intersection algorithm to check if the line between the button's current position and the new position would intersect a defined radius around the cursor.
function doesLineCrossCircle(
x1: number,
y1: number,
x2: number,
y2: number,
cx: number,
cy: number,
radius: number,
) {
const dx = x2 - x1;
const dy = y2 - y1;
const a = dx * dx + dy * dy;
const b = 2 * (dx * (x1 - cx) + dy * (y1 - cy));
const c = cx * cx + cy * cy;
const c1 = x1 * x1 + y1 * y1;
const c2 = 2 * (cx * x1 + cy * y1);
const det = b * b - 4 * a * (c1 - c2 + c - radius * radius);
return det <= 0;
}
Then we can use it in a check.
if (
!doesLineCrossCircle(
currentBtnCenterX,
currentBtnCenterY,
newX,
newY,
cursorX,
cursorY,
cursorRadius,
)
) {
...
}
There will be edge cases where there will be no new position that doesn't intersect the cursor, like when the button is in a corner. You can fix that by increasing the padding around the wrapper where a new position can be generated, so the button can't get too close to a corner.
This was a fun little interaction to work on and I hope you find an interesting use for it. If you do, let me know on Twitter