A cursor spotlight on a dot grid needs one masked layer and three CSS variables
Stitch's canvas darkens the dots under your cursor. I wanted the same press on my grid, so I inspected it: two copies of one tile, a radial mask, and a fade Stitch doesn't have.
Stitch's canvas has a detail I kept noticing. The dot grid under the artboards darkens around the cursor. My site sits on a dot grid as well, and I wanted its dots to darken the same way, like a pencil pressed into paper, so I went looking at how Stitch does it.
The answer is one extra layer, one mask, and three CSS variables that a script updates once per frame. The rest of this post is that code, and the fade I added so the dots settle when the hand stops moving.

Stitch draws the grid twice and masks the top copy
Inspecting the Stitch canvas shows two identical dot-pattern SVGs stacked on top of each other. The bottom one is light gray and always visible. The top one is black and masked to a circle around the pointer. On every mousemove the app rewrites an inline mask on that top layer:
mask-image: radial-gradient(100px at 900px 300px,
#000 0%, rgba(0, 0, 0, .8) 25%, rgba(0, 0, 0, .4) 55%, transparent 100%);The mask jumps to the new coordinates each frame. When the pointer leaves the canvas the layer gets a fully transparent mask and opacity zero.
The dark layer is the same tile as the light one, so the highlighted dots land exactly on the resting dots and nothing appears to move. The gradient stops give the disc a shoulder rather than a linear falloff: full strength at the center, still 80% a quarter of the way out, 40% just past halfway, then a fast drop to nothing.
Move the mask, not the gradient
Rewriting a viewport-sized gradient on every mousemove works, but it re-rasterizes the mask each frame. The same result comes from rasterizing the disc once and moving it. The mask image is sized to the disc, set to no-repeat, and positioned from two variables:
:root {
--mx: 0; --my: 0; /* pointer, viewport px, set by script */
--press: 0; /* 1 while the pointer moves, 0 at rest */
--press-d: 240px; /* disc diameter */
}
.container::before {
content: '';
position: fixed; inset: 0;
z-index: -1;
pointer-events: none;
background: var(--grid-press);
background-size: 120px 120px;
background-position: 12px calc(57px + var(--sy) * (var(--rate) - 1) * 1px);
mask-image: radial-gradient(closest-side,
rgb(0 0 0) 0%, rgb(0 0 0 / .8) 25%, rgb(0 0 0 / .4) 55%, transparent 100%);
mask-size: var(--press-d) var(--press-d);
mask-repeat: no-repeat;
mask-position:
calc(var(--mx) * 1px - var(--press-d) / 2)
calc(var(--my) * 1px - var(--press-d) / 2);
opacity: var(--press);
}
@media (hover: none), (prefers-reduced-motion: reduce) {
.container::before { display: none; }
}--grid-press is the site's grid tile, but with more opacity. The background-position formula is the one the site already uses to keep a fixed copy of the grid in phase with the scrolling one during parallax, so the darker dots sit exactly on the resting ones at any scroll offset. The closest-side keyword makes the gradient's radius half the mask size, so the disc fills its box and the rim lands at transparent.
The variables are unitless on purpose. The script writes raw clientX and clientY, and the multiply by 1px happens in the calc. The --sy variable for scroll already works that way.
The script side is small. The site already had a scroll listener that sets --sy inside a single requestAnimationFrame, so the pointer rides along in the same frame:
var mx = 0, my = 0, press = 0, pointerDirty = false;
function apply() {
pending = false;
root.style.setProperty('--sy', String(window.scrollY || 0));
if (!pointerDirty) return;
pointerDirty = false;
root.style.setProperty('--mx', String(mx));
root.style.setProperty('--my', String(my));
root.style.setProperty('--press', String(press));
}
if (matchMedia('(hover: hover) and (pointer: fine)').matches) {
addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') return;
mx = e.clientX; my = e.clientY; press = 1; pointerDirty = true;
schedule();
}, { passive: true });
root.addEventListener('mouseleave', function () {
press = 0; pointerDirty = true;
schedule();
});
}schedule() is the existing rAF debounce. Nothing runs on a loop; when the pointer is still, nothing runs at all. Touch is ignored, and the media query means phones never attach the listener in the first place. The CSS hides the layer for (hover: none) as well, so a device with no fine pointer pays nothing.
Fade out on stillness, not on leave
The first version was Stitch-faithful: instant on, instant off. After living with it for a bit, I wanted the dots to settle when the hand stops. Moving the cursor should press them immediately. Stopping should let them fade back. Moving again should press them again, fast.
That's an idle timer on the script side and two different durations on the CSS side.
var PRESS_IDLE_MS = 150;
var idleTimer = 0;
function rest() { press = 0; pointerDirty = true; schedule(); }
// inside the pointermove handler, after setting press = 1:
clearTimeout(idleTimer);
idleTimer = setTimeout(rest, PRESS_IDLE_MS);Mouse events arrive every few milliseconds while the pointer moves, so a 150ms window never trips mid-gesture. It only fires once the hand has actually stopped.
The two durations fit in a single declaration. A transition takes its duration from the style it's transitioning to, so the duration can be computed from the value being written:
:root { --press-in: .2s; --press-out: 1s; }
.container::before {
opacity: var(--press);
transition: opacity ease-out;
transition-duration: calc(
var(--press) * var(--press-in) + (1 - var(--press)) * var(--press-out)
);
}When --press becomes 1 the duration resolves to .2s and the disc comes up fast. When it becomes 0 the duration resolves to 1s and the dots settle slowly. No class toggling, no second variable from the script, and the disc position is still never eased. Only the opacity is.
Measured on the production build, opacity falls from 1 to .01 between 210ms and a little over a second after the last move, and climbs back past .8 within 110ms of moving again.
Don't read a duration back out of CSS
The idle threshold started as a CSS token, --press-idle: 150ms, read into the script with parseFloat so timing lived in one place. That worked in development and broke in production. Next's CSS minifier rewrites 150ms as .15s. The script parsed 0.15, set a timer for a sixth of a millisecond, and the press flickered off in the gap between every pair of mouse events. The threshold is now a plain constant in the script, and CSS keeps only the two fade durations, which nothing but CSS consumes.
The debugging was its own small lesson. The first measurement showed the press staying on for a full second after the cursor stopped, the opposite of the real bug, because the browser window was behind the editor and Chrome throttles timers in background tabs to once per second. Anything timing-related gets measured with the tab in front now.
Six numbers carry the whole effect
Everything visible about the effect lives in six numbers: dot opacity .14 at rest and .5 pressed, a 240px disc, a 150ms stillness threshold, .2s in and 1s out. The disc is two grid tiles wide, which is about as far as you'd look from a pencil tip. The rest ratio of 1s out to .2s in is what makes it read as settling rather than switching. I'm not sure the 1s is right yet. It might be long enough that the fade competes with the next movement, and I'd rather find that out by using the site for a week than by staring at the number.