← Hover & cursor

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.

Lakes
Aurora
Dunes

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-colour-bloom-hover to the frame around a picture.

Options

Add any of these attributes to the same element.

data-durationHow long the bloom takes. Default 900 ms.
data-softWidth of the bloom’s soft edge, in px. Default 60.
data-autoplayBloom 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.

1 · Snippet
<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>
2 · Your HTML
<figure data-colour-bloom-hover>
  <img src="dunes.jpg" alt="Sand dunes at sunset">
</figure>