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
- Paste the snippet just before
</head>. - Add
data-torchlight-cursorto the section that should go dark.
Options
Add any of these attributes to the same element.
data-size | Radius of the beam, in px. Default 110. |
data-darkness | How dark the rest gets, from 0 to 1. Default 0.92. |
data-color | Colour of the dark. Default #07050c, a deep violet-black. |
data-still | A steady beam, without the flicker. |
data-autoplay | A 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.
<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><section class="cellar" data-torchlight-cursor>…</section>
<!-- a wider, steady beam: -->
<section data-torchlight-cursor data-size="180" data-still>…</section>