All articles
24 min read

Light Painting: How to Create Stunning Long-Exposure Light Art With Code

light paintinglong exposurecreative codingJavaScriptgenerative artcanvaslight art

Light painting is one of the oldest photographic tricks: open the shutter, wave a light source through the darkness, and the camera accumulates every position into a single luminous trail. The results look magical—ribbons of fire floating in mid-air, spiraling orbs, entire words written in light against a black sky.

The technique dates back to 1889, when Etienne-Jules Marey attached incandescent bulbs to a walking assistant and opened his camera shutter. Man Ray experimented with it in the 1930s, calling his images “space writing.” Picasso made it famous in 1949 when Gjon Mili photographed him drawing a centaur with a small flashlight—the resulting Life Magazine image became iconic.

For creative coders, digital light painting is even more flexible than the physical version. A Canvas that never fully clears—just fades slightly each frame—naturally accumulates bright marks into glowing trails. Add bloom effects, additive blending, and velocity-based thickness, and you get images that are indistinguishable from real long-exposure photography.

In this guide we build eight light painting programs from scratch. Every example is self-contained, runs on a plain HTML Canvas with no libraries, and stays under 50KB. For related techniques, see the particle systems guide, the drawing with code guide, or the Bézier curves guide.

Setting up

Every example uses this minimal HTML setup:

<canvas id="c" width="800" height="600"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
const W = c.width, H = c.height;
// ... example code goes here ...
</script>

Paste each example into the script section. All code is vanilla JavaScript with the Canvas 2D API.

The core technique: persistent trails

The secret to digital light painting is simple: instead of clearing the canvas completely each frame, overlay a semi-transparent black rectangle. Bright marks accumulate while older ones fade. This single trick produces the long-exposure look.

// Instead of ctx.clearRect(0, 0, W, H):
ctx.fillStyle = 'rgba(0, 0, 0, 0.03)';
ctx.fillRect(0, 0, W, H);

Lower alpha values (0.01–0.03) produce longer, more persistent trails. Higher values (0.1–0.2) create shorter trails that fade quickly. This is the foundation every example below builds on.

1. Light trail brush

The simplest light painting: drag your mouse across the canvas and leave a glowing trail behind. The brush emits light particles that fade over time, and their color shifts along the HSL spectrum based on movement speed.

let mouseX = W / 2, mouseY = H / 2;
let prevX = mouseX, prevY = mouseY;
let hue = 0;
let isDrawing = false;

c.addEventListener('mousemove', e => {
  const r = c.getBoundingClientRect();
  mouseX = e.clientX - r.left;
  mouseY = e.clientY - r.top;
  isDrawing = true;
});
c.addEventListener('mouseleave', () => isDrawing = false);

ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);

function draw() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.02)';
  ctx.fillRect(0, 0, W, H);

  if (isDrawing) {
    const dx = mouseX - prevX;
    const dy = mouseY - prevY;
    const speed = Math.sqrt(dx * dx + dy * dy);
    const steps = Math.max(1, Math.floor(speed / 2));

    for (let i = 0; i < steps; i++) {
      const t = i / steps;
      const x = prevX + dx * t;
      const y = prevY + dy * t;
      const radius = 2 + speed * 0.05;
      const alpha = 0.3 + Math.min(speed * 0.01, 0.7);

      const grd = ctx.createRadialGradient(x, y, 0, x, y, radius * 3);
      grd.addColorStop(0, `hsla(${hue}, 100%, 90%, ${alpha})`);
      grd.addColorStop(0.3, `hsla(${hue}, 100%, 60%, ${alpha * 0.6})`);
      grd.addColorStop(1, `hsla(${hue}, 100%, 40%, 0)`);

      ctx.beginPath();
      ctx.arc(x, y, radius * 3, 0, Math.PI * 2);
      ctx.fillStyle = grd;
      ctx.fill();

      ctx.beginPath();
      ctx.arc(x, y, radius * 0.5, 0, Math.PI * 2);
      ctx.fillStyle = `hsla(${hue}, 100%, 95%, ${alpha})`;
      ctx.fill();
    }
    hue = (hue + speed * 0.3) % 360;
  }

  prevX = mouseX;
  prevY = mouseY;
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The radial gradient creates the glow effect—bright white core fading through saturated color to transparent. Interpolating between previous and current mouse positions ensures smooth lines even at high speeds. Speed also modulates hue, so fast strokes shift color while slow ones stay consistent.

