← Hover & cursor

Torchlight Cursor

The area goes dark except for a warm torch beam around the pointer, so you explore it by moving the light about. The beam trails the pointer a little and flickers gently, like a real torch.

Lights outMove the torch to look around

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-torchlight-cursor to the section that should go dark.

Options

Add any of these attributes to the same element.

data-sizeRadius of the beam, in px. Default 110.
data-darknessHow dark the rest gets, from 0 to 1. Default 0.92.
data-colorColour of the dark. Default #07050c, a deep violet-black.
data-stillA steady beam, without the flicker.
data-autoplayA pretend torch sweeps about while a real pointer isn’t in the section. Handy for demos and touch screens.

The dark sits over the section without catching clicks. With nobody steering, the beam rests in the middle. On touch screens, drag a finger to move it. Keyboard users can’t steer it, so the dark lifts while something inside has keyboard focus.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Torchlight Cursor: the area goes dark except for a warm torch beam
    // around the pointer, so you explore it by moving the light about. The
    // beam trails the pointer slightly and flickers gently, like a real torch.
    // Usage: <section data-torchlight-cursor>…</section>
    // Optional: data-size (radius of the beam, px, default 110), data-darkness
    // (how dark the rest gets, 0 to 1, default 0.92), data-color (the colour of
    // the dark, default #07050c), data-still (no flicker), data-autoplay (a
    // pretend torch sweeps about while a real pointer isn't in the area; handy
    // for demos and touch screens).
    // The dark sits over the area without catching clicks. On touch screens,
    // drag a finger to move the beam. Keyboard users can't steer it, so the
    // dark lifts while something inside has keyboard focus.
    var DEFAULTS = { size: 110, darkness: 0.92, color: '#07050c' };

    var css =
      '.tlc{position:relative;overflow:hidden}' +
      '.tlc-dark{position:absolute;inset:0;z-index:5;pointer-events:none;transition:opacity .4s}' +
      '.tlc-lit .tlc-dark{opacity: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; }

    // '#07050c' or 'rgb(…)' → 'r,g,b' for building rgba() strings.
    var probe = document.createElement('canvas').getContext('2d');
    function rgbList(c) {
      probe.fillStyle = '#000';
      probe.fillStyle = c;
      var s = probe.fillStyle;
      if (s.charAt(0) === '#') return [parseInt(s.substr(1, 2), 16), parseInt(s.substr(3, 2), 16), parseInt(s.substr(5, 2), 16)].join(',');
      return (s.match(/[\d.]+/g) || [0, 0, 0]).slice(0, 3).join(',');
    }

    function setup(el) {
      var d = el.dataset;
      var radius = num(d.size, DEFAULTS.size);
      var dark = Math.max(0, Math.min(1, num(d.darkness, DEFAULTS.darkness)));
      var col = rgbList(d.color || DEFAULTS.color);
      var flicker = d.still === undefined || d.still === 'false';
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var still = reduced();
      if (still) flicker = false;
      el.classList.add('tlc');

      var layer = document.createElement('div');
      layer.className = 'tlc-dark';
      layer.setAttribute('aria-hidden', 'true');
      el.appendChild(layer);

      var w = 0, h = 0;
      function measure() { w = el.clientWidth; h = el.clientHeight; }
      measure();

      var cur = { x: 0, y: 0 }, goal = { x: 0, y: 0 }, hover = false, hold = 0, visible = false, raf = 0, t = Math.random() * 6;
      // With nobody steering, the beam rests near the middle.
      var rest = { x: 0.5, y: 0.45 };
      cur.x = goal.x = w * rest.x; cur.y = goal.y = h * rest.y;

      function paint(now) {
        var r = Math.min(radius, Math.max(w, h) * 0.45);
        if (flicker) r *= 1 + 0.025 * Math.sin(now / 90) * Math.sin(now / 237) + 0.012 * Math.sin(now / 41);
        var x = cur.x.toFixed(1) + 'px ' + cur.y.toFixed(1) + 'px';
        layer.style.background =
          // A faint warm glow inside the beam…
          'radial-gradient(circle ' + (r * 0.9).toFixed(1) + 'px at ' + x + ', rgba(255,214,140,.16), rgba(255,214,140,0) 70%),' +
          // …and the dark, with a soft-edged hole.
          'radial-gradient(circle ' + (r * 1.35).toFixed(1) + 'px at ' + x + ', rgba(' + col + ',0) 0, rgba(' + col + ',0) 45%, rgba(' + col + ',' + (dark * 0.6).toFixed(3) + ') 72%, rgba(' + col + ',' + dark + ') 100%)';
      }

      function local(cx, cy) {
        var b = el.getBoundingClientRect();
        return { x: cx - b.left, y: cy - b.top };
      }
      function aim(cx, cy) {
        var p = local(cx, cy);
        goal.x = p.x; goal.y = p.y;
        if (still) { cur.x = p.x; cur.y = p.y; paint(0); }
        wake();
      }

      function frame(now) {
        raf = 0;
        if (!visible) return;
        var fake = auto && !hover && Date.now() > hold;
        if (fake) {
          // A slow searching sweep, pausing now and then.
          t += 0.008 + 0.012 * Math.pow(Math.sin(t * 0.8), 2);
          goal.x = w * (0.5 + 0.34 * Math.sin(t * 1.2));
          goal.y = h * (0.5 + 0.26 * Math.sin(t * 2.1 + 1.1));
        }
        cur.x += (goal.x - cur.x) * 0.14;
        cur.y += (goal.y - cur.y) * 0.14;
        paint(now);
        var moving = Math.abs(goal.x - cur.x) + Math.abs(goal.y - cur.y) > 0.3;
        if (fake || moving || flicker) raf = requestAnimationFrame(frame);
      }
      function wake() { if (!raf && visible && !still) raf = requestAnimationFrame(frame); }

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') hold = Date.now() + 3000;
        else hover = true;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType !== 'touch') hover = false;
      });
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 3000;
        aim(e.clientX, e.clientY);
      });
      // Keyboard users can't steer the beam, so lift the dark while focus is inside.
      el.addEventListener('focusin', function (e) {
        var kb = true;
        try { kb = e.target.matches(':focus-visible'); } catch (err) {}
        if (kb) el.classList.add('tlc-lit');
      });
      el.addEventListener('focusout', function () { el.classList.remove('tlc-lit'); });

      new ResizeObserver(function () {
        var ow = w, oh = h;
        measure();
        if (ow && oh) { cur.x *= w / ow; cur.y *= h / oh; goal.x *= w / ow; goal.y *= h / oh; }
        else { cur.x = goal.x = w * rest.x; cur.y = goal.y = h * rest.y; }
        paint(0);
        wake();
      }).observe(el);
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);
      paint(0);
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section class="cellar" data-torchlight-cursor>…</section>

<!-- a wider, steady beam: -->
<section data-torchlight-cursor data-size="180" data-still>…</section>