---
name: cursor-fluid-trail
description: Use when you want "Creative, playful, exotic" - Creates liquid-like motion following the cursor.
---

# Cursor Fluid Trail

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Creative, playful, exotic
>
> **Best use:** Experimental landing pages
>
> **Ratings:** Professional 2/5 - Casual 5/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

A liquid **dye smear** that follows the cursor — pigment that bleeds, diffuses and fades like ink dropped into water, rather than the discrete dots or polyline of a classic "cursor trail". It is a single canvas 2D *dye field*: each frame stamps soft blobs along the pointer's path into an accumulation buffer, then dissipates and blooms the whole buffer outward (a feedback transform). A CSS `blur()` + `contrast()` filter fuses the blobs into one continuous liquid mass, and `mix-blend-mode:screen` lets the glowing pigment wash over the content. Reach for it on experimental / launch hero sections where the page itself is the showpiece — it is far too loud for a regular product UI.

## Dependencies / CDN

**None — vanilla canvas 2D + CSS.** No WebGL, no library. Demo fonts (optional): Instrument Serif (display), Hanken Grotesk (body), Space Mono (labels):

```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=Hanken+Grotesk:wght@400;500;700&family=Instrument+Serif:ital@0;1&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
```

## HTML

A contained stage, the dye `<canvas>` on top (pointer-events off), and your content underneath. A static element is shown only when motion is reduced.

```html
<div class="cfx-stage">
  <canvas class="cfx-canvas" aria-hidden="true"></canvas>   <!-- the dye -->
  <div class="cfx-static" aria-hidden="true"></div>          <!-- reduced-motion: frozen smear -->
  <div class="cfx-ui">
    <h2 class="cfx-title">Ideas that <em>bleed</em><br>into motion.</h2>
    <p class="cfx-sub">Drag your cursor — watch the pigment follow.</p>
    <span class="cfx-coord">x — · y —</span>
  </div>
</div>
```

## CSS

The two load-bearing lines are the **canvas filter** (the "goo") and the **stage isolation**.

```css
.cfx-stage{
  position:relative; overflow:hidden; border-radius:26px; isolation:isolate; /* contain the blend + dye */
  min-height:642px; display:flex; flex-direction:column;
  background:radial-gradient(126% 122% at 72% 6%,#0c2024,#07131c 47%,#03070e 100%); /* dark = pigment glows */
}
.cfx-canvas{
  position:absolute; inset:0; z-index:3; pointer-events:none;          /* paints OVER the copy, never blocks it */
  filter:blur(7px) saturate(1.55) contrast(1.32);                      /* blur fuses blobs into liquid; contrast/saturate give it body */
  mix-blend-mode:screen;                                               /* luminous pigment instead of a flat overlay */
}
.cfx-ui{position:relative; z-index:1}                                  /* content sits beneath the dye */

/* Reduced motion: a single frozen dye smear, no canvas */
.cfx-static{display:none; position:absolute; inset:0; z-index:3; pointer-events:none; mix-blend-mode:screen;
  background:
    radial-gradient(34% 17% at 30% 70%,rgba(255,61,139,.6),transparent 70%),
    radial-gradient(30% 15% at 52% 56%,rgba(255,154,60,.55),transparent 72%),
    radial-gradient(40% 19% at 74% 40%,rgba(25,214,176,.55),transparent 72%);
  filter:blur(20px) saturate(1.3); transform:rotate(-13deg) scale(1.05); opacity:.9}
.cfx-reduced .cfx-canvas{display:none}
.cfx-reduced .cfx-static{display:block}
```

## JavaScript

The engine. All listeners live on the stage; `prefers-reduced-motion` short-circuits before any loop starts.

