← Backgrounds

Night Light Trails

A long-exposure photo of a road at night, in motion: streaks of white headlights pour towards you on one side, red tail lights stream away on the other, and street lamps glow along the verges as the road curves off to the horizon.

The city never sleeps

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-night-light-trails to a section with a dark background.

Options

Add any of these attributes to the same element.

data-headHeadlight colour. Default warm white #fff1d0.
data-tailTail light colour. Default red #ff3030.
data-lampsStreet lamp colour. Default amber #fbbf24; "false" for no lamps.
data-densityHow much traffic, as a multiplier. Default 1.
data-speedSpeed multiplier. Default 1.
data-horizonHow far down the horizon sits, from 0 (top) to 1 (bottom). Default 0.45.

It’s drawn in perspective on a transparent canvas, so give the section a night sky above the horizon and dark ground below, as in the example. It pauses when off screen; with reduced motion it shows one still frame.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Night Light Trails: a long-exposure photo of a road at night. Streaks of
    // white headlights stream towards you on one side, red tail lights stream
    // away on the other, and street lamps glow along the verges as the road
    // curves off to the horizon. Drawn on a transparent canvas behind your
    // content, and paused when off screen.
    // Usage: <section data-night-light-trails style="background:linear-gradient(#0b1026,#24163d 45%,#07070c 45%)">…</section>
    // Optional: data-head (headlight colour, default warm white #fff1d0),
    // data-tail (tail light colour, default red #ff3030), data-lamps (street
    // lamp colour, default amber #fbbf24, or "false" for none), data-density
    // (traffic, as a multiplier, default 1), data-speed (default 1),
    // data-horizon (how far down the horizon sits, 0 to 1, default 0.45).
    var DEFAULTS = { head: '#fff1d0', tail: '#ff3030', lamps: '#fbbf24', density: 1, speed: 1, horizon: 0.45 };

    var css =
      '[data-night-light-trails]{position:relative;isolation:isolate;overflow:hidden}' +
      '.nlt-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var TAU = Math.PI * 2;
    var probe = document.createElement('canvas').getContext('2d');
    function rgb(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c;
      var v = probe.fillStyle;
      if (v[0] === '#') return [parseInt(v.slice(1, 3), 16), parseInt(v.slice(3, 5), 16), parseInt(v.slice(5, 7), 16)];
      return v.match(/[\d.]+/g).slice(0, 3).map(Number);
    }

    // The road, in road units: x across (the carriageway runs from -1 to 1),
    // z away from you (1 is the bottom edge of the section, 40 the horizon).
    var FAR = 40, NEAR = 0.55, BEND = 0.018;
    var LANES = [-0.72, -0.26, 0.26, 0.72];

    function build(el) {
      var d = el.dataset;
      var head = rgb(d.head || DEFAULTS.head).join(',');
      var tail = rgb(d.tail || DEFAULTS.tail).join(',');
      var lamps = d.lamps === 'false' ? null : rgb(d.lamps || DEFAULTS.lamps).join(',');
      var density = d.density !== undefined ? +d.density : DEFAULTS.density;
      var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
      var hz = d.horizon !== undefined ? +d.horizon : DEFAULTS.horizon;
      var canvas = document.createElement('canvas');
      canvas.className = 'nlt-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var R = anime.utils.random;
      var w = 0, h = 0, dpr = 1, y0 = 0, S = 1, cars = [];
      var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

      // Road units to screen pixels. The road bends off to the right in the
      // distance.
      function sx(x, z) { return w * 0.5 + (x + BEND * z * z - 0.25) * S / z; }
      function sy(z) { return y0 + (h - y0) / z; }

      // The two left lanes come towards you (headlights), the two right lanes
      // go away (tail lights).
      function spawn(car, anywhere) {
        var lane = car.lane;
        car.toward = lane < 2;
        car.v = R(7, 12, 2) * (lane === 0 || lane === 3 ? 0.85 : 1.1);
        car.trail = R(5, 9, 2);
        car.a = R(0.6, 1, 2);
        if (anywhere) car.z = R(NEAR, FAR, 2);
        else car.z = car.toward ? FAR + R(0, 25, 1) : NEAR - R(0.1, 2.5, 2);
      }

      function makeCars() {
        cars = [];
        var per = Math.max(1, Math.round(3 * density));
        for (var l = 0; l < 4; l++) {
          for (var i = 0; i < per; i++) {
            var car = { lane: l };
            spawn(car, true);
            cars.push(car);
          }
        }
      }

      function resize() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        canvas.width = w * dpr;
        canvas.height = h * dpr;
        y0 = h * hz;
        // Wide enough that the near lanes spread past the bottom corners.
        S = Math.max(w * 0.42, (h - y0) * 1.1);
        if (!cars.length) makeCars();
        draw();
      }

      // One light: a streak from the car back along where it has been,
      // fading towards its far end, drawn twice for a soft glow.
      function streak(x, z0, z1, col, a) {
        var N = 16, prevX = sx(x, z0), prevY = sy(z0);
        for (var k = 1; k <= N; k++) {
          var z = z0 * Math.pow(z1 / z0, k / N);
          var px = sx(x, z), py = sy(z);
          var zm = z0 * Math.pow(z1 / z0, (k - 0.5) / N);
          var fade = Math.pow(1 - (k - 1) / N, 1.4) * a * Math.min(1, (FAR - zm) / 8);
          if (fade > 0.01) {
            var lw = Math.max(0.6, (0.03 * S) / zm);
            ctx.strokeStyle = 'rgba(' + col + ',' + (fade * 0.22).toFixed(3) + ')';
            ctx.lineWidth = lw * 3.2;
            ctx.beginPath();
            ctx.moveTo(prevX, prevY);
            ctx.lineTo(px, py);
            ctx.stroke();
            ctx.strokeStyle = 'rgba(' + col + ',' + fade.toFixed(3) + ')';
            ctx.lineWidth = lw;
            ctx.stroke();
          }
          prevX = px; prevY = py;
        }
      }

      function glow(x, y, r, col, a) {
        var g = ctx.createRadialGradient(x, y, 0, x, y, r);
        g.addColorStop(0, 'rgba(' + col + ',' + a + ')');
        g.addColorStop(0.25, 'rgba(' + col + ',' + a * 0.4 + ')');
        g.addColorStop(1, 'rgba(' + col + ',0)');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(x, y, r, 0, TAU);
        ctx.fill();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.globalCompositeOperation = 'source-over';
        ctx.clearRect(0, 0, w, h);
        ctx.lineCap = 'butt';
        // Faint road edges and lane lines catching the light.
        [-1.05, 1.05, 0].forEach(function (x) {
          ctx.strokeStyle = x ? 'rgba(255,255,255,.09)' : 'rgba(255,220,150,.07)';
          ctx.lineWidth = 1;
          ctx.beginPath();
          for (var z = NEAR; z < FAR; z *= 1.08) {
            if (z === NEAR) ctx.moveTo(sx(x, z), sy(z));
            else ctx.lineTo(sx(x, z), sy(z));
          }
          ctx.stroke();
        });
        ctx.globalCompositeOperation = 'lighter';
        if (lamps) {
          for (var z = 2.2; z < FAR; z += 3.2) {
            var u = 1 / z, fade = Math.min(1, (FAR - z) / 10) * Math.min(1, z / 5);
            [-1.35, 1.35].forEach(function (x) {
              var px = sx(x, z), py = sy(z) - 0.7 * u * S;
              glow(px, py, Math.max(2.5, Math.min(40, 0.22 * u * S)), lamps, (0.6 * fade).toFixed(3));
            });
          }
        }
        for (var i = 0; i < cars.length; i++) {
          var c = cars[i];
          var x = LANES[c.lane];
          var z1 = c.toward ? c.z + c.trail : Math.max(NEAR * 0.6, c.z - c.trail);
          var zc = Math.max(0.3, Math.min(c.z, FAR));
          var zt = Math.max(0.3, Math.min(z1, FAR));
          if (Math.abs(zc - zt) < 0.01) continue;
          streak(x - 0.09, zc, zt, c.toward ? head : tail, c.a);
          streak(x + 0.09, zc, zt, c.toward ? head : tail, c.a);
        }
        ctx.globalCompositeOperation = 'source-over';
      }

      function advance(dt) {
        for (var i = 0; i < cars.length; i++) {
          var c = cars[i];
          // Constant speed along the road, so far cars crawl and near ones fly.
          c.z += ((c.toward ? -1 : 1) * c.v * speed * dt) / 1000;
          if (c.toward && c.z + c.trail < NEAR) spawn(c);
          if (!c.toward && c.z - c.trail > FAR) spawn(c);
        }
      }

      resize();
      if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
      if (still) return;

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) : 16;
          last = now;
          advance(dt);
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-night-light-trails]').forEach(function (el) {
        if (el._nlt) return;
        el._nlt = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section data-night-light-trails style="background: linear-gradient(#0b1026, #2a1840 45%, #08070d 45%); color: #fff">
  <h1>The city never sleeps</h1>
</section>