← Filters & retro

Lens Flare

The pointer becomes a bright light shining into the lens. A glowing core with a starburst and a long blue streak sits under it, and a chain of coloured ghosts and a faint halo line up through the middle of the picture, swinging round as you move.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-lens-flare to an image wrapper, or any section. It works with any image, even from another site.
  3. The flare is drawn on top with a screen blend, so it only ever brightens.

Options

Add any of these attributes to the same element.

data-autoplayA pretend light sweeps across while nobody is pointing. Handy on phones.
data-intensityHow bright the flare is, from 0 to 2. Default 1.
data-colorTint of the core and the streak. Default #fff1c4.
data-streakfalse hides the long horizontal streak.

Touch works too: drag a finger across the picture.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Lens Flare: the pointer becomes a bright light shining into the camera.
    // A glowing core with a starburst and a long streak sits under the pointer,
    // and a chain of coloured ghosts and a halo line up through the centre of
    // the picture, swinging round as the light moves.
    // Usage: <figure data-lens-flare><img src="photo.jpg" alt="…"></figure>
    // Works on any element; an image inside is fine from any site.
    // Optional: data-autoplay (a pretend light drifts across while nobody is
    // pointing), data-intensity (0 to 2, default 1), data-color (tint of the
    // core and streak, default #fff1c4), data-streak (false hides the long
    // horizontal streak).
    var DEFAULTS = { intensity: 1, color: '#fff1c4', ease: 0.14 };

    // Ghosts along the line from the light through the centre: distance along it, size, colour, shape.
    var GHOSTS = [
      [0.32, 0.05, 'rgba(255,214,120,.45)', 'disc'],
      [0.55, 0.11, 'rgba(120,255,200,.22)', 'hex'],
      [0.72, 0.035, 'rgba(255,255,255,.6)', 'disc'],
      [1.18, 0.16, 'rgba(110,170,255,.18)', 'hex'],
      [1.38, 0.06, 'rgba(255,120,200,.4)', 'disc'],
      [1.62, 0.24, 'rgba(160,120,255,.14)', 'ring'],
      [1.95, 0.09, 'rgba(120,220,255,.32)', 'hex']
    ];

    var css =
      '.lfl{position:relative;overflow:hidden}' +
      '.lfl-layer{position:absolute;inset:0;pointer-events:none;mix-blend-mode:screen;opacity:0;transition:opacity .5s}' +
      '.lfl-on .lfl-layer{opacity:1}' +
      '.lfl-layer>*{position:absolute;left:0;top:0;border-radius:50%;will-change:transform}' +
      '.lfl-wash{background:radial-gradient(closest-side,var(--lfl-c-a),transparent)}' +
      '.lfl-core{width:var(--lfl-s);height:var(--lfl-s);margin:calc(var(--lfl-s)/-2) 0 0 calc(var(--lfl-s)/-2);background:radial-gradient(closest-side,#fff 0,#fff 10%,var(--lfl-c) 24%,transparent)}' +
      '.lfl-rays{width:calc(var(--lfl-s)*2.4);height:calc(var(--lfl-s)*2.4);margin:calc(var(--lfl-s)*-1.2) 0 0 calc(var(--lfl-s)*-1.2);' +
        'background:repeating-conic-gradient(from 0deg,rgba(255,255,255,.55) 0deg 1.2deg,transparent 2deg 15deg);' +
        '-webkit-mask:radial-gradient(closest-side,#000 8%,transparent);mask:radial-gradient(closest-side,#000 8%,transparent)}' +
      '.lfl-streak{width:220%;height:3px;margin:-1.5px 0 0 -110%;border-radius:0!important;background:linear-gradient(90deg,transparent,rgba(140,190,255,.35) 30%,var(--lfl-c) 50%,rgba(140,190,255,.35) 70%,transparent);filter:blur(.5px)}' +
      '.lfl-halo{border-radius:50%;box-shadow:inset 0 0 0 2px rgba(255,200,140,.18),0 0 0 1px rgba(140,200,255,.12)}' +
      '.lfl-disc{background:radial-gradient(closest-side,var(--lfl-g) 0,var(--lfl-g) 70%,transparent)}' +
      '.lfl-hex{border-radius:0!important;clip-path:polygon(25% 3%,75% 3%,100% 50%,75% 97%,25% 97%,0 50%);background:radial-gradient(closest-side,transparent 30%,var(--lfl-g))}' +
      '.lfl-ring{background:radial-gradient(closest-side,transparent 72%,var(--lfl-g) 86%,transparent)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function rgba(hex, a) {
      hex = hex.replace('#', '');
      if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
      var n = parseInt(hex, 16);
      return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
    }

    function part(layer, cls) {
      var d = document.createElement('i');
      d.className = cls;
      layer.appendChild(d);
      return d;
    }

    function setup(el) {
      var d = el.dataset;
      var intensity = d.intensity !== undefined ? +d.intensity : DEFAULTS.intensity;
      var color = d.color || DEFAULTS.color;
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      el.classList.add('lfl');

      var layer = document.createElement('div');
      layer.className = 'lfl-layer';
      layer.setAttribute('aria-hidden', 'true');
      layer.style.setProperty('--lfl-c', rgba(color, 0.9));
      layer.style.setProperty('--lfl-c-a', rgba(color, 0.22 * intensity));
      var wash = part(layer, 'lfl-wash');
      var halo = part(layer, 'lfl-halo');
      var ghosts = GHOSTS.map(function (g) {
        var n = part(layer, 'lfl-' + g[3]);
        n.style.setProperty('--lfl-g', g[2]);
        return n;
      });
      var streak = d.streak === 'false' ? null : part(layer, 'lfl-streak');
      var rays = part(layer, 'lfl-rays');
      var core = part(layer, 'lfl-core');
      el.appendChild(layer);

      var w = 0, h = 0;
      function measure() {
        w = el.clientWidth; h = el.clientHeight;
        var diag = Math.sqrt(w * w + h * h);
        layer.style.setProperty('--lfl-s', Math.round(Math.min(220, diag * 0.22) * (0.6 + intensity * 0.4)) + 'px');
        ghosts.forEach(function (n, i) {
          var s = Math.round(GHOSTS[i][1] * diag);
          n.style.width = n.style.height = s + 'px';
          n.style.margin = (-s / 2) + 'px 0 0 ' + (-s / 2) + 'px';
        });
        var ws = Math.round(diag * 1.1);
        wash.style.width = wash.style.height = ws + 'px';
        wash.style.margin = (-ws / 2) + 'px 0 0 ' + (-ws / 2) + 'px';
        var hs = Math.round(diag * 0.5);
        halo.style.width = halo.style.height = hs + 'px';
        halo.style.margin = (-hs / 2) + 'px 0 0 ' + (-hs / 2) + 'px';
      }
      measure();
      if (window.ResizeObserver) new ResizeObserver(measure).observe(el);

      // Light position as a fraction of the box: where it is now and where it is heading.
      var pos = { x: 0.7, y: 0.3 }, target = { x: 0.7, y: 0.3 };

      function render() {
        var x = pos.x * w, y = pos.y * h, cx = w / 2, cy = h / 2;
        var t = 'translate(' + x + 'px,' + y + 'px)';
        core.style.transform = t;
        wash.style.transform = t;
        // The rays turn slowly as the light moves sideways.
        rays.style.transform = t + ' rotate(' + (pos.x * 40 - pos.y * 25) + 'deg)';
        if (streak) streak.style.transform = t;
        halo.style.transform = t;
        // Ghosts are brightest when the light is near an edge, the way a real lens catches it.
        var off = Math.min(1, Math.sqrt(Math.pow(pos.x - 0.5, 2) + Math.pow(pos.y - 0.5, 2)) * 2);
        var ga = (0.45 + off * 0.75) * Math.min(1.4, intensity);
        ghosts.forEach(function (n, i) {
          var k = GHOSTS[i][0];
          n.style.transform = 'translate(' + (x + (cx - x) * k) + 'px,' + (y + (cy - y) * k) + 'px)';
          n.style.opacity = ga;
        });
      }

      function show() { el.classList.add('lfl-on'); }

      // Pointer and touch: the light follows. After a pause the pretend light takes over again.
      var lastUser = -1e9, active = false;
      function point(e) {
        var r = el.getBoundingClientRect();
        target.x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
        target.y = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height));
        lastUser = performance.now();
        active = true;
        show();
      }
      el.addEventListener('pointermove', point);
      el.addEventListener('pointerdown', point);
      el.addEventListener('pointerleave', function () {
        active = false;
        lastUser = performance.now();
        if (!autoplay) el.classList.remove('lfl-on');
      });

      if (reduced()) {
        // A still flare from the top right, and it jumps to the pointer without easing.
        pos.x = target.x; pos.y = target.y;
        render(); show();
        el.addEventListener('pointermove', function () { pos.x = target.x; pos.y = target.y; render(); });
        return;
      }

      var start = performance.now();
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          if (autoplay && !active && now - lastUser > 1200) {
            // A slow sweep, like the sun crossing the sky behind the camera.
            var a = (now - start) / 1000;
            target.x = 0.5 + Math.sin(a * 0.45) * 0.42;
            target.y = 0.36 - Math.cos(a * 0.45) * 0.16 + Math.sin(a * 0.9) * 0.05;
          }
          pos.x += (target.x - pos.x) * DEFAULTS.ease;
          pos.y += (target.y - pos.y) * DEFAULTS.ease;
          render();
        }
      });

      if (autoplay) show();
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play();
        else timer.pause();
      }, { threshold: 0.1 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-lens-flare]').forEach(function (el) {
        if (el._lfl) return;
        el._lfl = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-lens-flare data-autoplay style="height:400px">
  <img src="photo.jpg" alt="Sand dunes at sunset">
</figure>