Target Cursor

Hover the elements below to see the cursor lock onto targets

Card Targets

Hover me
Target
Select

Mixed Targets

Action
Details
Submit
Cancel
Wide Element

1. CDN Script

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>

2. Required CSS

.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;
}

3. Required HTML

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>

4. JavaScript

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 }
  );
});

5. HTML Usage

<!-- 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 />

6. Data Attributes Reference

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

7. Custom Settings (Optional)

<!-- 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>