---
name: cursor-trail
description: Use when you want "Creative, playful, expressive" - Leaves animated particles, lines, or shapes behind the cursor.
---

# Cursor Trail

> **Category:** Motion / Interaction  -  **Personality:** Creative, playful, expressive
>
> **Best use:** Portfolios, gaming, experimental sites
>
> **Ratings:** Professional 2/5 - Casual 5/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 4/5

## What it is

A cursor trail leaves a short-lived streak of particles and/or lines behind the pointer as it moves, so motion feels alive and hand-made. This recipe is a lightweight canvas-2D version: an eased "follower" lags slightly behind the real cursor, spawns small additive glow dots, and threads a tapered line through its recent positions — the whole thing fades as it ages. It reads as playful and crafted, which is why it suits portfolios, experimental landing pages and game/product heroes. It is **not** a custom or magnetic cursor (which *replaces* the pointer and snaps to targets): here the native cursor stays put and the trail is pure decoration drawn behind it.

## Dependencies / CDN

**None - vanilla JS** over a single `<canvas>`. No library. The demo only loads two display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;700&family=Unbounded:wght@600;700;800&display=swap" rel="stylesheet">
```

## HTML

A contained "stage", a full-bleed canvas pinned over it, and your normal content underneath:

```html
<div class="ct-stage">
  <canvas class="ct-canvas" aria-hidden="true"></canvas>   <!-- the trail layer -->
  <div class="ct-ui">
    <!-- nav / hero / footer ... your real content ... -->
    <span class="ct-coord">—  ·  —</span>                  <!-- optional live x/y readout -->
  </div>
</div>
```

## CSS

The only structural rules the effect needs. (Backdrop glows, grid and vignette in the live demo are just theming.)

```css
.ct-stage{
  position:relative; overflow:hidden; border-radius:26px; isolation:isolate;
  min-height:628px; display:flex; flex-direction:column;
}
/* the trail layer: above content, but never eats clicks */
.ct-canvas{ position:absolute; inset:0; z-index:2; pointer-events:none; }
.ct-ui{ position:relative; z-index:1; }

