Copied!
Background

Ribbon pointer trail

A smooth lime-to-green ribbon chases the pointer, swelling while you move and melting away when you stop.

← Library

Element · Vanilla JS + CanvasUpdated November 6, 2026

BackgroundElementVanilla JS + Canvas

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Ribbon pointer trail" for my site using the Enrichr Library element at https://enrichr.dev/elements/ribbon-pointer-trail.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/ribbon-pointer-trail.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- segments: 200
- width: 100
- drag: 3.5
- glide: 0.3
- idle: on
- speed: 1
- head: #e6ff55
- tail: #1a7a4c
- middle: none
- background: #050d0a

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/ribbon-pointer-trail.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-rpt"><canvas class="ef-rpt__c" role="img" aria-label="A smooth ribbon that trails the pointer, fading from lime to green, thick in the middle and tapering at both ends"></canvas></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-rpt { position: relative; width: 100%; height: 100%; min-height: 26rem; overflow: hidden; border-radius: 1rem; background: #050d0a; touch-action: pan-y; }
.ef-rpt .ef-rpt__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Ribbon pointer trail: a smooth gradient ribbon that chases the pointer. A chain of 200 points follows a target that glides
// to the pointer in 0.3 s on a power1.out curve (GSAP's quickTo, re-implemented). Every frame each point moves 1/3.5 of the
// way to the point ahead of it, and each link of the chain is drawn as a round-capped line whose width rises from 1 px at
// the head to the full width in the middle and back down at the tail (GSAP's wrapYoyo), scaled by the link's length, so
// the ribbon swells while you move and melts away when you stop. The head is lime, the tail green, with an optional violet
// or terracotta stop in the middle. With idle on, a virtual pointer loops through the box while no one is pointing.
// Reduced motion draws one still ribbon and ignores the pointer. The loop pauses off screen and in hidden tabs.
// mount(root, { segments (links in the chain), width (max ribbon width, px), drag (chain lag, higher = longer),
//               glide (seconds for the target to reach the pointer), idle (on | off), speed (idle loop speed),
//               head (colour), tail (colour), middle (none | violet | terracotta), background })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-rpt");
  var canvas = root.querySelector(".ef-rpt__c");
  var ctx = canvas.getContext("2d");
  var MIDS = { violet: "#4b1a9a", terracotta: "#e54c1e" };
  var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0;
  var W = 1, H = 1, dpr = 1, pts = [], widths = [], cols = [], n = 0;
  var m = { x: 0, y: 0 }, qx = null, qy = null, inside = false, lastReal = -1e9, phase = 0;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function hex(h, d) { var r = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(r ? r[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
  // gsap.utils.wrapYoyo(min, max, value): bounces value back and forth between min and max
  function wrapYoyo(min, max, v) { var range = max - min, total = range * 2; v = (total + (v - min) % total) % total || 0; return min + (v > range ? total - v : v); }

  // gsap.quickTo(m, "x", { duration }) re-implemented: every call restarts a tween from the current value to the new one
  function ease(p) { return 1 - (1 - p) * (1 - p); } // power1.out, GSAP's default ease
  function qval(q, now) { if (!q) return 0; var p = Math.min(1, (now - q.t0) / (q.d * 1000)); return p >= 1 ? q.to : q.from + (q.to - q.from) * ease(p); }
  function qto(q, v, now) { var cur = q ? qval(q, now) : v; return { from: cur, to: v, t0: now, d: Math.max(0.001, opts.glide) }; }
  function aim(x, y, now) { qx = qto(qx, x, now); qy = qto(qy, y, now); }

  function build(keep) {
    var nn = Math.max(3, Math.round(opts.segments));
    if (!keep || nn !== n) {
      var old = pts; pts = [];
      for (var i = 0; i < nn; i++) {
        // the original scatters the chain across the page first, so it gathers on the first frames
        pts.push(old[i] ? old[i] : { x: W * Math.random(), y: (1 - i / nn) * H });
      }
      n = nn;
    }
    var peak = n / 2, hc = hex(opts.head, "e6ff55"), tc = hex(opts.tail, "1a7a4c"), mc = MIDS[opts.middle] ? hex(MIDS[opts.middle]) : null;
    widths = []; cols = [];
    for (var j = 0; j < n; j++) {
      widths.push(wrapYoyo(1, peak, j) / peak * opts.width); // 1 px at the ends, full width in the middle, like the original
      var t = j / (n - 1), c;
      if (mc) c = t < 0.5 ? mix(hc, mc, t * 2) : mix(mc, tc, (t - 0.5) * 2);
      else c = mix(hc, tc, t);
      cols.push("rgb(" + Math.round(c[0]) + "," + Math.round(c[1]) + "," + Math.round(c[2]) + ")");
    }
  }

  function step() {
    var nx = m.x, ny = m.y, k = Math.max(1, opts.drag);
    for (var i = 0; i < n; i++) {
      var pt = pts[i];
      pt.x += (nx - pt.x) / k;
      pt.y += (ny - pt.y) / k;
      nx = pt.x; ny = pt.y;
    }
  }
  function draw() {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, W, H);
    ctx.lineCap = "round";
    // the original prepends every path, so the head sits on top: paint from the tail forward
    for (var i = n - 1; i > 0; i--) {
      var a = pts[i - 1], b = pts[i];
      var d = Math.hypot(a.x - b.x, a.y - b.y), w = widths[i] * (d < 0 ? 0 : d > 1 ? 1 : d);
      if (w <= 0) continue;
      ctx.strokeStyle = cols[i]; ctx.lineWidth = w;
      ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
    }
  }

  // idle demo: a slow figure-of-eight path across the box, fed to the same quickTo as a real pointer
  function idlePoint(ph) {
    return { x: W * (0.5 + 0.34 * Math.sin(ph * 1.3) + 0.06 * Math.sin(ph * 3.1)), y: H * (0.5 + 0.3 * Math.sin(ph * 2.1 + 0.7)) };
  }
  function idle(now, dt) {
    if (opts.idle !== "on" || inside || now - lastReal < 1500) return;
    phase += dt * opts.speed;
    var p = idlePoint(phase);
    aim(p.x, p.y, now);
  }

  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = Math.min(0.1, (now - last) / 1000 || 0); last = now;
    if (!visible || document.hidden) return;
    idle(now, dt);
    m.x = qval(qx, now); m.y = qval(qy, now);
    step(); // per frame, like gsap.ticker
    draw();
  }

  // reduced motion: run the idle path off screen for a moment and draw the resulting ribbon once
  function still() {
    var ph = 2.2, t = 0;
    for (var i = 0; i < n; i++) { var p0 = idlePoint(ph - i * 2.4 / n); pts[i].x = p0.x; pts[i].y = p0.y; }
    qx = qy = null;
    for (var f = 0; f < 30; f++) {
      t += 1000 / 60; ph += 1 / 60 * 1.1;
      var p = idlePoint(ph); aim(p.x, p.y, t);
      m.x = qval(qx, t); m.y = qval(qy, t);
      step();
    }
    draw();
  }

  function onMove(e) {
    var r = wrap.getBoundingClientRect();
    inside = true; lastReal = performance.now();
    aim(e.clientX - r.left, e.clientY - r.top, lastReal);
  }
  function onEnter() { inside = true; lastReal = performance.now(); }
  function onLeave() { inside = false; lastReal = performance.now(); }

  function size() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    var w = Math.max(1, wrap.clientWidth), h = Math.max(1, wrap.clientHeight), first = W === 1 && H === 1;
    W = w; H = h;
    canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    if (first) {
      // the original starts its target at (half the height, the bottom edge)
      m.x = H / 2; m.y = H; var t = performance.now(); qx = { from: m.x, to: m.x, t0: t, d: 0.3 }; qy = { from: m.y, to: m.y, t0: t, d: 0.3 };
      build(false);
    }
    if (reduce) still(); else draw();
  }
  function update(partial) {
    opts = Object.assign({ segments: 200, width: 100, drag: 3.5, glide: 0.3, idle: "on", speed: 1, head: "#e6ff55", tail: "#1a7a4c", middle: "none", background: "#050d0a" }, opts, partial || {});
    opts.segments = Math.min(400, Math.max(3, num(opts.segments, 200))); opts.width = Math.max(1, num(opts.width, 100));
    opts.drag = Math.max(1, num(opts.drag, 3.5)); opts.glide = Math.max(0.01, num(opts.glide, 0.3)); opts.speed = Math.max(0, num(opts.speed, 1));
    wrap.style.background = opts.background;
    if (W > 1 || H > 1) { build(true); if (reduce) still(); else draw(); }
  }

  update(o);
  size();
  if (!reduce) {
    wrap.addEventListener("pointermove", onMove, { passive: true });
    wrap.addEventListener("pointerdown", onMove, { passive: true });
    wrap.addEventListener("pointerenter", onEnter);
    wrap.addEventListener("pointerleave", onLeave);
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
    last = performance.now(); lastReal = last - 900; // let the opening gather play before the idle loop starts
    raf = requestAnimationFrame(loop);
  }
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) size(); }); ro.observe(wrap); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerdown", onMove);
      wrap.removeEventListener("pointerenter", onEnter);
      wrap.removeEventListener("pointerleave", onLeave);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"segments":200,"width":100,"drag":3.5,"glide":0.3,"idle":"on","speed":1,"head":"#e6ff55","tail":"#1a7a4c","middle":"none","background":"#050d0a"});

