Copied!
Background

Light trails canvas

Streaks of coloured light rushing toward you along a night road, drawn with additive blending on a 2D canvas.

← Library

Element · Vanilla JS + CanvasUpdated November 5, 2026

3DElementVanilla 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 "Light trails canvas" for my site using the Enrichr Library element at https://enrichr.dev/elements/light-trails-canvas.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/light-trails-canvas.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:
- speed: 1
- density: 90
- boost: 2.4
- colorA: #e6ff55
- colorB: #4de8b5

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/light-trails-canvas.json.

Code

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

HTML

<div class="ef-lt" tabindex="0" role="img" aria-label="Streaks of coloured light rushing toward you along a night road. Hold the pointer or press and hold Space to speed up.">
  <canvas class="ef-lt__c" aria-hidden="true"></canvas>
  <span class="ef-lt__hint" aria-hidden="true">Hold to accelerate</span>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-lt { position: relative; width: 100%; min-height: 20rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; outline: none; cursor: pointer; touch-action: pan-y; user-select: none; -webkit-user-select: none; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-lt:focus-visible { box-shadow: inset 0 0 0 3px #e6ff55; }
.ef-lt__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-lt__hint { position: absolute; left: 50%; bottom: 0.9rem; transform: translateX(-50%); padding: 0.25rem 0.7rem; border-radius: 999px; border: 1px solid rgba(251, 248, 241, 0.35); color: rgba(251, 248, 241, 0.85); background: rgba(12, 30, 24, 0.5); font-size: 0.7rem; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; pointer-events: none; transition: opacity 0.4s; }
.ef-lt.is-held .ef-lt__hint { opacity: 0; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Light trails canvas: streaks of coloured light rush toward the viewer along a night road in simple perspective, drawn on a 2D canvas with additive blending.
// Hold the pointer (or press and hold Space or Enter while it has focus) to accelerate; releasing eases back. Reduced motion draws one still frame and ignores holding.
// mount(root, { speed (0.2-3), density (number of light groups, 10-200), boost (hold speed multiplier, 1-4), colorA (right lane lights), colorB (left lane lights) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-lt");
  var canvas = root.querySelector(".ef-lt__c");
  var ctx = canvas.getContext("2d");
  var opts = {}, cars = [], raf = 0, visible = true, io = null, ro = null, held = false, cur = 1, dist = 0, last = 0, destroyed = false;
  var W = 1, H = 1, f = 300, cx = 0, cy = 0, dpr = 1, seed = 20261106;
  var ZMAX = 2.6, GR = 0.5;

  function rnd() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }
  function rgb(h, d) {
    var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16);
    return [v >> 16, (v >> 8) & 255, v & 255];
  }
  function pre(c) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + ","; }
  function hot(c) { return "rgba(" + Math.round(c[0] + (255 - c[0]) * 0.65) + "," + Math.round(c[1] + (255 - c[1]) * 0.65) + "," + Math.round(c[2] + (255 - c[2]) * 0.65) + ","; }

  function respawn(c, initial) {
    var r = rnd(), side = rnd() < 0.5 ? -1 : 1, col = side < 0 ? opts._b : opts._a;
    if (rnd() < 0.12) col = [251, 248, 241];
    if (r < 0.22) {
      c.X = (rnd() * 2 - 1) * 5.5; c.Y = -0.7 + rnd() * 1.0; c.pair = 0; c.v = 0.9 + rnd() * 0.9; c.L = 0.7 + rnd() * 0.8; c.thick = 0.45 + rnd() * 0.4;
    } else {
      c.X = side * (0.55 + Math.floor(rnd() * 3) * 0.95 + (rnd() - 0.5) * 0.08); c.Y = GR - 0.05 - rnd() * 0.12; c.pair = 0.2; c.v = 0.7 + rnd() * 0.75; c.L = 0.4 + rnd() * 0.7; c.thick = 1;
    }
    c.col = pre(col); c.hot = hot(col);
    c.z = initial ? 0.07 + rnd() * (ZMAX - 0.07) : ZMAX + rnd() * 0.8;
  }
  function build() {
    var n = Math.round(opts.density);
    while (cars.length > n) cars.pop();
    while (cars.length < n) { var c = {}; respawn(c, true); cars.push(c); }
    cars.forEach(function (c, i) { var side = c.X < 0 ? opts._b : opts._a; if (c.col.indexOf("251,248,241") < 0) { c.col = pre(side); c.hot = hot(side); } });
  }
  function size() {
    dpr = Math.min(window.devicePixelRatio || 1, 1.5);
    W = Math.max(1, wrap.clientWidth); H = Math.max(1, wrap.clientHeight);
    canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    f = Math.max(W * 0.32, H * 0.55); cx = W / 2; cy = H * 0.42;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  function drawRoad(A, spd) {
    var bg = ctx.createLinearGradient(0, 0, 0, H);
    bg.addColorStop(0, "#06110d"); bg.addColorStop(cy / H, "#0e2820"); bg.addColorStop(Math.min(1, cy / H + 0.02), "#0a1d17"); bg.addColorStop(1, "#040b08");
    ctx.globalCompositeOperation = "source-over";
    ctx.globalAlpha = 1; ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
    ctx.globalCompositeOperation = "lighter";
    var glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(W, H) * 0.55);
    var ga = 0.2 + Math.min(0.25, (spd - 0.6) * 0.1);
    glow.addColorStop(0, pre(A) + ga + ")"); glow.addColorStop(0.35, pre(A) + ga * 0.3 + ")"); glow.addColorStop(1, pre(A) + "0)");
    ctx.fillStyle = glow; ctx.fillRect(0, 0, W, H);
    ctx.lineCap = "butt";
    var zn = 0.25, ex, ey, gr, k, lw, i, P = 0.32, z0, za, zb, x1, y1, x2, y2;
    for (i = -1; i <= 1; i += 2) {
      ex = cx + i * 3.1 / zn * f; ey = cy + GR / zn * f;
      gr = ctx.createLinearGradient(cx, cy, ex, ey); gr.addColorStop(0, pre(A) + "0)"); gr.addColorStop(1, pre(A) + "0.5)");
      ctx.strokeStyle = gr; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex, ey); ctx.stroke();
    }
    z0 = (((-dist % P) + P) % P) + 0.1;
    ctx.strokeStyle = "rgba(251,248,241,0.5)";
    var divs = [-2, -1, 1, 2];
    for (k = 0; k < divs.length; k++) {
      var bx = divs[k];
      for (za = z0; za < 3.4; za += P) {
        zb = za + P * 0.42;
        x1 = cx + bx / za * f; y1 = cy + GR / za * f; x2 = cx + bx / zb * f; y2 = cy + GR / zb * f;
        lw = Math.max(0.5, 0.014 * f / za);
        ctx.globalAlpha = Math.min(0.5, 0.8 / (za * 2.2)) * (Math.abs(bx) > 1 ? 0.6 : 1);
        ctx.lineWidth = lw; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
      }
    }
    ctx.globalAlpha = 1;
  }
  function render() {
    var A = opts._a, lenScale = 0.65 + 0.35 * cur;
    drawRoad(A, opts.speed * cur);
    ctx.globalCompositeOperation = "lighter";
    ctx.lineCap = "round";
    for (var i = 0; i < cars.length; i++) {
      var c = cars[i], zh = c.z, zt = c.z + c.L * lenScale, n = c.pair ? 2 : 1;
      var a = Math.min(1, Math.max(0.12, 1.3 - zh * 0.45));
      for (var j = 0; j < n; j++) {
        var X = c.pair ? c.X + (j ? c.pair : -c.pair) : c.X;
        var hx = cx + X / zh * f, hy = cy + c.Y / zh * f, tx = cx + X / zt * f, ty = cy + c.Y / zt * f;
        var lw = Math.max(0.7, c.thick * 0.011 * f / zh);
        var g = ctx.createLinearGradient(hx, hy, tx, ty);
        g.addColorStop(0, c.hot + a + ")"); g.addColorStop(0.28, c.col + a * 0.8 + ")"); g.addColorStop(1, c.col + "0)");
        ctx.strokeStyle = g;
        ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(tx, ty);
        ctx.lineWidth = lw * 3.4; ctx.globalAlpha = 0.18; ctx.stroke();
        ctx.lineWidth = lw; ctx.globalAlpha = 1; ctx.stroke();
      }
    }
    ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over";
  }
  function step(dt) {
    var target = held ? opts.boost : 1;
    cur += (target - cur) * Math.min(1, dt * (held ? 2.2 : 1.6));
    var s = opts.speed * cur * 0.55 * dt;
    dist += s;
    for (var i = 0; i < cars.length; i++) {
      var c = cars[i]; c.z -= c.v * s;
      if (c.z < 0.07 || Math.abs(c.X / c.z * f) > W * 0.9 + 80) respawn(c, false);
    }
  }
  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = Math.min(0.05, (now - last) / 1000 || 0.016); last = now;
    if (!visible || document.hidden) return;
    step(dt); render();
  }
  function setHeld(v) { if (reduce) return; held = v; wrap.classList.toggle("is-held", v); }
  function down(e) { if (e.button !== undefined && e.button > 0) return; setHeld(true); }
  function up() { setHeld(false); }
  function kd(e) { if (e.key === " " || e.key === "Enter") { setHeld(true); e.preventDefault(); } }
  function ku(e) { if (e.key === " " || e.key === "Enter") setHeld(false); }
  function update(next) {
    opts = Object.assign({ speed: 1, density: 90, boost: 2.4, colorA: "#e6ff55", colorB: "#4de8b5" }, opts, next || {});
    opts._a = rgb(opts.colorA, "e6ff55"); opts._b = rgb(opts.colorB, "4de8b5");
    build();
    if (reduce && !destroyed) render();
  }

  size();
  update(o);
  if (reduce) { render(); var h = root.querySelector(".ef-lt__hint"); if (h) h.style.display = "none"; }
  else {
    wrap.addEventListener("pointerdown", down);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    wrap.addEventListener("keydown", kd);
    wrap.addEventListener("keyup", ku);
    wrap.addEventListener("blur", up);
    wrap.addEventListener("contextmenu", up);
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
    last = performance.now(); raf = requestAnimationFrame(loop);
  }
  if ("ResizeObserver" in window) {
    ro = new ResizeObserver(function () { if (destroyed) return; size(); if (reduce) render(); });
    ro.observe(wrap);
  }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      wrap.removeEventListener("pointerdown", down); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up);
      wrap.removeEventListener("keydown", kd); wrap.removeEventListener("keyup", ku); wrap.removeEventListener("blur", up); wrap.removeEventListener("contextmenu", up);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"speed":1,"density":90,"boost":2.4,"colorA":"#e6ff55","colorB":"#4de8b5"});