2. Orbital light painting

Multiple light points orbit a central axis on circular paths, each at a different speed and radius. This simulates the “orb” technique in real light painting, where photographers swing a light on a string in a dark room during a long exposure.

const orbs = [];
const numOrbs = 5;

for (let i = 0; i < numOrbs; i++) {
  orbs.push({
    radius: 80 + i * 40,
    speed: (0.5 + i * 0.3) * (i % 2 ? 1 : -1),
    angle: (i / numOrbs) * Math.PI * 2,
    hue: (i / numOrbs) * 360,
    size: 3 - i * 0.3,
    yOffset: Math.sin(i * 1.3) * 30,
  });
}

ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);

function draw(t) {
  const s = t * 0.001;
  ctx.fillStyle = 'rgba(0, 0, 0, 0.015)';
  ctx.fillRect(0, 0, W, H);

  const cx = W / 2 + Math.sin(s * 0.2) * 30;
  const cy = H / 2 + Math.cos(s * 0.15) * 20;

  for (const orb of orbs) {
    orb.angle += orb.speed * 0.02;
    const wobble = Math.sin(s * 0.7 + orb.hue) * 15;
    const r = orb.radius + wobble;
    const x = cx + Math.cos(orb.angle) * r;
    const y = cy + Math.sin(orb.angle) * r * 0.6 + orb.yOffset;

    const grd = ctx.createRadialGradient(x, y, 0, x, y, orb.size * 6);
    grd.addColorStop(0, `hsla(${orb.hue + s * 10}, 100%, 90%, 0.8)`);
    grd.addColorStop(0.4, `hsla(${orb.hue + s * 10}, 100%, 60%, 0.3)`);
    grd.addColorStop(1, 'transparent');

    ctx.beginPath();
    ctx.arc(x, y, orb.size * 6, 0, Math.PI * 2);
    ctx.fillStyle = grd;
    ctx.fill();

    ctx.beginPath();
    ctx.arc(x, y, orb.size * 0.6, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${orb.hue + s * 10}, 100%, 98%, 0.9)`;
    ctx.fill();
  }
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The 0.6 multiplier on the y-axis creates an elliptical orbit that implies perspective—as if you are viewing the orbs from slightly above. The slow wobble on center position and radius prevents the pattern from becoming perfectly periodic, keeping it organic and alive.

3. Light calligraphy

Automated light calligraphy: a light point traces words in cursive, leaving glowing letter forms behind. This simulates what artists like Darren Pearson (“Dariustwin”) do with LED flashlights in real long-exposure photography.

const words = ['LIGHT', 'PAINTING', 'ART'];
let wordIdx = 0;
let pathPoints = [];
let pathIdx = 0;
let phase = 'writing';
let pauseTimer = 0;

function generatePath(word) {
  const pts = [];
  ctx.font = 'bold 120px Georgia, serif';
  const metrics = ctx.measureText(word);
  const startX = (W - metrics.width) / 2;
  const startY = H / 2 + 40;

  const offCanvas = document.createElement('canvas');
  offCanvas.width = W;
  offCanvas.height = H;
  const octx = offCanvas.getContext('2d');
  octx.fillStyle = '#fff';
  octx.font = 'bold 120px Georgia, serif';
  octx.fillText(word, startX, startY);
  const imgData = octx.getImageData(0, 0, W, H).data;

  for (let x = 0; x < W; x += 3) {
    for (let y = 0; y < H; y += 3) {
      if (imgData[(y * W + x) * 4 + 3] > 128) {
        pts.push({ x, y });
      }
    }
  }

  pts.sort((a, b) => {
    const colA = Math.floor(a.x / 8);
    const colB = Math.floor(b.x / 8);
    if (colA !== colB) return colA - colB;
    return colA % 2 === 0 ? a.y - b.y : b.y - a.y;
  });
  return pts;
}

pathPoints = generatePath(words[0]);

ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);

function draw(t) {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.008)';
  ctx.fillRect(0, 0, W, H);

  if (phase === 'writing' && pathPoints.length > 0) {
    const batch = Math.min(12, pathPoints.length - pathIdx);
    for (let i = 0; i < batch; i++) {
      const pt = pathPoints[pathIdx + i];
      if (!pt) break;
      const hue = 40 + (pathIdx / pathPoints.length) * 30;

      const grd = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, 6);
      grd.addColorStop(0, `hsla(${hue}, 100%, 95%, 0.9)`);
      grd.addColorStop(0.5, `hsla(${hue}, 100%, 60%, 0.4)`);
      grd.addColorStop(1, 'transparent');
      ctx.beginPath();
      ctx.arc(pt.x, pt.y, 6, 0, Math.PI * 2);
      ctx.fillStyle = grd;
      ctx.fill();
    }
    pathIdx += batch;

    if (pathIdx >= pathPoints.length) {
      phase = 'pause';
      pauseTimer = t;
    }
  } else if (phase === 'pause' && t - pauseTimer > 3000) {
    phase = 'writing';
    pathIdx = 0;
    wordIdx = (wordIdx + 1) % words.length;
    pathPoints = generatePath(words[wordIdx]);
    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, W, H);
  }

  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The sorting trick is key: scanning left-to-right in narrow columns (with alternating direction) mimics how a human hand would trace letters. Without sorting, the light would jump randomly across the text. The warm golden hue (40–70) evokes the color temperature of actual flashlight LEDs.

4. Sparkler effect

Digital sparklers—particles erupt from a moving point like iron filings burning off a sparkler stick. Each particle has a brief, bright life: it launches with velocity, decelerates under gravity, and fades to a warm orange ember. The accumulated trails create the characteristic sparkler texture.

const particles = [];
let sparkX = W / 2, sparkY = H / 2;
let sparkAngle = 0;

c.addEventListener('mousemove', e => {
  const r = c.getBoundingClientRect();
  sparkX = e.clientX - r.left;
  sparkY = e.clientY - r.top;
});

ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);

function draw(t) {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.03)';
  ctx.fillRect(0, 0, W, H);

  sparkAngle += 0.03;
  const autoX = W / 2 + Math.cos(sparkAngle) * 150;
  const autoY = H / 2 + Math.sin(sparkAngle * 0.7) * 100;
  const sx = autoX, sy = autoY;

  for (let i = 0; i < 8; i++) {
    const angle = Math.random() * Math.PI * 2;
    const speed = 1 + Math.random() * 4;
    particles.push({
      x: sx, y: sy,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed - 1,
      life: 1,
      decay: 0.01 + Math.random() * 0.03,
      size: 0.5 + Math.random() * 2,
      bright: Math.random() > 0.7,
    });
  }

  for (let i = particles.length - 1; i >= 0; i--) {
    const p = particles[i];
    p.x += p.vx;
    p.y += p.vy;
    p.vy += 0.04;
    p.vx *= 0.99;
    p.life -= p.decay;

    if (p.life <= 0) {
      particles.splice(i, 1);
      continue;
    }

    const hue = p.life > 0.5 ? 50 : 30 - (1 - p.life) * 20;
    const lum = p.bright ? 95 : 70;
    const alpha = p.life * (p.bright ? 1 : 0.7);

    ctx.beginPath();
    ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${hue}, 100%, ${lum}%, ${alpha})`;
    ctx.fill();

    if (p.bright && p.life > 0.6) {
      const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 4);
      grd.addColorStop(0, `hsla(${hue}, 100%, 90%, ${alpha * 0.3})`);
      grd.addColorStop(1, 'transparent');
      ctx.beginPath();
      ctx.arc(p.x, p.y, p.size * 4, 0, Math.PI * 2);
      ctx.fillStyle = grd;
      ctx.fill();
    }
  }

  const coreGrd = ctx.createRadialGradient(sx, sy, 0, sx, sy, 8);
  coreGrd.addColorStop(0, 'rgba(255, 255, 240, 0.9)');
  coreGrd.addColorStop(0.5, 'rgba(255, 200, 50, 0.4)');
  coreGrd.addColorStop(1, 'transparent');
  ctx.beginPath();
  ctx.arc(sx, sy, 8, 0, Math.PI * 2);
  ctx.fillStyle = coreGrd;
  ctx.fill();

  if (particles.length > 2000) particles.splice(0, particles.length - 2000);

  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The hue transition from yellow (50) to deep orange (10) as particles age replicates how real sparks cool as they fly. The “bright” flag on 30% of particles adds the occasional white-hot spark that makes sparklers visually distinctive. Gravity (0.04 per frame) gives the characteristic downward cascade.

