Introduction
In this article we build a Hover Reveal Effect — a full-viewport interactive lens that follows your cursor and cuts a soft circular hole through a foreground image to reveal a completely different image underneath. Moving the mouse feels like sweeping a torch across a painting to uncover a hidden scene beneath it. The entire effect is built on the Canvas 2D API using a single compositing technique: drawing the foreground onto an OffscreenCanvas, punching a feathered hole with destination-out, then stamping the result over the background. No SVG, no CSS filters, no third-party libraries.
The lens edge is not a hard circle cut. A two-pass radial gradient erase creates a soft feathered border — the inner radius is solid transparent, and an outer band fades from fully erased to fully opaque over 60px. This blurs the boundary so the transition from foreground to background feels like light diffusion rather than a cookie-cutter punch.
How globalCompositeOperation destination-out removes pixels from a canvas — how OffscreenCanvas scopes compositing so it does not bleed onto the main canvas — how a two-pass radial gradient (solid inner circle + gradient outer ring) produces a feathered lens edge — how cover-fit scaling works for images of any aspect ratio — why CSS mix-blend-mode fails for this use case and what the canvas approach solves.
How the Compositing Trick Works
Canvas compositing is controlled by globalCompositeOperation. The default value source-over paints new pixels on top of existing ones — the normal behaviour. The value destination-out does the opposite: wherever you draw, pixels in the destination are erased proportionally to the alpha of what you draw. Draw a solid black circle with destination-out and you get a perfectly transparent hole. Draw a radial gradient that fades to zero with destination-out and the hole softens at its edge.
destination-out does not paint — it erases. The shape you draw becomes a cookie-cutter, and the alpha of your fill controls how much gets cut away.
The critical detail is scoping. If you apply destination-out directly on the main canvas, it erases the background image too — which is not what we want. The fix is an OffscreenCanvas: draw the foreground onto it, punch the hole on it in isolation, then composite the already-masked result onto the main canvas with source-over. The background is untouched because the erasing happened on a separate surface.
Step 1 — A Single Canvas Element
The entire effect lives inside one <canvas> element. CSS sets it position: absolute; inset: 0 so it fills the viewport. The canvas pixel dimensions are set in JavaScript via canvas.width = window.innerWidth and canvas.height = window.innerHeight — never in CSS, because CSS scaling blurs the canvas. A resize event listener keeps them in sync. cursor: none on the .scene wrapper hides the default OS cursor so the torch dot we draw feels native to the effect.
<div class="scene">
<canvas id="c"></canvas>
<p class="hint" id="hint">move to reveal</p>
</div>
<style>
html, body { width: 100%; height: 100%; overflow: hidden; }
.scene {
position: relative;
width: 100%; height: 100%;
cursor: none; /* hide OS cursor */
}
canvas {
position: absolute;
inset: 0;
display: block; /* removes inline baseline gap */
}
</style>Step 2 — Loading Images and Cover-Fit Scaling
Both images are loaded with a small loadImg helper that returns a Promise resolving to an HTMLImageElement. Setting img.crossOrigin = "anonymous" before assigning src is required for remote URLs — without it the canvas is tainted and getImageData / drawImage will throw a security error. For local files served from the same origin, the attribute is harmless.
function loadImg(src) {
return new Promise((res, rej) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => res(img);
img.onerror = () => rej(new Error(`Could not load: ${src}`));
img.src = src;
});
}The drawCover helper replicates CSS background-size: cover for canvas. It computes a uniform scale factor as Math.max(canvasW / imgW, canvasH / imgH) — taking the larger ratio ensures the image fills the canvas in both dimensions. The image is then centred by offsetting by half the overflow in each axis.
function drawCover(context, img, w, h) {
const scale = Math.max(w / img.width, h / img.height);
const sw = img.width * scale;
const sh = img.height * scale;
context.drawImage(img, (w - sw) / 2, (h - sh) / 2, sw, sh);
}Step 3 — The OffscreenCanvas Isolation Pattern
Every frame, a fresh OffscreenCanvas is created at the same pixel dimensions as the main canvas. The foreground image is drawn onto it first. Then globalCompositeOperation is switched to destination-out and the lens circle is erased at the current cursor position. Finally the composited offscreen result is drawn onto the main canvas — which already has the background image on it — using source-over. Because the hole was punched in isolation on the offscreen surface, only the foreground pixels are removed; the background underneath shows through cleanly.
// 1 — Draw background onto main canvas
ctx.globalCompositeOperation = 'source-over';
drawCover(ctx, imgBg, W, H);
// 2 — Build masked foreground on a fresh OffscreenCanvas
const off = new OffscreenCanvas(W, H);
const oc = off.getContext('2d');
drawCover(oc, imgFg, W, H); // full foreground
oc.globalCompositeOperation = 'destination-out'; // switch to erase mode
// ... punch the hole here (see Step 4)
// 3 — Stamp the masked foreground over the background
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(off, 0, 0);Step 4 — Two-Pass Feathered Lens
A single radial gradient from transparent to opaque does not produce a crisp enough centre — the middle of the lens looks slightly faded. The fix is a two-pass approach. Pass one fills a solid opaque circle of radius RADIUS at the cursor position using destination-out. This guarantees the inner area is fully erased. Pass two draws a second, larger circle of radius RADIUS + BLUR_BAND filled with a radial gradient that runs from fully opaque at RADIUS to fully transparent at RADIUS + BLUR_BAND. This second pass softens only the outer ring, leaving the centre sharp.
const RADIUS = 130; // hard inner radius
const BLUR_BAND = 60; // feather width in px
// Pass 1 — solid erase for the inner circle
oc.fillStyle = 'rgba(0,0,0,1)';
oc.beginPath();
oc.arc(mx, my, RADIUS, 0, Math.PI * 2);
oc.fill();
// Pass 2 — gradient erase for the feathered outer ring
const grad = oc.createRadialGradient(mx, my, RADIUS, mx, my, RADIUS + BLUR_BAND);
grad.addColorStop(0, 'rgba(0,0,0,1)'); // fully erased at inner edge
grad.addColorStop(1, 'rgba(0,0,0,0)'); // untouched at outer edge
oc.fillStyle = grad;
oc.beginPath();
oc.arc(mx, my, RADIUS + BLUR_BAND, 0, Math.PI * 2);
oc.fill();A single gradient from the centre would fade even the middle of the lens, making the revealed image look dim or blurry at the hotspot. The two-pass approach keeps the centre at full 100% reveal — only the border softens. The BLUR_BAND constant is the only number you need to adjust to make the edge crisper or softer.
Step 5 — The Animation Loop
The draw function is called inside a requestAnimationFrame loop. Every frame it clears the canvas, redraws the background, builds a fresh OffscreenCanvas with the masked foreground, composites it, and adds the vignette. The OffscreenCanvas is recreated each frame rather than cached — this is intentional: it avoids state bleeding between frames when the cursor moves, and the cost of allocating a small canvas each frame is negligible on modern hardware compared to a GPU draw call.
function draw() {
ctx.clearRect(0, 0, W, H);
// background
ctx.globalCompositeOperation = 'source-over';
drawCover(ctx, imgBg, W, H);
// masked foreground
const off = new OffscreenCanvas(W, H);
const oc = off.getContext('2d');
drawCover(oc, imgFg, W, H);
oc.globalCompositeOperation = 'destination-out';
// ... two-pass erase at (mx, my)
ctx.drawImage(off, 0, 0);
// vignette
const vig = ctx.createRadialGradient(W/2, H/2, H * 0.3, W/2, H/2, H * 0.85);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(0,0,0,0.5)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, W, H);
}
function loop() { draw(); requestAnimationFrame(loop); }
Promise.all([loadImg(BG_IMAGE), loadImg(FG_IMAGE)])
.then(([bg, fg]) => { imgBg = bg; imgFg = fg; resize(); loop(); });Step 6 — Cursor Tracking
Two variables mx and my are initialised to -9999 so the lens starts off-screen and the foreground image renders fully intact on load. A mousemove listener on document updates them each frame. A mouseleave listener resets them to -9999 so the lens disappears when the cursor exits the window — without this, the last cursor position would remain punched through the foreground forever.
let mx = -9999, my = -9999;
document.addEventListener('mousemove', e => {
mx = e.clientX;
my = e.clientY;
});
document.addEventListener('mouseleave', () => {
mx = -9999;
my = -9999;
});Step 7 — Vignette
After compositing the masked foreground, a final radial gradient fills the entire canvas as a vignette. It is transparent in the centre and fades to 50% black at the edges. The vignette is drawn with source-over on the main canvas — it sits above both images and draws attention to the centre of the frame. It also visually anchors the composition so the two images do not feel like they are floating on a void when the images have similar edge colours.
Why Not CSS mix-blend-mode?
The obvious CSS approach is to stack the foreground over the background and use a circular element with mix-blend-mode: destination-out to punch the hole. This fails because mix-blend-mode blends relative to a stacking context — the foreground and the punching element must share the same stacking context, and isolation: isolate is needed on a wrapper to prevent the blend from leaking onto the background. In practice, the background bleeds through in every browser because the isolation boundary is shared between the foreground and the cursor element, not between the two images. The OffscreenCanvas approach sidesteps all of this — compositing happens in JavaScript where you have full, explicit control over which surface is affected.
CSS compositing scopes to stacking contexts you do not fully control. Canvas compositing scopes to exactly the surface you draw on — nothing more.
Tuning Reference
| Constant / Property | Default | Effect |
|---|---|---|
| RADIUS | 130px | Hard inner radius of the lens — increase for a wider reveal window |
| BLUR_BAND | 60px | Width of the feathered soft edge — increase for a dreamier border, decrease for a crisper cut |
| BG_IMAGE | URL | The image revealed inside the lens — shown only where the foreground is erased |
| FG_IMAGE | URL | The image covering the full canvas — erased at the cursor position |
| Vignette inner r | H * 0.3 | Controls how far from centre the vignette begins fading in |
| Vignette outer r | H * 0.85 | Controls how far the vignette reaches — increase past 1.0 to lighten corners |
| Vignette alpha | 0.5 | Maximum darkness of the vignette at canvas edges |
| mx / my init | -9999 | Off-screen default keeps the lens hidden until the cursor enters the viewport |
Full Source Code
Save the following as hover-reveal.html. Replace BG_IMAGE and FG_IMAGE with your own URLs or relative paths served from a local server. Open in any modern browser — no build step required.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/hover-reveal.htmlOne canvas, one OffscreenCanvas, two passes — and a lens that feels like light.