How it works

Every light has a position on a flat road and a distance from the viewer. Each frame the distance shrinks, and a simple perspective divide turns the head and the tail of the light into two screen points that are joined with a gradient stroke. Strokes use the lighter composite mode so overlapping streaks add up to brighter light, and each one is drawn twice, wide and faint then thin and bright, for a cheap glow. Holding the pointer, or Space or Enter while the element has focus, eases a speed multiplier up and releasing eases it back.

Accessibility and performance

The canvas is decorative and carries a text description, including how to hold to accelerate. The element is focusable so keyboard users can speed it up with Space or Enter. Reduced motion draws one still frame, hides the hint and ignores holding.

Roughly two strokes and one gradient per light per frame, so the default 90 groups is a few hundred draw calls on a 2D canvas. Pixel ratio is capped at 1.5 and the loop pauses off screen. Lower the density on low-end phones.

SectionBackground
TechniquesParallax and depth, Pointer interaction
StackVanilla JS, Canvas
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byHigh-speed Light Trails in Three.js by Daniel Velasquez on Codrops. 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.

Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. Depth map shaderA WebGL shader that shifts an image by its own depth and the pointer, so near parts move more than far ones. Cutout collage gridAn editorial collage built with CSS grid and clip-path shapes over generated art, with a gentle depth drift on pointer move.

FAQ

Can I put text over it?

Yes. The background is dark and the glow is soft, so light text stays readable. Keep headings away from the busy lower corners.

How do I change the road?

Edit the lane positions in respawn and the divider list in drawRoad. Lights use world units, so a wider road is just larger lane offsets.

Why no WebGL?

A 2D canvas with additive blending is enough for this look and runs everywhere, with no fallback needed.