```js
(function(){
  var stage=document.querySelector('.cfx-stage');
  var canvas=stage&&stage.querySelector('.cfx-canvas');
  if(!stage||!canvas||!canvas.getContext){return;}

  // Accessibility gate: motion off -> show the frozen CSS smear, never start a loop.
  if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches){
    stage.classList.add('cfx-reduced');return;
  }

  var ctx=canvas.getContext('2d');
  var tmp=document.createElement('canvas'), tctx=tmp.getContext('2d'); // snapshot buffer for the feedback step
  var coord=stage.querySelector('.cfx-coord');

  var dpr=Math.min(window.devicePixelRatio||1,1.4);
  var W=0,H=0,DW=0,DH=0;
  function resize(){
    var r=stage.getBoundingClientRect(); if(!r.width){return;}
    W=r.width;H=r.height;DW=Math.round(W*dpr);DH=Math.round(H*dpr);
    canvas.width=DW;canvas.height=DH;canvas.style.width=W+'px';canvas.style.height=H+'px';
    tmp.width=DW;tmp.height=DH;
  }
  resize();
  if(window.ResizeObserver){new ResizeObserver(resize).observe(stage);}else{addEventListener('resize',resize);}

  // Iridescent "oil-slick" hue band: magenta -> amber -> teal -> (back through violet).
  var STOPS=[330,388,518,690];
  function hueAt(t){
    var p=(t*0.10)%(STOPS.length-1), i=Math.floor(p), f=p-i;
    return (STOPS[i]+(STOPS[i+1]-STOPS[i])*f)%360;
  }

  var DECAY=0.955, GROW=1.0075;                 // per-frame fade + outward bloom
  var target={x:W*0.5,y:H*0.44}, lead={x:target.x,y:target.y}, prev={x:target.x,y:target.y};
  var hovering=false, auto=true, autoT=0, clock=0, idle=0, rafId=null;

  // One soft pigment blob (radial gradient, additive).
  function blob(x,y,r,h,a){
    var g=ctx.createRadialGradient(x,y,0,x,y,r);
    g.addColorStop(0,'hsla('+h+',95%,60%,'+a+')');
    g.addColorStop(0.45,'hsla('+h+',92%,54%,'+(a*0.42)+')');
    g.addColorStop(1,'hsla('+h+',90%,50%,0)');
    ctx.fillStyle=g;ctx.beginPath();ctx.arc(x,y,r,0,6.2832);ctx.fill();
  }

  function frame(){
    clock+=0.016;
    if(auto){                                   // self-running preview so the effect is visible on load
      autoT+=0.018;
      target.x=W*0.5+Math.cos(autoT*1.15)*W*0.30;
      target.y=H*0.50+Math.sin(autoT*1.9)*H*0.20;
      if(coord)coord.textContent='auto · drifting';
      if(autoT>6.6){auto=false;if(coord)coord.textContent='x — · y —';}
    }
    lead.x+=(target.x-lead.x)*0.28;             // eased follower => smooth continuous path
    lead.y+=(target.y-lead.y)*0.28;
    var vx=lead.x-prev.x, vy=lead.y-prev.y, sp=Math.sqrt(vx*vx+vy*vy);

    // 1) FEEDBACK — snapshot, then redraw scaled-up + drifted + faded (dissipate & bloom).
    tctx.setTransform(1,0,0,1,0,0);tctx.clearRect(0,0,DW,DH);tctx.drawImage(canvas,0,0);
    ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,DW,DH);
    var dx=Math.max(-5,Math.min(5,vx*0.16))*dpr, dy=Math.max(-5,Math.min(5,vy*0.16))*dpr;
    ctx.globalAlpha=DECAY;
    ctx.setTransform(GROW,0,0,GROW,(1-GROW)*DW/2+dx,(1-GROW)*DH/2+dy);
    ctx.drawImage(tmp,0,0);
    ctx.setTransform(1,0,0,1,0,0);ctx.globalAlpha=1;

    // 2) STAMP fresh pigment along the segment travelled this frame (in CSS px).
    var active=hovering||auto, stamped=false;
    ctx.setTransform(dpr,0,0,dpr,0,0);
    ctx.globalCompositeOperation='lighter';
    if(active){
      var base=Math.min(W,H)*0.05, r=base+Math.min(sp*0.9,base*1.7);
      var d=Math.sqrt((lead.x-prev.x)*(lead.x-prev.x)+(lead.y-prev.y)*(lead.y-prev.y));
      var steps=Math.max(1,Math.floor(d/(base*0.5)));
      for(var i=0;i<steps;i++){
        var t=i/steps, x=prev.x+(lead.x-prev.x)*t, y=prev.y+(lead.y-prev.y)*t;
        blob(x,y,r,hueAt(clock)+(Math.random()-0.5)*22,0.16);
        if(d>0.4)stamped=true;
      }
      blob(lead.x,lead.y,r*0.5,hueAt(clock),0.10);                 // wet body
      blob(lead.x,lead.y,base*0.34,(hueAt(clock)+10),0.5);         // bright fresh "drop" head
    }
    ctx.globalCompositeOperation='source-over';
    ctx.setTransform(1,0,0,1,0,0);
    prev.x=lead.x;prev.y=lead.y;

    // 3) Idle shutdown once the pigment has fully dissipated (saves CPU; no getImageData).
    idle=stamped?0:idle+1;
    if(idle>108&&!active){rafId=null;ctx.clearRect(0,0,DW,DH);return;}
    rafId=requestAnimationFrame(frame);
  }
  function run(){if(rafId==null){idle=0;rafId=requestAnimationFrame(frame);}}

  stage.addEventListener('pointermove',function(e){
    var r=stage.getBoundingClientRect();
    target.x=e.clientX-r.left;target.y=e.clientY-r.top;
    hovering=true;auto=false;idle=0;
    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;if(coord)coord.textContent='x — · y —';});

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

## How it works

- **It is a field, not particles.** There is no array of dots. The canvas *is* the dye — colour accumulates into it and never tracks individual sprites, which is what gives a continuous smear instead of a string of beads.
- **Stamping** — between last frame's point and this one, soft radial-gradient blobs are painted with `globalCompositeOperation='lighter'` (additive). Interpolating along the segment keeps the smear unbroken even when the pointer jumps several pixels between events.
- **Feedback (the "fluid" part)** — every frame the buffer is snapshotted to a second canvas, cleared, then redrawn at `globalAlpha 0.955` (dissipation) and scaled by `1.0075` about centre with a small velocity-based offset (bloom + drift). Repeating this each frame makes old pigment spread, soften and fade — exactly how dye diffuses in water.
- **The goo filter** — the additive blobs are crisp and separate in the buffer; the CSS `blur(7px)` fuses neighbours into one liquid body, while `contrast()` + `saturate()` restore definition and `mix-blend-mode:screen` makes the pigment glow over the copy. The blur is applied at display only, so the *stored* pixels stay sharp for the next feedback pass.
- **Lifecycle** — an autopilot Lissajous path runs for ~6.5 s so the effect is alive on load, then hands off to the pointer. When nothing is happening and the pigment has fully faded, the rAF loop stops itself (no `getImageData` polling) and restarts on the next `pointerenter`.

## Customization

- **Trail length / wetness:** `DECAY` (0.93 = short & snappy, 0.975 = long, syrupy smear).
- **Diffusion:** `GROW` (1.0 = pigment holds its shape like paint; 1.015 = aggressively blooming smoke/dye).
- **Liquidity:** the canvas `blur()` radius — more blur = rounder, more "metaball"; less = stringy/wispy.
- **Palette:** edit the `STOPS` hue array. Want a single ink colour? Return a constant from `hueAt`. Want neon? Raise the lightness in `blob()`.
- **Body vs. glow:** swap `globalCompositeOperation` to `'source-over'` and use higher blob alpha for opaque "paint" instead of luminous "plasma" pigment.
- **Brush size & spray:** `base` (fraction of the stage) and the `Math.min(sp*0.9,…)` speed term (how much a fast flick fattens the stroke).

## Accessibility & performance

- **Reduced motion:** the script adds `.cfx-reduced` and returns *before* creating any loop; CSS swaps the live canvas for a single frozen smear. No trail, no animation — fully honoured.
- **GPU cost is real** (hence the 5/5 rating): a full-canvas `blur()` re-runs every frame, plus two full-buffer `drawImage` feedback passes. Mitigations already in place: DPR capped at **1.4**, the loop **stops when idle**, and the canvas is `pointer-events:none`. On weaker devices drop the blur radius or lower the DPR cap to 1.
- The canvas is `aria-hidden` and purely decorative — it carries no information, so screen-reader users lose nothing.
- Keep real content legible: the dye is luminous and transient, but test your headline contrast with pigment washing over it; nudge `mix-blend-mode` to `lighten` or lower blob alpha if text gets lost.

## Gotchas

- **`isolation:isolate` on the stage is mandatory.** Without it, `mix-blend-mode:screen` blends with the whole page behind the stage, not just the stage's own layers.
- **Read the buffer *before* you clear it.** The feedback step copies the canvas to a temp canvas first; clearing then redrawing the same canvas onto itself (without the snapshot) just erases everything.
- **The CSS `filter` is display-only.** `drawImage(canvas,…)` reads the raw, unfiltered pixels — which is what you want, or the blur would compound into mush over a few frames.
- **`globalCompositeOperation='lighter'` clips toward white** where many strokes overlap. Keep per-blob alpha low (~0.16) and let density build the brightness, or dense scribbling turns into a white blob.
- **Always reset state each frame:** restore `globalAlpha=1`, `globalCompositeOperation='source-over'` and the transform, or the next frame inherits them and the effect drifts or fades wrongly.
- **Don't attach `pointermove` to `window`.** Scope it to the stage so the dye stays confined and you're not running a full-screen canvas loop for an effect that lives in one panel.