/* reduced-motion: hide the canvas, show a static decorative streak instead */
.ct-reduced .ct-canvas{ display:none; }
.ct-streak{ display:none; }
.ct-reduced .ct-streak{
  display:block; position:absolute; z-index:2; right:13%; top:31%;
  width:clamp(180px,30%,320px); height:6px; border-radius:999px; pointer-events:none;
  background:linear-gradient(90deg,transparent,#22d3ee,#a855f7,#ff5de1);
  filter:blur(2px); transform:rotate(-19deg); box-shadow:0 0 34px rgba(168,85,247,.55);
}
```

## JavaScript

The actual code the demo runs (canvas trail, eased follower, additive particles + tapered line). Listeners are scoped to the stage; `prefers-reduced-motion` disables it entirely.

```js
(function(){
  var stage  = document.querySelector('.ct-stage');
  var canvas = stage && stage.querySelector('.ct-canvas');
  if(!stage || !canvas || !canvas.getContext) return;
  var ctx   = canvas.getContext('2d');
  var coord = stage.querySelector('.ct-coord');

  // Accessibility gate: no motion -> no trail (CSS shows a static streak instead).
  if(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches){
    stage.classList.add('ct-reduced'); return;
  }

  // Crisp on retina; re-apply the transform whenever the backing store is resized.
  var dpr = Math.min(window.devicePixelRatio || 1, 2), W = 0, H = 0;
  function resize(){
    var r = stage.getBoundingClientRect(); W = r.width; H = r.height;
    canvas.width = Math.round(W*dpr); canvas.height = Math.round(H*dpr);
    canvas.style.width = W+'px'; canvas.style.height = H+'px';
    ctx.setTransform(dpr,0,0,dpr,0,0);
  }
  resize();
  if(window.ResizeObserver) new ResizeObserver(resize).observe(stage);
  else addEventListener('resize', resize);

  // Cohesive hue band: cyan(185) <-> pink(305) through violet — never a full rainbow.
  function hue(t){ return 245 + Math.sin(t)*60; }

  var parts = [], MAX = 210, ribbon = [], clock = 0;
  var target = {x:W*0.5, y:H*0.42}, leader = {x:target.x, y:target.y}, last = {x:target.x, y:target.y};
  var hovering = false, auto = true, autoT = 0, rafId = null;

  function spawn(x, y, vx, vy, speed){
    var n = 1 + Math.min(3, Math.floor(speed/9));           // faster pointer -> more sparks
    for(var i=0; i<n && parts.length<MAX; i++){
      parts.push({
        x:x+(Math.random()-.5)*7, y:y+(Math.random()-.5)*7,
        vx:vx*0.12+(Math.random()-.5)*0.7,
        vy:vy*0.12+(Math.random()-.5)*0.7-0.12,             // slight upward drift
        life:1, decay:0.012+Math.random()*0.018,
        size:1.5+Math.random()*2.7, h:hue(clock)+(Math.random()-.5)*30
      });
    }
  }

  function frame(){
    if(auto){                                               // gentle self-running preview on load
      autoT += 0.02;
      target.x = W*0.5 + Math.cos(autoT*1.25)*W*0.27;
      target.y = H*0.46 + Math.sin(autoT*1.9)*H*0.17;
      if(autoT > 7.2) auto = false;
    }
    leader.x += (target.x-leader.x)*0.2;                    // <- the easing/lag that "follows" the cursor
    leader.y += (target.y-leader.y)*0.2;
    ribbon.push({x:leader.x, y:leader.y}); if(ribbon.length>26) ribbon.shift();

    var dx = leader.x-last.x, dy = leader.y-last.y, d = Math.sqrt(dx*dx+dy*dy);
    if((hovering||auto) && d>1.6){ spawn(leader.x,leader.y,dx,dy,d); last.x=leader.x; last.y=leader.y; }
    clock += 0.013;

    ctx.clearRect(0,0,W,H);
    ctx.globalCompositeOperation = 'lighter';               // additive glow

    ctx.lineCap = 'round'; ctx.lineJoin = 'round';          // tapered trailing line
    for(var i=1; i<ribbon.length; i++){
      var a = ribbon[i-1], b = ribbon[i], t = i/ribbon.length;   // 0 tail .. 1 head
      ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y);
      ctx.lineWidth = 1 + t*5.5;
      ctx.strokeStyle = 'hsla('+(hue(clock)+(1-t)*40)+',95%,66%,'+(t*0.5)+')';
      ctx.stroke();
    }
    for(var j=parts.length-1; j>=0; j--){                   // particles: soft halo + bright core
      var p = parts[j];
      p.x+=p.vx; p.y+=p.vy; p.vx*=0.955; p.vy*=0.955; p.vy-=0.006; p.life-=p.decay;
      if(p.life<=0){ parts.splice(j,1); continue; }
      ctx.beginPath(); ctx.fillStyle='hsla('+p.h+',95%,62%,'+(0.13*p.life)+')';
      ctx.arc(p.x,p.y,p.size*3.4,0,6.2832); ctx.fill();
      ctx.beginPath(); ctx.fillStyle='hsla('+p.h+',100%,82%,'+(0.92*p.life)+')';
      ctx.arc(p.x,p.y,p.size*p.life+0.4,0,6.2832); ctx.fill();
    }
    if(hovering||auto){                                     // bright follower head
      var hh = hue(clock);
      ctx.beginPath(); ctx.fillStyle='hsla('+hh+',95%,60%,0.22)'; ctx.arc(leader.x,leader.y,17,0,6.2832); ctx.fill();
      ctx.beginPath(); ctx.fillStyle='hsla('+hh+',100%,86%,0.9)'; ctx.arc(leader.x,leader.y,3.6,0,6.2832); ctx.fill();
    }
    ctx.globalCompositeOperation = 'source-over';

    if(parts.length || hovering || auto){ rafId = requestAnimationFrame(frame); }
    else { rafId = null; ctx.clearRect(0,0,W,H); }          // idle -> stop the loop (saves CPU)
  }
  function run(){ if(rafId==null) rafId = requestAnimationFrame(frame); }  // MUST restart rAF, not just a flag

  stage.addEventListener('pointermove', function(e){
    var r = stage.getBoundingClientRect();                  // coords relative to the stage, not the page
    target.x = e.clientX-r.left; target.y = e.clientY-r.top;
    hovering = true; auto = false;
    if(coord) coord.textContent = 'x '+Math.round(target.x)+'  ·  y '+Math.round(target.y);
    run();
  });
  stage.addEventListener('pointerenter', function(){ hovering=true; auto=false; run(); });
  stage.addEventListener('pointerleave', function(){ hovering=false; });

  run();
})();
```

## How it works

- **Eased follower = the "trail".** Each frame a `leader` point moves a fraction (`*0.2`) toward the real pointer (`target`). That lag is what makes the streak appear to *chase* the cursor; everything is drawn from the leader, not the raw pointer.
- **Two visual layers.** A **tapered line** (`ribbon`, the leader's last 26 positions) gives the connected "line" character — width and alpha ramp from thin/faint at the tail to thick/bright at the head. **Particles** spawned at the leader give the sparkle; each is a low-alpha halo arc plus a bright core arc, both fading with `life`.
- **Additive blending.** `globalCompositeOperation = 'lighter'` makes overlapping dots and the line sum their light, so the trail glows instead of muddying.
- **Persistence without a fade-canvas.** The canvas is fully `clearRect`-ed every frame; the visible trail length comes from *particle lifetimes* and the stored ribbon, not from leaving old pixels behind.
- **Cohesive colour.** `hue = 245 + sin(t)*60` sweeps the band cyan↔violet↔pink as time passes, so the trail shifts colour playfully but never turns into a clown rainbow.
- **Density tracks speed.** `spawn()` emits more particles the faster the pointer travels, so flicks read as bursts and slow drags as a thin thread.
- **Self-stopping loop + on-load preview.** When nothing is happening the rAF loop halts to save battery; pointer events restart it. A gentle Lissajous "preview" runs on load so the stage demonstrates itself before the first interaction.

## Customization

- **Trail length / lifetime:** raise the ribbon cap (`> 26`) and lower particle `decay` for a longer comet; shorten both for a tight spark.
- **Density:** change the spawn divisor (`speed/9`) or `MAX` (210). Lower `MAX` for a minimalist trail.
- **Follower lag:** the `*0.2` lerp — smaller = floatier, longer lag; larger (→1) = snappy, almost no trail.
- **Palette:** edit `hue()` (e.g. fix it to one colour, or widen/narrow the `*60` band) to retheme without touching the draw code.
- **Shape language:** swap the core `arc()` for `rect()`/`star`/glyphs to leave squares, sparkles or characters; drop the ribbon loop for particles-only, or drop particles for a pure neon line.
- **Drift/gravity:** tweak `p.vy -= 0.006` (rise) and the `*0.955` drag for floating embers vs. falling sparks.

## Accessibility & performance

- **Respects `prefers-reduced-motion`:** the script returns early and adds `.ct-reduced`, which hides the canvas and shows a single static CSS streak — no animation at all.
- **Never blocks interaction:** the canvas is `pointer-events:none` and `aria-hidden` (purely decorative), and the OS cursor is left untouched, so keyboard/pointer use is unaffected.
- **Cheap to draw:** plain `arc()` fills under additive blending — no per-particle gradients, `shadowBlur` or CSS `filter` in the loop. Particle count is capped (`MAX`), DPR is clamped to 2, and the loop stops when idle.
- **Scoped listeners:** events live on the stage, not `window`, so multiple trails / other page interactions don't interfere.

## Gotchas

- **Under `'lighter'`, the classic fade-rectangle trick does nothing.** Painting a translucent dark rect each frame (the usual way to fade canvas trails) is a no-op in additive blend (dark + screen/lighter = unchanged). You must `clearRect` and rebuild from live particles, as above.
- **Restart the loop, don't just set a flag.** If you stop rAF when idle, re-entry handlers must call `requestAnimationFrame` again — flipping `hovering=true` alone leaves the canvas blank forever.
- **Use stage-relative coordinates.** Compute `clientX - rect.left` from `getBoundingClientRect()`; using raw `clientX/clientY` offsets the trail whenever the stage isn't at the top-left of the viewport.
- **Resize resets the transform.** Setting `canvas.width` clears the context state, so re-apply `ctx.setTransform(dpr,...)` inside `resize()` or retina rendering silently breaks.
- **Keep the stage a stacking context** (`position:relative; isolation:isolate; overflow:hidden`) so the absolutely-positioned canvas clips to the rounded corners and sits correctly above content.
- **Touch:** `pointermove` fires on drag, so the trail works on touch — but don't `preventDefault`, or you'll block scrolling.
