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
- Paste the snippet just before
</head>. - Add
data-lens-flareto an image wrapper, or any section. It works with any image, even from another site. - 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-autoplay | A pretend light sweeps across while nobody is pointing. Handy on phones. |
data-intensity | How bright the flare is, from 0 to 2. Default 1. |
data-color | Tint of the core and the streak. Default #fff1c4. |
data-streak | false hides the long horizontal streak. |
Touch works too: drag a finger across the picture.
<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><figure data-lens-flare data-autoplay style="height:400px">
<img src="photo.jpg" alt="Sand dunes at sunset">
</figure>