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.
| Section | Background |
|---|---|
| Techniques | Parallax and depth, Pointer interaction |
| Stack | Vanilla JS, Canvas |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | High-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.
More elements
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.