Hover the elements below to see the cursor lock onto targets
Add this to your <head> or before closing </body> tag:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
.target-cursor-wrapper {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
pointer-events: none;
z-index: 9999;
mix-blend-mode: difference;
transform: translate(-50%, -50%);
}
.target-cursor-dot {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 4px;
background: #fff;
border-radius: 50%;
transform: translate(-50%, -50%);
will-change: transform;
}
.target-cursor-corner {
position: absolute;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
border: 3px solid #fff;
will-change: transform;
}
.corner-tl {
transform: translate(-150%, -150%);
border-right: none;
border-bottom: none;
}
.corner-tr {
transform: translate(50%, -150%);
border-left: none;
border-bottom: none;
}
.corner-br {
transform: translate(50%, 50%);
border-left: none;
border-top: none;
}
.corner-bl {
transform: translate(-150%, 50%);
border-right: none;
border-top: none;
}
[data-target-cursor] {
cursor: none;
}
Add this cursor element once, anywhere in your <body>:
<div class="target-cursor-wrapper" id="targetCursor">
<div class="target-cursor-dot" id="cursorDot"></div>
<div class="target-cursor-corner corner-tl"></div>
<div class="target-cursor-corner corner-tr"></div>
<div class="target-cursor-corner corner-br"></div>
<div class="target-cursor-corner corner-bl"></div>
</div>
document.addEventListener("DOMContentLoaded", () => {
const TARGET_SELECTOR = "[data-target-cursor]";
const BORDER_WIDTH = 3;
const CORNER_SIZE = 12;
const cursor = document.getElementById("targetCursor");
const dot = document.getElementById("cursorDot");
const corners = cursor.querySelectorAll(".target-cursor-corner");
let spinTl = null;
let activeTarget = null;
let currentLeaveHandler = null;
let resumeTimeout = null;
let targetCornerPositions = null;
const activeStrength = { current: 0 };
// Read data attributes from cursor element
const spinDuration = parseFloat(cursor.dataset.spinDuration) || 2;
const hoverDuration = parseFloat(cursor.dataset.hoverDuration) || 0.2;
const parallax = cursor.dataset.parallax !== "false";
// Hide default cursor on body
document.body.style.cursor = "none";
// Initial position
gsap.set(cursor, {
xPercent: -50,
yPercent: -50,
x: window.innerWidth / 2,
y: window.innerHeight / 2,
});
// Spin timeline
function createSpinTimeline() {
if (spinTl) spinTl.kill();
spinTl = gsap
.timeline({ repeat: -1 })
.to(cursor, { rotation: "+=360", duration: spinDuration, ease: "none" });
}
createSpinTimeline();
// Move cursor
function moveCursor(x, y) {
gsap.to(cursor, { x: x, y: y, duration: 0.1, ease: "power3.out" });
}
// Parallax ticker
function tickerFn() {
if (!targetCornerPositions || !corners.length) return;
var strength = activeStrength.current;
if (strength === 0) return;
var cursorX = gsap.getProperty(cursor, "x");
var cursorY = gsap.getProperty(cursor, "y");
corners.forEach(function (corner, i) {
var currentX = gsap.getProperty(corner, "x");
var currentY = gsap.getProperty(corner, "y");
var targetX = targetCornerPositions[i].x - cursorX;
var targetY = targetCornerPositions[i].y - cursorY;
var finalX = currentX + (targetX - currentX) * strength;
var finalY = currentY + (targetY - currentY) * strength;
var dur = strength >= 0.99 ? (parallax ? 0.2 : 0) : 0.05;
gsap.to(corner, {
x: finalX,
y: finalY,
duration: dur,
ease: dur === 0 ? "none" : "power1.out",
overwrite: "auto",
});
});
}
function cleanupTarget(target) {
if (currentLeaveHandler) {
target.removeEventListener("mouseleave", currentLeaveHandler);
}
currentLeaveHandler = null;
}
// Mouse move
window.addEventListener("mousemove", function (e) {
moveCursor(e.clientX, e.clientY);
});
// Scroll check
window.addEventListener(
"scroll",
function () {
if (!activeTarget) return;
var mouseX = gsap.getProperty(cursor, "x");
var mouseY = gsap.getProperty(cursor, "y");
var el = document.elementFromPoint(mouseX, mouseY);
var isStillOver =
el &&
(el === activeTarget ||
(el.closest && el.closest(TARGET_SELECTOR) === activeTarget));
if (!isStillOver && currentLeaveHandler) {
currentLeaveHandler();
}
},
{ passive: true }
);
// Mouse down / up
window.addEventListener("mousedown", function () {
gsap.to(dot, { scale: 0.7, duration: 0.3 });
gsap.to(cursor, { scale: 0.9, duration: 0.2 });
});
window.addEventListener("mouseup", function () {
gsap.to(dot, { scale: 1, duration: 0.3 });
gsap.to(cursor, { scale: 1, duration: 0.2 });
});
// Hover enter
window.addEventListener(
"mouseover",
function (e) {
var current = e.target;
var target = null;
while (current && current !== document.body) {
if (current.matches && current.matches(TARGET_SELECTOR)) {
target = current;
break;
}
current = current.parentElement;
}
if (!target) return;
if (activeTarget === target) return;
if (activeTarget) cleanupTarget(activeTarget);
if (resumeTimeout) {
clearTimeout(resumeTimeout);
resumeTimeout = null;
}
activeTarget = target;
corners.forEach(function (corner) {
gsap.killTweensOf(corner);
});
gsap.killTweensOf(cursor, "rotation");
if (spinTl) spinTl.pause();
gsap.set(cursor, { rotation: 0 });
var rect = target.getBoundingClientRect();
var cursorX = gsap.getProperty(cursor, "x");
var cursorY = gsap.getProperty(cursor, "y");
targetCornerPositions = [
{ x: rect.left - BORDER_WIDTH, y: rect.top - BORDER_WIDTH },
{
x: rect.right + BORDER_WIDTH - CORNER_SIZE,
y: rect.top - BORDER_WIDTH,
},
{
x: rect.right + BORDER_WIDTH - CORNER_SIZE,
y: rect.bottom + BORDER_WIDTH - CORNER_SIZE,
},
{
x: rect.left - BORDER_WIDTH,
y: rect.bottom + BORDER_WIDTH - CORNER_SIZE,
},
];
gsap.ticker.add(tickerFn);
gsap.to(activeStrength, {
current: 1,
duration: hoverDuration,
ease: "power2.out",
});
corners.forEach(function (corner, i) {
gsap.to(corner, {
x: targetCornerPositions[i].x - cursorX,
y: targetCornerPositions[i].y - cursorY,
duration: 0.2,
ease: "power2.out",
});
});
var leaveHandler = function () {
gsap.ticker.remove(tickerFn);
targetCornerPositions = null;
gsap.set(activeStrength, { current: 0 });
activeTarget = null;
var positions = [
{ x: -CORNER_SIZE * 1.5, y: -CORNER_SIZE * 1.5 },
{ x: CORNER_SIZE * 0.5, y: -CORNER_SIZE * 1.5 },
{ x: CORNER_SIZE * 0.5, y: CORNER_SIZE * 0.5 },
{ x: -CORNER_SIZE * 1.5, y: CORNER_SIZE * 0.5 },
];
corners.forEach(function (corner) {
gsap.killTweensOf(corner);
});
var tl = gsap.timeline();
corners.forEach(function (corner, index) {
tl.to(
corner,
{
x: positions[index].x,
y: positions[index].y,
duration: 0.3,
ease: "power3.out",
},
0
);
});
resumeTimeout = setTimeout(function () {
if (!activeTarget && cursor && spinTl) {
var rot = gsap.getProperty(cursor, "rotation") % 360;
spinTl.kill();
spinTl = gsap
.timeline({ repeat: -1 })
.to(cursor, {
rotation: "+=360",
duration: spinDuration,
ease: "none",
});
gsap.to(cursor, {
rotation: rot + 360,
duration: spinDuration * (1 - rot / 360),
ease: "none",
onComplete: function () {
if (spinTl) spinTl.restart();
},
});
}
resumeTimeout = null;
}, 50);
cleanupTarget(target);
};
currentLeaveHandler = leaveHandler;
target.addEventListener("mouseleave", leaveHandler);
},
{ passive: true }
);
});
<!-- Add data-target-cursor to any element -->
<div data-target-cursor>
Hover me
</div>
<!-- Works on any element -->
<button data-target-cursor>Click</button>
<a href="#" data-target-cursor>Link</a>
<img src="photo.jpg" data-target-cursor />
| Attribute | On | Default | Description |
|---|---|---|---|
data-target-cursor |
target element | required | Marks element as a cursor target |
data-spin-duration |
#targetCursor | 2 |
Rotation speed in seconds |
data-hover-duration |
#targetCursor | 0.2 |
Transition speed when entering targets |
data-parallax |
#targetCursor | true |
Enable corner parallax while hovering |
<!-- Slower spin, faster hover transition, no parallax -->
<div class="target-cursor-wrapper" id="targetCursor"
data-spin-duration="4"
data-hover-duration="0.1"
data-parallax="false">
<div class="target-cursor-dot" id="cursorDot"></div>
<div class="target-cursor-corner corner-tl"></div>
<div class="target-cursor-corner corner-tr"></div>
<div class="target-cursor-corner corner-br"></div>
<div class="target-cursor-corner corner-bl"></div>
</div>