5. Physiogram spirals

A physiogram is a photograph of a swinging light source shot from directly above. The light traces Lissajous-like patterns as the pendulum decays. This was one of the first light painting techniques, used by photographers in the 1940s–50s.

let time = 0;
const pendulums = [
  { freqX: 3, freqY: 2, phaseX: 0, phaseY: Math.PI / 4, ampX: 250, ampY: 200, decay: 0.0003, hue: 200 },
  { freqX: 5, freqY: 3, phaseX: Math.PI / 3, phaseY: 0, ampX: 200, ampY: 180, decay: 0.0004, hue: 320 },
  { freqX: 2, freqY: 5, phaseX: 0, phaseY: Math.PI / 6, ampX: 220, ampY: 190, decay: 0.00035, hue: 60 },
];
let activePendulum = 0;
let transitionTime = 0;

ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);

function draw() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.005)';
  ctx.fillRect(0, 0, W, H);

  time++;
  const p = pendulums[activePendulum];
  const damping = Math.exp(-p.decay * time);

  if (damping < 0.05) {
    activePendulum = (activePendulum + 1) % pendulums.length;
    time = 0;
    transitionTime = 60;
    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, W, H);
  }

  if (transitionTime > 0) {
    transitionTime--;
    requestAnimationFrame(draw);
    return;
  }

  const t = time * 0.015;
  const x = W / 2 + Math.sin(p.freqX * t + p.phaseX) * p.ampX * damping;
  const y = H / 2 + Math.sin(p.freqY * t + p.phaseY) * p.ampY * damping;

  const speed = Math.abs(Math.cos(p.freqX * t + p.phaseX) * p.freqX) +
                Math.abs(Math.cos(p.freqY * t + p.phaseY) * p.freqY);
  const brightness = 60 + Math.min(speed * 5, 35);
  const alpha = 0.4 + damping * 0.5;

  const grd = ctx.createRadialGradient(x, y, 0, x, y, 5 + damping * 5);
  grd.addColorStop(0, `hsla(${p.hue}, 100%, ${brightness}%, ${alpha})`);
  grd.addColorStop(0.5, `hsla(${p.hue}, 100%, 50%, ${alpha * 0.3})`);
  grd.addColorStop(1, 'transparent');

  ctx.beginPath();
  ctx.arc(x, y, 5 + damping * 5, 0, Math.PI * 2);
  ctx.fillStyle = grd;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(x, y, 1.5, 0, Math.PI * 2);
  ctx.fillStyle = `hsla(${p.hue}, 100%, 95%, ${alpha})`;
  ctx.fill();

  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The exponential decay function produces the natural spiral-inward motion of a real pendulum losing energy. Different frequency ratios between X and Y produce different Lissajous figures: 3:2 gives a figure-eight variant, 5:3 gives a star-like pattern. The very low fade rate (0.005) means trails persist for hundreds of frames, building up complex overlapping patterns.

