DEV Community

Cover image for How to Build a Crosshair Cursor Effect with HTML, CSS & JavaScript
CodeRipple
CodeRipple

Posted on

How to Build a Crosshair Cursor Effect with HTML, CSS & JavaScript

A custom cursor can make an interactive website feel more dynamic, especially for portfolios, creative landing pages, gaming interfaces, and experimental UI designs.

In this tutorial, we'll build a Crosshair Cursor Effect using HTML, CSS, and JavaScript.

Instead of simply replacing the default cursor with a circle, this component creates a complete targeting-style interaction with:

  • Smooth mouse tracking
  • Crosshair lines and a glowing center ring
  • Live X and Y coordinates
  • Interactive hover targets
  • Cyan-to-purple targeting feedback
  • Responsive behavior
  • Touch-device fallback

No libraries or frameworks are required.

Final Result

The cursor follows the mouse inside the component area while displaying its current X and Y position.

When you hover over one of the interactive targets, the crosshair switches from cyan to purple and expands slightly, creating a targeting effect.

Let's build it.

HTML

First, create the component structure.

We need a preview panel, three interactive targets, a hint at the bottom, and the custom crosshair itself.

<div class="cr-crosshair" id="crCrosshair">
  <div class="cr-crosshair__panel">

    <div class="cr-crosshair__content">
      <span class="cr-crosshair__eyebrow">
        INTERACTIVE CURSOR
      </span>

      <h2>Crosshair Cursor Effect</h2>

      <p>
        Move your cursor around the preview area and hover over
        the interactive targets.
      </p>

      <div class="cr-crosshair__targets">

        <button class="cr-crosshair__target" type="button">
          <span class="cr-crosshair__target-icon">01</span>

          <span class="cr-crosshair__target-text">
            <strong>Explore</strong>
            <small>Hover target</small>
          </span>
        </button>

        <button class="cr-crosshair__target" type="button">
          <span class="cr-crosshair__target-icon">02</span>

          <span class="cr-crosshair__target-text">
            <strong>Discover</strong>
            <small>Hover target</small>
          </span>
        </button>

        <button class="cr-crosshair__target" type="button">
          <span class="cr-crosshair__target-icon">03</span>

          <span class="cr-crosshair__target-text">
            <strong>Launch</strong>
            <small>Hover target</small>
          </span>
        </button>

      </div>
    </div>

    <div class="cr-crosshair__hint">
      <span class="cr-crosshair__hint-dot"></span>
      Move cursor inside this area
    </div>

    <div class="cr-crosshair__cursor" aria-hidden="true">

      <span class="cr-crosshair__line cr-crosshair__line--top"></span>
      <span class="cr-crosshair__line cr-crosshair__line--right"></span>
      <span class="cr-crosshair__line cr-crosshair__line--bottom"></span>
      <span class="cr-crosshair__line cr-crosshair__line--left"></span>

      <span class="cr-crosshair__ring">
        <span class="cr-crosshair__dot"></span>
      </span>

      <span class="cr-crosshair__coordinate cr-crosshair__coordinate--x">
        X 000
      </span>

      <span class="cr-crosshair__coordinate cr-crosshair__coordinate--y">
        Y 000
      </span>

    </div>

  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The four line elements form the crosshair, while the ring and dot create the center targeting point.

The two coordinate elements will be updated dynamically with JavaScript.

CSS

Now let's build the visual design.

The component uses a dark grid background with cyan and purple accents.

* {
  box-sizing: border-box;
}

html,
body {
  margin: 0;
  min-height: 100%;
}

body {
  background: #050816;
}

.cr-crosshair {
  --cr-bg: #050816;
  --cr-panel: #090d1f;
  --cr-border: rgba(148, 163, 184, 0.18);
  --cr-text: #f8fafc;
  --cr-muted: #94a3b8;
  --cr-cyan: #22d3ee;
  --cr-purple: #a855f7;

  width: 100%;
  min-height: 100vh;

  display: flex;
  align-items: center;
  justify-content: center;

  padding: 24px;

  background:
    radial-gradient(
      circle at 20% 20%,
      rgba(34, 211, 238, 0.08),
      transparent 30%
    ),
    radial-gradient(
      circle at 80% 80%,
      rgba(168, 85, 247, 0.08),
      transparent 32%
    ),
    var(--cr-bg);

  font-family:
    Inter,
    system-ui,
    -apple-system,
    BlinkMacSystemFont,
    "Segoe UI",
    sans-serif;
}

.cr-crosshair *,
.cr-crosshair *::before,
.cr-crosshair *::after {
  box-sizing: border-box;
}

