← Hover & cursor

Cursor Mask Reveal

A circle follows the pointer and cuts a hole through the top of the area, showing a hidden layer underneath, like peeking through a porthole. Over the headline it swells wider, and when the pointer leaves it shrinks away.

Simple on the surface.

Move your pointer to look underneath

Wild underneath.

Every layer has a secret

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-cursor-mask-reveal to the area.
  3. Put the hidden layer inside it, marked data-reveal. It’s laid over the whole area, so give it its own background.
  4. Optionally, mark items such as the headline with data-mask-grow to make the circle swell over them.

Options

Add any of these attributes to the same element.

data-radiusSize of the circle, in px. Default 70.
data-grow-radiusSize of the circle over items marked data-mask-grow, in px. Default 2.4 times the radius.
data-softWidth of the circle’s soft edge, in px. Default 2.
data-autoplayA pretend pointer wanders about while a real one isn’t over the area. Handy for demos and touch screens.

Lay out the hidden layer like the top one, so its text lines up underneath. The hidden layer is left out for screen readers, so don’t put anything there that only lives there. On touch screens the circle jumps to your finger.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Cursor Mask Reveal: a circle round the pointer cuts through the top of an
    // area to show a hidden layer beneath, as if looking through a porthole.
    // The circle swells over anything marked data-mask-grow, and shrinks away
    // when the pointer leaves.
    // Usage:
    //   <section data-cursor-mask-reveal>
    //     <h2>Simple on the surface.</h2>
    //     <div data-reveal><h2>Wild underneath.</h2></div>
    //   </section>
    // The data-reveal layer is laid over the whole area; style it as you like.
    // Optional: data-radius (px, default 70), data-grow-radius (px over items
    // marked data-mask-grow, default 2.4 times the radius, but never more
    // than the area can hold), data-soft (width of
    // the circle's soft edge, px, default 2), data-autoplay (a pretend pointer
    // wanders about while a real one isn't over it; handy for demos and touch
    // screens).
    var DEFAULTS = { radius: 70, grow: 2.4, soft: 2 };

    var css =
      '.cmr{position:relative;overflow:hidden;isolation:isolate}' +
      '.cmr-layer{position:absolute;inset:0;z-index:2;pointer-events:none;' +
      '-webkit-mask-image:radial-gradient(circle at 0 0,#000 0,transparent 0);mask-image:radial-gradient(circle at 0 0,#000 0,transparent 0)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    function setup(el) {
      var d = el.dataset;
      var layer = el.querySelector('[data-reveal]');
      if (!layer) return;
      var radius = num(d.radius, DEFAULTS.radius);
      var grow = num(d.growRadius, radius * DEFAULTS.grow);
      var soft = Math.max(0.5, num(d.soft, DEFAULTS.soft));
      var auto = d.autoplay !== undefined && d.autoplay !== 'false' && !reduced();
      el.classList.add('cmr');
      layer.classList.add('cmr-layer');
      layer.setAttribute('aria-hidden', 'true');

      var s = { x: 0, y: 0, tx: 0, ty: 0, r: 0, t: Math.random() * 6 };
      var hover = false, hold = 0, visible = false, raf = 0, w = 0, h = 0;
      var growers = [].slice.call(el.querySelectorAll('[data-mask-grow]')).filter(function (g) { return !layer.contains(g); });

      function size() { w = el.clientWidth; h = el.clientHeight; }
      size();
      new ResizeObserver(size).observe(el);

      function aim(cx, cy, jump) {
        var b = el.getBoundingClientRect();
        s.tx = cx - b.left; s.ty = cy - b.top;
        if (jump || s.r < 2) { s.x = s.tx; s.y = s.ty; }
      }
      // Is (x, y), in the area's own coordinates, over a data-mask-grow item?
      function overGrower(x, y) {
        if (!growers.length) return false;
        var b = el.getBoundingClientRect();
        for (var i = 0; i < growers.length; i++) {
          var g = growers[i].getBoundingClientRect();
          if (b.left + x >= g.left && b.left + x <= g.right && b.top + y >= g.top && b.top + y <= g.bottom) return true;
        }
        return false;
      }
      function draw() {
        var r = Math.max(0, s.r);
        var m = 'radial-gradient(circle at ' + s.x.toFixed(1) + 'px ' + s.y.toFixed(1) + 'px,#000 ' + Math.max(0, r - soft).toFixed(1) + 'px,transparent ' + r.toFixed(1) + 'px)';
        layer.style.webkitMaskImage = layer.style.maskImage = m;
      }

      function frame() {
        raf = 0;
        if (!visible) return;
        var touchHeld = Date.now() < hold;
        var pretend = auto && !hover && !touchHeld;
        if (pretend) {
          s.t += 0.011;
          s.tx = w * (0.5 + 0.36 * Math.sin(s.t * 1.1));
          s.ty = h * (0.5 + 0.28 * Math.sin(s.t * 1.7 + 0.6));
        }
        var on = hover || touchHeld || pretend;
        var ease = reduced() ? 1 : 0.14;
        s.x += (s.tx - s.x) * ease;
        s.y += (s.ty - s.y) * ease;
        var big = Math.max(radius, Math.min(grow, Math.min(w, h) * 0.42));
        var target = on ? (overGrower(s.x, s.y) ? big : radius) : 0;
        s.r += (target - s.r) * (reduced() ? 1 : 0.12);
        draw();
        if (on || s.r > 0.3) raf = requestAnimationFrame(frame);
        else { s.r = 0; draw(); }
      }
      function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true;
        aim(e.clientX, e.clientY);
        wake();
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') hold = Date.now() + 3500;
        else hover = true;
        aim(e.clientX, e.clientY);
        wake();
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        hover = false;
        wake();
      });
      // Touch: the circle jumps to the finger and stays a moment.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 3500;
        aim(e.clientX, e.clientY, true);
        wake();
      });

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-cursor-mask-reveal]').forEach(function (el) {
        if (el._cmr) return;
        el._cmr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section class="hero" data-cursor-mask-reveal>
  <h2 data-mask-grow>Simple on the surface.</h2>
  <div class="hero-under" data-reveal>
    <h2>Wild underneath.</h2>
  </div>
</section>