6. Long-exposure traffic

Simulated long-exposure photography of traffic at night: red tail lights stream in one direction, white headlights in the other. This is the classic “light trails on a highway” photograph, built from simple particle physics.

const lanes = [
  { y: H * 0.42, dir: 1, color: [255, 60, 30], speed: 2.5 },
  { y: H * 0.47, dir: 1, color: [255, 40, 20], speed: 3 },
  { y: H * 0.55, dir: -1, color: [255, 255, 220], speed: 2.8 },
  { y: H * 0.60, dir: -1, color: [240, 240, 255], speed: 3.2 },
];
const cars = [];

ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, W, H);

ctx.fillStyle = '#1a1a24';
ctx.fillRect(0, H * 0.38, W, H * 0.28);

function spawnCar() {
  const lane = lanes[Math.floor(Math.random() * lanes.length)];
  cars.push({
    x: lane.dir > 0 ? -20 : W + 20,
    y: lane.y + (Math.random() - 0.5) * 8,
    vx: lane.speed * lane.dir * (0.8 + Math.random() * 0.4),
    color: lane.color,
    size: 2 + Math.random(),
  });
}

function draw() {
  ctx.fillStyle = 'rgba(10, 10, 18, 0.02)';
  ctx.fillRect(0, 0, W, H);

  ctx.fillStyle = 'rgba(26, 26, 36, 0.02)';
  ctx.fillRect(0, H * 0.38, W, H * 0.28);

  if (Math.random() < 0.15) spawnCar();

  for (let i = cars.length - 1; i >= 0; i--) {
    const car = cars[i];
    car.x += car.vx;

    if (car.x < -30 || car.x > W + 30) {
      cars.splice(i, 1);
      continue;
    }

    const [r, g, b] = car.color;

    const grd = ctx.createRadialGradient(car.x, car.y, 0, car.x, car.y, car.size * 4);
    grd.addColorStop(0, `rgba(${r}, ${g}, ${b}, 0.8)`);
    grd.addColorStop(0.3, `rgba(${r}, ${g}, ${b}, 0.3)`);
    grd.addColorStop(1, 'transparent');
    ctx.beginPath();
    ctx.arc(car.x, car.y, car.size * 4, 0, Math.PI * 2);
    ctx.fillStyle = grd;
    ctx.fill();

    ctx.beginPath();
    ctx.arc(car.x, car.y, car.size * 0.7, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(${Math.min(r + 50, 255)}, ${Math.min(g + 50, 255)}, ${Math.min(b + 50, 255)}, 0.9)`;
    ctx.fill();

    const streakLen = Math.abs(car.vx) * 3;
    ctx.beginPath();
    ctx.moveTo(car.x, car.y);
    ctx.lineTo(car.x - car.vx * 3, car.y);
    ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.15)`;
    ctx.lineWidth = car.size * 2;
    ctx.lineCap = 'round';
    ctx.stroke();
  }

  const horizonY = H * 0.35;
  for (let i = 0; i < 3; i++) {
    const starX = (Math.sin(i * 47.3 + performance.now() * 0.00001) * 0.5 + 0.5) * W;
    const starY = horizonY * (0.3 + i * 0.2);
    ctx.beginPath();
    ctx.arc(starX, starY, 1, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(200, 200, 255, 0.5)';
    ctx.fill();
  }

  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

Red taillights go left-to-right (away from the viewer), white headlights come right-to-left (toward the viewer)—exactly how a real highway photograph looks. The motion streak drawn behind each car (a short line segment in the direction opposite to velocity) reinforces the speed impression. Over time, the trails merge into the continuous ribbons of light that characterize classic long-exposure highway photography.

7. Light graffiti

Automated light graffiti that draws geometric shapes and patterns, simulating what light painters like LightPaintingLab and LAPP create with precise LED tools. The system draws predefined shapes—circles, triangles, stars—with glowing trails.

const shapes = [
  { type: 'circle', cx: W * 0.3, cy: H * 0.45, r: 80, hue: 280 },
  { type: 'triangle', cx: W * 0.7, cy: H * 0.45, r: 90, hue: 160 },
  { type: 'star', cx: W * 0.5, cy: H * 0.5, r: 100, points: 5, hue: 40 },
];
let shapeIdx = 0;
let progress = 0;
let totalPts = 200;

ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);

function getShapePoint(shape, t) {
  const a = t * Math.PI * 2;
  if (shape.type === 'circle') {
    return { x: shape.cx + Math.cos(a) * shape.r, y: shape.cy + Math.sin(a) * shape.r };
  } else if (shape.type === 'triangle') {
    const sides = 3;
    const segment = Math.floor(t * sides) % sides;
    const segT = (t * sides) % 1;
    const a1 = (segment / sides) * Math.PI * 2 - Math.PI / 2;
    const a2 = ((segment + 1) / sides) * Math.PI * 2 - Math.PI / 2;
    return {
      x: shape.cx + (Math.cos(a1) * (1 - segT) + Math.cos(a2) * segT) * shape.r,
      y: shape.cy + (Math.sin(a1) * (1 - segT) + Math.sin(a2) * segT) * shape.r
    };
  } else {
    const pts = shape.points;
    const totalVerts = pts * 2;
    const segment = Math.floor(t * totalVerts) % totalVerts;
    const segT = (t * totalVerts) % 1;
    const outerR = shape.r;
    const innerR = shape.r * 0.4;
    const a1 = (segment / totalVerts) * Math.PI * 2 - Math.PI / 2;
    const a2 = ((segment + 1) / totalVerts) * Math.PI * 2 - Math.PI / 2;
    const r1 = segment % 2 === 0 ? outerR : innerR;
    const r2 = (segment + 1) % 2 === 0 ? outerR : innerR;
    return {
      x: shape.cx + (Math.cos(a1) * r1 * (1 - segT) + Math.cos(a2) * r2 * segT),
      y: shape.cy + (Math.sin(a1) * r1 * (1 - segT) + Math.sin(a2) * r2 * segT)
    };
  }
}

function draw() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.008)';
  ctx.fillRect(0, 0, W, H);

  const shape = shapes[shapeIdx];
  const t = progress / totalPts;
  const pt = getShapePoint(shape, t);
  const jitterX = (Math.random() - 0.5) * 2;
  const jitterY = (Math.random() - 0.5) * 2;
  const x = pt.x + jitterX;
  const y = pt.y + jitterY;

  const grd = ctx.createRadialGradient(x, y, 0, x, y, 10);
  grd.addColorStop(0, `hsla(${shape.hue}, 100%, 90%, 0.9)`);
  grd.addColorStop(0.3, `hsla(${shape.hue}, 100%, 60%, 0.5)`);
  grd.addColorStop(0.7, `hsla(${shape.hue}, 80%, 40%, 0.15)`);
  grd.addColorStop(1, 'transparent');
  ctx.beginPath();
  ctx.arc(x, y, 10, 0, Math.PI * 2);
  ctx.fillStyle = grd;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(x, y, 1.5, 0, Math.PI * 2);
  ctx.fillStyle = `hsla(${shape.hue}, 100%, 98%, 1)`;
  ctx.fill();

  progress++;
  if (progress >= totalPts) {
    progress = 0;
    shapeIdx = (shapeIdx + 1) % shapes.length;
    if (shapeIdx === 0) {
      setTimeout(() => {
        ctx.fillStyle = '#000';
        ctx.fillRect(0, 0, W, H);
      }, 2000);
    }
  }

  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

The subtle jitter on each point (random offset of ±1 pixel) simulates the imprecision of a human hand holding a light tool. Without it, the shapes look computer-perfect, which breaks the light-painting illusion. Each shape gets a distinct hue: purple circle, teal triangle, golden star—as if using different colored LEDs.

8. Generative light painting composition

The finale: a full generative light painting that combines multiple techniques—orbital paths, particle bursts, flowing curves, and color transitions—into an evolving long-exposure artwork. New elements emerge continuously, creating an infinite light painting that never repeats.

const trails = [];
const sparks = [];
let frame = 0;

function addTrail() {
  const type = Math.random();
  const hue = Math.random() * 360;
  if (type < 0.4) {
    trails.push({
      type: 'orbit',
      cx: W * (0.2 + Math.random() * 0.6),
      cy: H * (0.2 + Math.random() * 0.6),
      rx: 40 + Math.random() * 120,
      ry: 30 + Math.random() * 100,
      speed: (0.3 + Math.random() * 0.7) * (Math.random() > 0.5 ? 1 : -1),
      angle: Math.random() * Math.PI * 2,
      hue, life: 300 + Math.random() * 300, age: 0, size: 2 + Math.random() * 2,
    });
  } else if (type < 0.7) {
    trails.push({
      type: 'wave',
      x: Math.random() > 0.5 ? -10 : W + 10,
      y: H * (0.2 + Math.random() * 0.6),
      vx: (Math.random() > 0.5 ? 1 : -1) * (1 + Math.random()),
      freq: 0.02 + Math.random() * 0.03,
      amp: 30 + Math.random() * 60,
      hue, life: 400, age: 0, size: 2 + Math.random() * 1.5,
    });
  } else {
    trails.push({
      type: 'spiral',
      cx: W * (0.3 + Math.random() * 0.4),
      cy: H * (0.3 + Math.random() * 0.4),
      radius: 10,
      growRate: 0.3 + Math.random() * 0.5,
      angle: 0,
      rotSpeed: 0.08 + Math.random() * 0.06,
      hue, life: 250 + Math.random() * 200, age: 0, size: 2 + Math.random(),
    });
  }
}

ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);