/* Preview Panel */

.cr-crosshair__panel {
  position: relative;

  width: min(100%, 920px);
  min-height: 540px;

  overflow: hidden;

  border: 1px solid var(--cr-border);
  border-radius: 26px;

  background:
    linear-gradient(
      rgba(255, 255, 255, 0.018) 1px,
      transparent 1px
    ),
    linear-gradient(
      90deg,
      rgba(255, 255, 255, 0.018) 1px,
      transparent 1px
    ),
    linear-gradient(
      145deg,
      rgba(13, 18, 40, 0.96),
      rgba(5, 8, 22, 0.98)
    );

  background-size:
    42px 42px,
    42px 42px,
    auto;

  box-shadow:
    0 30px 80px rgba(0, 0, 0, 0.38),
    inset 0 1px 0 rgba(255, 255, 255, 0.04);

  isolation: isolate;
  cursor: none;
}

.cr-crosshair__panel::before,
.cr-crosshair__panel::after {
  content: "";

  position: absolute;

  width: 300px;
  height: 300px;

  border-radius: 50%;

  filter: blur(90px);
  opacity: 0.14;

  pointer-events: none;
  z-index: -1;
}

.cr-crosshair__panel::before {
  top: -120px;
  left: -100px;

  background: var(--cr-cyan);
}

.cr-crosshair__panel::after {
  right: -120px;
  bottom: -130px;

  background: var(--cr-purple);
}

/* Content */

.cr-crosshair__content {
  position: relative;
  z-index: 2;

  width: min(100%, 680px);

  margin: 0 auto;
  padding: 78px 36px 110px;

  text-align: center;

  pointer-events: none;
}

.cr-crosshair__eyebrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;

  margin-bottom: 18px;
  padding: 7px 12px;

  border: 1px solid rgba(34, 211, 238, 0.2);
  border-radius: 999px;

  background: rgba(34, 211, 238, 0.06);

  color: var(--cr-cyan);

  font-size: 11px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.16em;
}

.cr-crosshair__content h2 {
  margin: 0;

  color: var(--cr-text);

  font-size: clamp(30px, 5vw, 52px);
  line-height: 1.08;
  letter-spacing: -0.04em;
}

.cr-crosshair__content > p {
  max-width: 560px;

  margin: 20px auto 0;

  color: var(--cr-muted);

  font-size: 15px;
  line-height: 1.75;
}

/* Interactive Targets */

.cr-crosshair__targets {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));

  gap: 14px;

  margin-top: 44px;

  pointer-events: auto;
}

.cr-crosshair__target {
  position: relative;

  display: flex;
  align-items: center;

  gap: 12px;

  min-width: 0;
  padding: 15px;

  border: 1px solid rgba(148, 163, 184, 0.15);
  border-radius: 15px;

  outline: none;

  background: rgba(255, 255, 255, 0.025);

  color: var(--cr-text);

  text-align: left;
  font: inherit;

  cursor: none;
  overflow: hidden;

  transition:
    transform 180ms ease,
    border-color 180ms ease,
    background 180ms ease,
    box-shadow 180ms ease;
}

.cr-crosshair__target::before {
  content: "";

  position: absolute;
  inset: 0;

  background:
    linear-gradient(
      120deg,
      rgba(34, 211, 238, 0.09),
      transparent 45%,
      rgba(168, 85, 247, 0.09)
    );

  opacity: 0;

  pointer-events: none;

  transition: opacity 180ms ease;
}

.cr-crosshair__target:hover,
.cr-crosshair__target:focus-visible {
  transform: translateY(-3px);

  border-color: rgba(34, 211, 238, 0.5);

  background: rgba(34, 211, 238, 0.045);

  box-shadow:
    0 12px 30px rgba(0, 0, 0, 0.25),
    0 0 24px rgba(34, 211, 238, 0.08);
}

.cr-crosshair__target:hover::before,
.cr-crosshair__target:focus-visible::before {
  opacity: 1;
}

.cr-crosshair__target-icon {
  position: relative;
  z-index: 1;

  flex: 0 0 38px;

  width: 38px;
  height: 38px;

  display: grid;
  place-items: center;

  border: 1px solid rgba(34, 211, 238, 0.22);
  border-radius: 11px;

  background: rgba(34, 211, 238, 0.06);

  color: var(--cr-cyan);

  font-size: 11px;
  font-weight: 800;
  letter-spacing: 0.06em;
}

.cr-crosshair__target-text {
  position: relative;
  z-index: 1;

  display: flex;
  min-width: 0;

  flex-direction: column;

  gap: 3px;
}

