Colour Bloom Hover
Pictures wait in black and white. Hover one and colour blooms out from the pointer in a soft-edged circle until the whole picture glows in full colour. Move away and the colour gathers back to where you left.
How to use it
- Paste the snippet just before
</head>. - Add
data-colour-bloom-hoverto the frame around a picture.
Options
Add any of these attributes to the same element.
data-duration | How long the bloom takes. Default 900 ms. |
data-soft | Width of the bloom’s soft edge, in px. Default 60. |
data-autoplay | Bloom from random spots on a timer while the pointer isn’t over it. Handy for demos and touch screens. |
The grey is a filter laid over the picture, so your image stays as it is. Browsers without backdrop-filter fade the whole picture into colour instead. A tap blooms it on touch screens.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Colour Bloom Hover: pictures sit in greyscale until you hover one, then
// colour blooms out from the pointer in a soft-edged circle until the whole
// picture is in full colour. When the pointer leaves, the colour gathers back
// to where it left and the grey returns.
// Usage: <figure data-colour-bloom-hover><img src="photo.jpg" alt="…"></figure>
// Optional: data-duration (ms for the bloom, default 900), data-soft (width
// of the soft edge, px, default 60), data-autoplay (bloom from random spots
// on a timer while the pointer isn't over it; handy for demos and touch
// screens).
var DEFAULTS = { duration: 900, soft: 60 };
var css =
'.cbh{position:relative;overflow:hidden;isolation:isolate}' +
'.cbh-grey{position:absolute;inset:-1px;pointer-events:none;z-index:1;' +
'-webkit-backdrop-filter:grayscale(1) contrast(1.05);backdrop-filter:grayscale(1) contrast(1.05)}' +
'.cbh-flat>*{filter:grayscale(1);transition:filter .8s ease}' +
'.cbh-flat.cbh-on>*{filter:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var backdrop = window.CSS && (CSS.supports('backdrop-filter', 'grayscale(1)') || CSS.supports('-webkit-backdrop-filter', 'grayscale(1)'));
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
var soft = d.soft !== undefined ? +d.soft : DEFAULTS.soft;
el.classList.add('cbh');
var on = false, anim = null;
// No backdrop-filter: fade the whole picture instead of blooming.
if (!backdrop) {
el.classList.add('cbh-flat');
var flat = {
enter: function () { on = true; el.classList.add('cbh-on'); },
leave: function () { on = false; el.classList.remove('cbh-on'); }
};
wire(el, flat, function () { return on; });
return { enter: flat.enter, leave: flat.leave, isOn: function () { return on; } };
}
var grey = document.createElement('span');
grey.className = 'cbh-grey';
grey.setAttribute('aria-hidden', 'true');
el.appendChild(grey);
// The colour is a hole of radius r at (x, y) in the grey layer.
var b = { x: 0, y: 0, r: -soft };
function draw() {
var g = 'radial-gradient(circle at ' + b.x.toFixed(1) + 'px ' + b.y.toFixed(1) + 'px,transparent ' + b.r.toFixed(1) + 'px,#000 ' + (b.r + soft).toFixed(1) + 'px)';
grey.style.webkitMaskImage = grey.style.maskImage = b.r + soft > 0 ? g : 'none';
}
function far(x, y) {
var w = el.offsetWidth, h = el.offsetHeight;
return Math.sqrt(Math.pow(Math.max(x, w - x), 2) + Math.pow(Math.max(y, h - y), 2)) + 4;
}
function enter(x, y) {
if (on) return;
on = true;
if (anim) anim.pause();
// Already fading back: bloom again from where it is. Otherwise start at the pointer.
if (b.r + soft <= 0) { b.x = x; b.y = y; b.r = -soft; }
anim = anime.animate(b, { r: far(b.x, b.y), duration: reduced() ? 0 : duration, ease: 'out(2)', onUpdate: draw });
}
function leave(x, y) {
if (!on) return;
on = false;
if (anim) anim.pause();
anim = anime.animate(b, { x: x, y: y, r: -soft, duration: reduced() ? 0 : duration * 0.8, ease: 'inOut(2)', onUpdate: draw });
}
draw();
var api = { enter: enter, leave: leave, isOn: function () { return on; } };
wire(el, api, api.isOn);
return api;
}
function local(el, e) {
var r = el.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function wire(el, api, isOn) {
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
el._cbhReal = true;
var p = local(el, e);
api.enter(p[0], p[1]);
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
el._cbhReal = false;
var p = local(el, e);
api.leave(p[0], p[1]);
});
// Touch: a tap blooms from the finger, another tap greys it again.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
el._cbhHold = Date.now() + 5000;
var p = local(el, e);
if (isOn()) api.leave(p[0], p[1]);
else api.enter(p[0], p[1]);
});
el.addEventListener('focusin', function () { api.enter(el.offsetWidth / 2, el.offsetHeight / 2); });
el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) api.leave(el.offsetWidth / 2, el.offsetHeight / 2); });
}
function autoplay(el, api, offset) {
var visible = false, timer = null, mine = false;
function spot() { return [el.offsetWidth * (0.2 + Math.random() * 0.6), el.offsetHeight * (0.2 + Math.random() * 0.6)]; }
function tick() {
if (!visible) return;
var busy = el._cbhReal || el.contains(document.activeElement) || (el._cbhHold && Date.now() < el._cbhHold);
if (busy) { mine = false; timer = setTimeout(tick, 800); return; }
var p = spot();
if (mine) { api.leave(p[0], p[1]); mine = false; timer = setTimeout(tick, 2000); }
else if (!api.isOn()) { api.enter(p[0], p[1]); mine = true; timer = setTimeout(tick, 1600); }
else timer = setTimeout(tick, 800);
}
new IntersectionObserver(function (entries) {
var was = visible;
visible = entries[0].isIntersecting;
if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 300 + offset); }
}).observe(el);
}
var count = 0;
function init() {
document.querySelectorAll('[data-colour-bloom-hover]').forEach(function (el) {
if (el._cbh) return;
el._cbh = true;
var api = setup(el);
var auto = el.dataset.autoplay;
if (auto === undefined || auto === 'false' || reduced()) return;
// Pictures take turns, so a gallery ripples rather than flashing at once.
autoplay(el, api, (count++ % 3) * 1200);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><figure data-colour-bloom-hover>
<img src="dunes.jpg" alt="Sand dunes at sunset">
</figure>