How it works

A target point glides to the pointer over 0.3 seconds on a power1.out curve, and every new pointer move restarts that glide from wherever the target is, which is what GSAP's quickTo does. Behind it hangs a chain of 200 points. Every frame each point moves a 3.5th of the way towards the point ahead of it, so the chain lags further the further back you go. Each link is drawn as one round-capped line. Its width climbs from 1 pixel at the head to the full width in the middle and back down to 1 pixel at the tail, and is multiplied by the link's own length capped at one pixel, so the ribbon is fat while you move and shrinks to nothing when you stop. Colours run from the head colour to the tail colour, through violet or terracotta if you pick a middle stop. Links are painted from the tail forward so the thin head sits on top. With the idle demo on, a virtual pointer traces a slow loop while nobody is pointing.

Accessibility and performance

The canvas is decorative and has a text label. With prefers-reduced-motion it draws one still ribbon and ignores the pointer and the idle loop. Nothing flashes. There is nothing to operate, so no keyboard input is needed.

One canvas, one short line per link, no filters or shadows, so 200 links cost very little. The pixel ratio is capped at 2 and the loop pauses off screen and in hidden tabs. Touch scrolling still works because the element only claims horizontal drags.

SectionBackground
TechniquesPointer interaction
StackVanilla JS, Canvas
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byMouse Trail by Enrichr on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

Why does the ribbon vanish when I stop moving?

Each link's width is multiplied by its length, capped at one pixel. When the pointer rests, the chain bunches up into one spot, every link becomes shorter than a pixel, and the ribbon fades to nothing. That is the original pen's behaviour.

Can I make it follow the whole page instead of the box?

Yes. Listen for pointermove on window instead of the element and use clientX and clientY directly, then make the canvas position: fixed and full screen with pointer-events: none.

Does it need GSAP?

No. quickTo is a few lines: store where the value started, where it is going and when, then ease between them with power1.out each frame. wrapYoyo, which shapes the width, is one line of modulo maths.

How do I get the rainbow back?

Set the head and tail to two hues and pick a middle stop, or replace the colour mix with hsl(200 + i, 80%, 60%) per link as in the original pen.