.cr-crosshair__target-text strong {
  color: var(--cr-text);

  font-size: 13px;
  font-weight: 700;
}

.cr-crosshair__target-text small {
  color: var(--cr-muted);

  font-size: 10px;
}

/* Bottom Hint */

.cr-crosshair__hint {
  position: absolute;

  left: 50%;
  bottom: 24px;

  z-index: 2;

  display: flex;
  align-items: center;

  gap: 8px;

  transform: translateX(-50%);

  color: rgba(148, 163, 184, 0.72);

  font-size: 11px;
  letter-spacing: 0.04em;

  white-space: nowrap;

  pointer-events: none;
}

.cr-crosshair__hint-dot {
  width: 6px;
  height: 6px;

  border-radius: 50%;

  background: var(--cr-cyan);

  box-shadow: 0 0 10px var(--cr-cyan);

  animation:
    crCrosshairPulse 1.8s ease-in-out infinite;
}

@keyframes crCrosshairPulse {
  0%,
  100% {
    opacity: 0.35;
    transform: scale(0.8);
  }

  50% {
    opacity: 1;
    transform: scale(1.15);
  }
}

/* Custom Cursor */

.cr-crosshair__cursor {
  position: absolute;

  top: 0;
  left: 0;

  z-index: 20;

  width: 0;
  height: 0;

  opacity: 0;

  pointer-events: none;

  will-change: transform;

  transition: opacity 160ms ease;
}

.cr-crosshair__panel:hover
.cr-crosshair__cursor {
  opacity: 1;
}

/* Center Ring */

.cr-crosshair__ring {
  position: absolute;

  top: 0;
  left: 0;

  width: 32px;
  height: 32px;

  border: 1px solid rgba(34, 211, 238, 0.9);
  border-radius: 50%;

  transform: translate(-50%, -50%);

  box-shadow:
    0 0 12px rgba(34, 211, 238, 0.28),
    inset 0 0 12px rgba(34, 211, 238, 0.06);

  transition:
    width 180ms ease,
    height 180ms ease,
    border-color 180ms ease,
    box-shadow 180ms ease,
    background 180ms ease;
}

.cr-crosshair__dot {
  position: absolute;

  top: 50%;
  left: 50%;

  width: 5px;
  height: 5px;

  border-radius: 50%;

  background: var(--cr-cyan);

  transform: translate(-50%, -50%);

  box-shadow:
    0 0 7px var(--cr-cyan),
    0 0 15px rgba(34, 211, 238, 0.65);

  transition:
    transform 180ms ease,
    background 180ms ease,
    box-shadow 180ms ease;
}

/* Crosshair Lines */

.cr-crosshair__line {
  position: absolute;

  display: block;

  background:
    linear-gradient(
      to var(--cr-line-direction),
      rgba(34, 211, 238, 0.65),
      rgba(34, 211, 238, 0.08)
    );

  box-shadow:
    0 0 7px rgba(34, 211, 238, 0.12);

  transition:
    background 180ms ease,
    opacity 180ms ease;
}

.cr-crosshair__line--top {
  --cr-line-direction: top;

  left: -0.5px;
  bottom: 22px;

  width: 1px;
  height: 52px;
}

.cr-crosshair__line--bottom {
  --cr-line-direction: bottom;

  left: -0.5px;
  top: 22px;

  width: 1px;
  height: 52px;
}

.cr-crosshair__line--left {
  --cr-line-direction: left;

  top: -0.5px;
  right: 22px;

  width: 52px;
  height: 1px;
}

.cr-crosshair__line--right {
  --cr-line-direction: right;

  top: -0.5px;
  left: 22px;

  width: 52px;
  height: 1px;
}

/* Coordinates */

.cr-crosshair__coordinate {
  position: absolute;

  min-width: 48px;

  padding: 4px 7px;

  border: 1px solid rgba(34, 211, 238, 0.18);
  border-radius: 6px;

  background: rgba(5, 8, 22, 0.82);

  color: rgba(34, 211, 238, 0.9);

  font-family: "Courier New", monospace;

  font-size: 9px;
  font-weight: 700;
  line-height: 1;

  letter-spacing: 0.06em;

  white-space: nowrap;

  backdrop-filter: blur(6px);

  box-shadow:
    0 4px 12px rgba(0, 0, 0, 0.18);

  transition:
    color 180ms ease,
    border-color 180ms ease,
    background 180ms ease;
}

.cr-crosshair__coordinate--x {
  top: 32px;
  left: 34px;
}

.cr-crosshair__coordinate--y {
  top: 55px;
  left: 34px;
}