for (let i = 0; i < 3; i++) addTrail();

function draw() {
  frame++;
  ctx.fillStyle = 'rgba(0, 0, 0, 0.012)';
  ctx.fillRect(0, 0, W, H);

  if (frame % 120 === 0 && trails.length < 6) addTrail();

  for (let i = trails.length - 1; i >= 0; i--) {
    const tr = trails[i];
    tr.age++;
    if (tr.age > tr.life) { trails.splice(i, 1); continue; }

    const lifeRatio = 1 - tr.age / tr.life;
    const alpha = lifeRatio * 0.7;
    let x, y;

    if (tr.type === 'orbit') {
      tr.angle += tr.speed * 0.02;
      x = tr.cx + Math.cos(tr.angle) * tr.rx;
      y = tr.cy + Math.sin(tr.angle) * tr.ry;
    } else if (tr.type === 'wave') {
      tr.x += tr.vx;
      x = tr.x;
      y = tr.y + Math.sin(tr.x * tr.freq) * tr.amp;
    } else {
      tr.angle += tr.rotSpeed;
      tr.radius += tr.growRate;
      x = tr.cx + Math.cos(tr.angle) * tr.radius;
      y = tr.cy + Math.sin(tr.angle) * tr.radius;
    }

    const h = (tr.hue + tr.age * 0.2) % 360;

    const grd = ctx.createRadialGradient(x, y, 0, x, y, tr.size * 4);
    grd.addColorStop(0, `hsla(${h}, 100%, 90%, ${alpha})`);
    grd.addColorStop(0.4, `hsla(${h}, 100%, 55%, ${alpha * 0.4})`);
    grd.addColorStop(1, 'transparent');
    ctx.beginPath();
    ctx.arc(x, y, tr.size * 4, 0, Math.PI * 2);
    ctx.fillStyle = grd;
    ctx.fill();

    ctx.beginPath();
    ctx.arc(x, y, tr.size * 0.5, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${h}, 100%, 95%, ${alpha})`;
    ctx.fill();

    if (Math.random() < 0.05) {
      sparks.push({
        x, y,
        vx: (Math.random() - 0.5) * 3,
        vy: (Math.random() - 0.5) * 3 - 0.5,
        life: 1, decay: 0.03 + Math.random() * 0.04, hue: h, size: 1,
      });
    }
  }

  for (let i = sparks.length - 1; i >= 0; i--) {
    const s = sparks[i];
    s.x += s.vx; s.y += s.vy;
    s.vy += 0.02;
    s.life -= s.decay;
    if (s.life <= 0) { sparks.splice(i, 1); continue; }
    ctx.beginPath();
    ctx.arc(s.x, s.y, s.size * s.life, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${s.hue}, 100%, 80%, ${s.life * 0.6})`;
    ctx.fill();
  }

  if (sparks.length > 500) sparks.splice(0, sparks.length - 500);

  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

Three motion types—orbits, sine waves, and spirals—create visual variety. The slow hue drift per trail (`tr.age * 0.2`) produces the rainbow-shift that real light painters achieve by slowly rotating a color wheel filter. Occasional sparks ejected from trail positions add texture without overwhelming the composition. The very slow fade (alpha 0.012) allows patterns to build up over minutes, creating increasingly complex layered images.

Tips for your own light paintings

  • Fade rate is everything. Values of 0.005–0.02 create long, luminous trails. Values above 0.05 produce short streaks more like motion blur than light painting.
  • White-hot cores sell the glow. Every light point should have a tiny, bright white center surrounded by a larger colored halo. This mimics how overexposed light sources look in photographs.
  • Additive color logic. Where trails overlap, they should get brighter, not darker. Use ctx.globalCompositeOperation = 'lighter' for true additive blending, or simulate it with multiple semi-transparent layers.
  • Warm colors dominate. Real light sources skew warm (incandescent bulbs, LEDs, fire). Using yellows, oranges, and warm whites as default colors makes digital light painting feel physically plausible.
  • Human imperfection adds realism. Jitter, slight speed variation, and wobble prevent the mathematical precision that makes code-generated art look sterile.

Explore more generative art on Lumitree, where every branch is a unique micro-world built from code. For more techniques, try the particle systems guide, the drawing with code guide, or the Bézier curves guide.

Related articles