Skip to content

Cursor Mask Reveal

The pointer cuts a soft hole through one layer to expose a different one beneath.

Freebrutalisteditorialplayful
Category
Patterns
Added
2026-09-20
Deps
none
Updated
2026-09-20
Preview
UnderneathFOUND IT
Move to revealLOOK CLOSER

Props

72
30
Show ring
No runtime dependencies
"use client";

import { useRef, useState, type ReactNode } from "react";

export function CursorMaskReveal({
  base,
  top,
  radius = 72,
  feather = 30,
  showRing = true,
}: {
  /** The layer revealed under the pointer. */
  base: ReactNode;
  /** The layer the pointer punches a hole through. */
  top: ReactNode;
  radius?: number;
  /** 0-100. How soft the edge of the hole is. */
  feather?: number;
  /** Outline the hole so its edge is legible over busy content. */
  showRing?: boolean;
}) {
  const boxRef = useRef<HTMLDivElement>(null);
  const [active, setActive] = useState(false);

  function handleMove(e: React.MouseEvent<HTMLDivElement>) {
    const el = boxRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    // Custom properties via a ref — the mask follows at pointer speed with no
    // React render per pixel.
    el.style.setProperty("--mx", e.clientX - rect.left + "px");
    el.style.setProperty("--my", e.clientY - rect.top + "px");
  }

  /*
   * A real CSS mask, not a radial gradient tinted over the content. That is
   * the difference between "a bright spot" and two genuinely different
   * treatments of the same area — the base layer can be any markup at all.
   * -webkit-mask-image is still required for Safari.
   */
  const maskImage =
    "radial-gradient(circle " +
    radius +
    "px at var(--mx) var(--my), #000 " +
    (100 - feather) +
    "%, transparent 100%)";

  return (
    <div
      ref={boxRef}
      onMouseMove={handleMove}
      onMouseEnter={() => setActive(true)}
      onMouseLeave={() => setActive(false)}
      className="relative overflow-hidden"
      style={{ "--mx": "50%", "--my": "50%" } as React.CSSProperties}
    >
      <div className="absolute inset-0">{base}</div>

      <div
        className="absolute inset-0"
        style={{
          maskImage: active ? maskImage : undefined,
          WebkitMaskImage: active ? maskImage : undefined,
        }}
      >
        {top}
      </div>

      {showRing && active && (
        <span
          aria-hidden
          className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/30"
          style={{ left: "var(--mx)", top: "var(--my)", width: radius * 2, height: radius * 2 }}
        />
      )}
    </div>
  );
}
Notes

About this pattern

A spotlight that merely brightens the area under the cursor is a radial gradient with some opacity, and it can only ever change the lighting. Masking the top layer instead removes it where the pointer is, so what shows through is arbitrary markup: a different palette, a different headline, a photograph under an illustration, an answer under a question. That is a categorically different tool, and it costs the same. Two practical notes for adapting it. Safari still requires the `-webkit-mask-image` prefix alongside the standard property, and omitting it is the usual reason this works everywhere except one browser. And pointer position belongs in CSS custom properties written from a ref — routing it through React state means a render for every pixel of pointer travel, which is precisely what makes hand-rolled versions of this feel sticky. The feather value controls where the gradient's opaque stop ends, so a low number gives a hard-edged porthole and a high one a soft vignette. Because the whole interaction is pointer-driven, anything essential must exist outside the hidden layer.

Curator’s note

Built for viberdy 2.0. A mask rather than a tinted gradient — that distinction is what lets the two layers say completely different things.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

Featured
NewPro

A list of text rows, each hiding a photograph that wipes open under the cursor and follows it.

galleryhoverlist
Featured

A card grid with a soft red spotlight that follows the cursor across it.

gridspotlightcursor

An image whose circular clip-path mask grows and shrinks in lockstep with scroll.

scrollimagemask