/* Targeting State */

.cr-crosshair__cursor.is-targeting
.cr-crosshair__ring {
  width: 46px;
  height: 46px;

  border-color: var(--cr-purple);

  background: rgba(168, 85, 247, 0.06);

  box-shadow:
    0 0 18px rgba(168, 85, 247, 0.4),
    inset 0 0 16px rgba(168, 85, 247, 0.08);
}

.cr-crosshair__cursor.is-targeting
.cr-crosshair__dot {
  background: var(--cr-purple);

  transform:
    translate(-50%, -50%)
    scale(1.3);

  box-shadow:
    0 0 8px var(--cr-purple),
    0 0 18px rgba(168, 85, 247, 0.7);
}

.cr-crosshair__cursor.is-targeting
.cr-crosshair__line {
  background: rgba(168, 85, 247, 0.65);
}

.cr-crosshair__cursor.is-targeting
.cr-crosshair__coordinate {
  color: #c084fc;

  border-color: rgba(168, 85, 247, 0.3);

  background: rgba(12, 8, 28, 0.88);
}

/* Responsive */

@media (max-width: 700px) {
  .cr-crosshair {
    min-height: 100vh;
    padding: 14px;
  }

  .cr-crosshair__panel {
    min-height: 550px;
    border-radius: 20px;
  }

  .cr-crosshair__content {
    padding: 60px 20px 100px;
  }

  .cr-crosshair__content h2 {
    font-size: clamp(28px, 9vw, 42px);
  }

  .cr-crosshair__targets {
    grid-template-columns: 1fr;

    max-width: 340px;

    margin: 34px auto 0;
  }
}

/* Touch Devices */

@media (hover: none), (pointer: coarse) {
  .cr-crosshair__panel,
  .cr-crosshair__target {
    cursor: auto;
  }

  .cr-crosshair__cursor {
    display: none;
  }
}

/* Reduced Motion */

@media (prefers-reduced-motion: reduce) {
  .cr-crosshair__target,
  .cr-crosshair__ring,
  .cr-crosshair__dot,
  .cr-crosshair__line,
  .cr-crosshair__coordinate {
    transition: none;
  }

  .cr-crosshair__hint-dot {
    animation: none;
  }
}
Enter fullscreen mode Exit fullscreen mode

One important detail is the touch-device fallback.

On devices where a traditional mouse cursor isn't available, the custom cursor is hidden and the normal interaction remains available.

JavaScript

Now we'll make the crosshair follow the mouse.

Instead of assigning the cursor directly to the mouse coordinates on every frame, we'll interpolate its current position toward the latest mouse position.

This creates smoother movement.

(() => {
  const component = document.querySelector("#crCrosshair");

  if (!component) return;

  const panel = component.querySelector(".cr-crosshair__panel");
  const cursor = component.querySelector(".cr-crosshair__cursor");

  const coordX = component.querySelector(
    ".cr-crosshair__coordinate--x"
  );

  const coordY = component.querySelector(
    ".cr-crosshair__coordinate--y"
  );

  const targets = component.querySelectorAll(
    ".cr-crosshair__target"
  );

  if (!panel || !cursor || !coordX || !coordY) return;

  let mouseX = 0;
  let mouseY = 0;

  let currentX = 0;
  let currentY = 0;

  let isInside = false;
  let animationFrame = null;

  const formatCoordinate = (value) => {
    return String(
      Math.max(0, Math.round(value))
    ).padStart(3, "0");
  };

  const updateCursor = () => {
    if (!isInside) {
      animationFrame = null;
      return;
    }

    currentX += (mouseX - currentX) * 0.22;
    currentY += (mouseY - currentY) * 0.22;

    cursor.style.transform =
      `translate3d(${currentX}px, ${currentY}px, 0)`;

    animationFrame =
      requestAnimationFrame(updateCursor);
  };

  panel.addEventListener("mouseenter", (event) => {
    const rect = panel.getBoundingClientRect();

    mouseX = event.clientX - rect.left;
    mouseY = event.clientY - rect.top;

    currentX = mouseX;
    currentY = mouseY;

    isInside = true;

    cursor.style.transform =
      `translate3d(${currentX}px, ${currentY}px, 0)`;

    cursor.style.opacity = "1";

    coordX.textContent =
      `X ${formatCoordinate(mouseX)}`;

    coordY.textContent =
      `Y ${formatCoordinate(mouseY)}`;

    if (!animationFrame) {
      animationFrame =
        requestAnimationFrame(updateCursor);
    }
  });

  panel.addEventListener("mousemove", (event) => {
    const rect = panel.getBoundingClientRect();

    mouseX = event.clientX - rect.left;
    mouseY = event.clientY - rect.top;

    coordX.textContent =
      `X ${formatCoordinate(mouseX)}`;

    coordY.textContent =
      `Y ${formatCoordinate(mouseY)}`;
  });

  panel.addEventListener("mouseleave", () => {
    isInside = false;

    cursor.style.opacity = "0";

    cursor.classList.remove("is-targeting");

    if (animationFrame) {
      cancelAnimationFrame(animationFrame);
      animationFrame = null;
    }
  });

  targets.forEach((target) => {
    target.addEventListener("mouseenter", () => {
      cursor.classList.add("is-targeting");
    });

    target.addEventListener("mouseleave", () => {
      cursor.classList.remove("is-targeting");
    });

    target.addEventListener("focus", () => {
      cursor.classList.add("is-targeting");
    });

    target.addEventListener("blur", () => {
      cursor.classList.remove("is-targeting");
    });
  });

  document.addEventListener("visibilitychange", () => {
    if (!document.hidden) return;

    isInside = false;

    cursor.style.opacity = "0";

    cursor.classList.remove("is-targeting");

    if (animationFrame) {
      cancelAnimationFrame(animationFrame);
      animationFrame = null;
    }
  });
})();
Enter fullscreen mode Exit fullscreen mode

