Draw Path Cursor Effect

Category: Cursor Animations. Last updated: Aug 20, 2025

Setup — External Scripts

Setup: External Scripts
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>

Code

index.html
html
<div class="cursor-wrap">
  <div data-cursor-dot class="cursor-dot"></div>
  <canvas data-cursor-canvas class="cursor-canvas"></canvas>
</div>
styles.css
css
.cursor-wrap {
  position: fixed;
  inset: 0;
  z-index: 1000;
  pointer-events: none;
  width: 100%;
  height: 100%;
}

.cursor-dot {
  width: 2em;
  height: 2em;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.3);
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
}

.cursor-canvas {
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  position: absolute;
}

@media (max-width: 991px), (prefers-reduced-motion: reduce) {
  .cursor-wrap {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  [data-cursor-dot],
  [data-cursor-canvas] {
    display: none;
  }
}
script.js
javascript
function initDrawPathCursorEffect() {
  if (window.matchMedia('(pointer: coarse)').matches) return;
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  // Config
  const trailDuration = 1250; // how long the trail lingers in ms
  const trailColor = '#A1FF62'; // hex color of the trail

  const strokeMinWidth = 2; // thinnest line (fast movement)
  const strokeMaxWidth = 16; // thickest line (slow movement)
  const strokeSmoothing = 0.1; // 0–1 — lower = smoother width transitions

  const velocitySlow = 0.08; // px/ms threshold for "slow"
  const velocityFast = 2.8; // px/ms threshold for "fast"

  const glowBlur = 10; // px — glow radius
  const glowIntensity = 0.25; // 0–1 — glow opacity

  const cursorLag = 0.15; // seconds — GSAP easing duration

  const dot = document.querySelector('[data-cursor-dot]');
  const canvas = document.querySelector('[data-cursor-canvas]');
  const ctx = canvas.getContext('2d');

  let points = [];
  let hasMouse = false;
  let runningWidth = strokeMinWidth;

  function hexToRgb(hex) {
    const m = hex.replace('#', '').match(/.{2}/g);
    return m.map(c => parseInt(c, 16));
  }

  const color = hexToRgb(trailColor);

  gsap.set(dot, { xPercent: -50, yPercent: -50, opacity: 0 });
  const xTo = gsap.quickTo(dot, 'x', { duration: cursorLag, ease: 'power3' });
  const yTo = gsap.quickTo(dot, 'y', { duration: cursorLag, ease: 'power3' });

  function resize() {
    const dpr = window.devicePixelRatio || 1;
    canvas.width = window.innerWidth * dpr;
    canvas.height = window.innerHeight * dpr;
    ctx.scale(dpr, dpr);
  }
  resize();
  window.addEventListener('resize', resize);

  document.addEventListener('mouseenter', () => {
    dot.style.opacity = '1';
  });
  document.addEventListener('mouseleave', () => {
    dot.style.opacity = '0';
  });

  window.addEventListener('mousemove', (e) => {
    hasMouse = true;
    xTo(e.clientX);
    yTo(e.clientY);
  });

  gsap.ticker.add(() => {
    if (!hasMouse) return;

    const x = gsap.getProperty(dot, 'x');
    const y = gsap.getProperty(dot, 'y');

    if (points.length > 0) {
      const last = points[points.length - 1];
      const dx = x - last.x;
      const dy = y - last.y;
      if (dx * dx + dy * dy < 0.1) return;
    }

    points.push({ x, y, time: performance.now() });
  });

  function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }

  function remap(v, inMin, inMax, outMin, outMax) {
    const t = clamp((v - inMin) / (inMax - inMin), 0, 1);
    return outMin + t * (outMax - outMin);
  }

  function render() {
    const now = performance.now();
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    points = points.filter(p => now - p.time < trailDuration);

    if (points.length >= 3) drawTrail(now);
    requestAnimationFrame(render);
  }

  function drawTrail(now) {
    const [r, g, b] = color;

    ctx.lineCap = 'butt';
    ctx.shadowColor = `rgba(${r}, ${g}, ${b}, ${glowIntensity})`;
    ctx.shadowBlur = glowBlur;

    for (let i = 1; i < points.length - 1; i++) {
      const prev = points[i - 1];
      const curr = points[i];
      const next = points[i + 1];

      const mx1 = (prev.x + curr.x) * 0.5;
      const my1 = (prev.y + curr.y) * 0.5;
      const mx2 = (curr.x + next.x) * 0.5;
      const my2 = (curr.y + next.y) * 0.5;

      const dx = curr.x - prev.x;
      const dy = curr.y - prev.y;
      const dt = curr.time - prev.time || 1;
      const velocity = Math.sqrt(dx * dx + dy * dy) / dt;

      const targetWidth = remap(velocity, velocitySlow, velocityFast, strokeMaxWidth, strokeMinWidth);
      runningWidth += (targetWidth - runningWidth) * strokeSmoothing;

      const age = now - curr.time;
      const life = 1 - age / trailDuration;
      const alpha = life * life;
      if (alpha <= 0.005) continue;

      ctx.beginPath();
      ctx.moveTo(mx1, my1);
      ctx.quadraticCurveTo(curr.x, curr.y, mx2, my2);
      ctx.lineWidth = runningWidth;
      ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
      ctx.stroke();
    }

    ctx.shadowColor = 'transparent';
    ctx.shadowBlur = 0;
  }

  requestAnimationFrame(render);
}


// Initialize Draw Path Cursor Effect
document.addEventListener('DOMContentLoaded', () => {
  initDrawPathCursorEffect();
});

Guide

Implementation

This effect adds a smooth trailing line behind a dot that follows the mouse. The dot is animated with GSAP for a soft, lagging feel, and a full-window canvas draws a velocity-sensitive trail that fades over time. Move fast and the line gets thin, move slow and it gets thick. The effect is disabled automatically on touch devices, small screens, and when the user prefers reduced motion.

Trail

Trail duration and color are set at the top of the script. trailDuration controls how long each point in the trail stays alive before fading out, and trailColor sets the hex color for both the line and its glow.

Stroke

Stroke width reacts to how fast the cursor moves. strokeMinWidth is the thinnest the line gets at full speed, strokeMaxWidth is the thickest at rest, and strokeSmoothing controls how gradually the width transitions between the two. Lower smoothing values give a more fluid feel.

Velocity

The velocity thresholds define what counts as slow and fast movement in pixels per millisecond. Everything between velocitySlow and velocityFast is mapped linearly to the stroke width range.

Glow

The trail has a soft glow rendered with canvas shadow. glowBlur sets the radius in pixels and glowIntensity controls the opacity of the glow color.‍

Cursor lag

Use cursorLag to control how quickly the dot catches up to the actual mouse position. This is the GSAP easing duration in seconds. The trail originates from the dot's animated position, not the raw mouse, so both always stay connected.

Window bounds

The dot hides when the cursor leaves the browser window and reappears when it re-enters. The trail is not cleared on exit, so any existing line fades out naturally.