How the Smooth Cursor Movement Works

The important part is this:

currentX += (mouseX - currentX) * 0.22;
currentY += (mouseY - currentY) * 0.22;
Enter fullscreen mode Exit fullscreen mode

mouseX and mouseY store the latest pointer position.

currentX and currentY represent the rendered position of the custom cursor.

Instead of immediately jumping to the new position, the cursor moves part of the remaining distance on each animation frame.

The animation loop uses requestAnimationFrame(), which keeps the movement synchronized with browser rendering.

Live X and Y Coordinates

Every time the pointer moves inside the panel, we calculate its position relative to the panel:

const rect = panel.getBoundingClientRect();

mouseX = event.clientX - rect.left;
mouseY = event.clientY - rect.top;
Enter fullscreen mode Exit fullscreen mode

We then update the coordinate labels:

coordX.textContent =
  `X ${formatCoordinate(mouseX)}`;

coordY.textContent =
  `Y ${formatCoordinate(mouseY)}`;
Enter fullscreen mode Exit fullscreen mode

This gives the component its small HUD-style coordinate display.

Creating the Targeting State

Each interactive card listens for mouse and keyboard focus events.

When a target becomes active, JavaScript adds:

cursor.classList.add("is-targeting");
Enter fullscreen mode Exit fullscreen mode

CSS then expands the center ring and changes the crosshair from cyan to purple.

When the pointer leaves the target, the class is removed.

This keeps the JavaScript responsible for interaction while CSS handles the visual state.

Where Can You Use This Effect?

A crosshair cursor works particularly well in interfaces where the cursor itself is part of the visual experience.

For example:

  • Developer and designer portfolios
  • Creative agency websites
  • Interactive landing pages
  • Gaming-inspired interfaces
  • Photography showcases
  • Experimental UI projects
  • Product presentations

For conventional forms, checkout pages, banking interfaces, and other task-focused experiences, the standard cursor is usually more familiar to users.

Accessibility and Touch Devices

Custom cursors need a fallback because phones and tablets generally don't use a traditional mouse pointer.

The CSS checks for coarse pointers and devices without hover support:

@media (hover: none), (pointer: coarse) {
  .cr-crosshair__panel,
  .cr-crosshair__target {
    cursor: auto;
  }

  .cr-crosshair__cursor {
    display: none;
  }
}
Enter fullscreen mode Exit fullscreen mode

We've also included a prefers-reduced-motion rule to remove unnecessary animation for users who request reduced motion.

Try the Live Demo

You can experiment with the component in the live CodePen:

https://codepen.io/editor/getcoderipple/pen/01a0e6c9-b084-7cb2-ab7f-0495f968502c

For the complete CodeRipple tutorial and source code:

https://getcoderipple.com/crosshair-cursor-effect/

Final Thoughts

This component goes beyond simply replacing the browser cursor.

The crosshair lines, live coordinates, smooth tracking, and interactive targeting state work together to create a small HUD-style interaction while keeping the implementation framework-free.

Because the HTML, CSS, and JavaScript are separated, you can also customize the effect easily.

Try changing the colors, crosshair dimensions, interpolation speed, or target styles to match your own project.

If you're building an interactive portfolio or creative interface, this can be a useful effect to experiment with.

Happy coding!

Top comments (0)