Cursor Smudge Revealer — Gooey SVG-Mask Hero
Goal
Build a full-screen hero made of two stacked full-viewport layers: a dark foreground carrying a giant title, and a light background carrying a hidden message. Moving the cursor (or dragging a finger) smudges the dark foreground away, revealing the light layer beneath through the cursor trail. The star effect: at a lerp-smoothed pointer position, white circles are continuously stamped into an SVG mask whose contents run through a gooey (metaball) SVG filter, so overlapping stamps fuse into organic blobs. Each stamp is sized by pointer speed, expands 2× over 2s with GSAP, then dissolves back to 0 over 3s and is removed — producing a soft, wet "wipe-away" reveal that heals itself when you stop moving.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no ScrollTrigger, SplitText, Lenis, or any other plugin. No smooth scroll. Import as:
import gsap from "gsap";
No registerPlugin needed. All motion is pointer-driven (mousemove + touch), animated per-stamp via gsap.timeline, and pumped by a manual requestAnimationFrame loop. Runs in a fresh Vite project with just gsap installed.
Layout / HTML
A single <section class="hero"> containing two absolutely-stacked content layers and one inline <svg> that holds the mask + filter definitions:
<section class="hero">
<div class="hero-content-foreground">
<h1>Dig in</h1>
</div>
<div class="hero-content-background">
<h3>
The things worth finding are never on the surface. They live in the
parts you almost scrolled past.
</h3>
</div>
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" class="smudge-revealer">
<defs>
<filter id="smudge-goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="25" />
<feColorMatrix type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 60 -14" />
</filter>
</defs>
<mask id="smudge-mask">
<g class="smudge-blobs" filter="url(#smudge-goo)"></g>
</mask>
</svg>
<script type="module" src="./script.js"></script>
</section>
Key structural facts (load-bearing):
.hero-content-foregroundis the dark layer on top with the big<h1>..hero-content-backgroundis the light layer underneath with the<h3>message, and it is the element the mask is applied to (mask: url(#smudge-mask)).- The
<svg>is empty of visible geometry: it only defines the goo<filter>and the<mask>. The<g class="smudge-blobs">is where JS injects<circle>elements at runtime; that group carriesfilter="url(#smudge-goo)". - Use neutral demo copy. Title: "Dig in". Message: a short two-sentence line about things worth finding living below the surface. No brands.
Styling
Fonts (read carefully — this is the one thing reproductions get wrong): the original CSS declares font-family: "Hanson", sans-serif; for both h1 and h3 but never actually loads a Hanson font — there is no @font-face, no web-font import, no <link>. So in a real browser the declared family fails and the headings fall back to the generic sans-serif default (a neutral, medium-weight face like Helvetica/Arial), rendered at normal weight (400). That neutral, normal-weight look is exactly what the visible reference shows, and it must be reproduced.
- Do NOT substitute a heavy/condensed display face (Anton, Archivo Black, Oswald, etc.). Those are much bolder, narrower, and taller and will make the giant near-viewport-filling
<h1>clearly wrong. - Do NOT add an external font via Google Fonts
@import/<link>or a bundled@font-face. It changes the look and adds an unnecessary network dependency to an otherwise fully self-contained, image-free component. - Reproduce the CSS as the original has it: keep
font-family: "Hanson", sans-serif;(leaving the unresolved "Hanson" first is fine and faithful — it just falls through tosans-serif), and do not setfont-weight(so it stays the defaultnormal/400). If you prefer to be explicit, writefont-family: sans-serif; font-weight: 400;— same visible result: a plain system sans at normal weight. - Both headings:
text-transform: uppercase; line-height: 0.9;(these stay).
Reset: * { margin:0; padding:0; box-sizing:border-box; }.
Type sizes:
h1:font-size: clamp(5rem, 22.5vw, 30rem);— enormous, near-viewport-filling.h3:font-size: clamp(3rem, 5vw, 6rem);
Color tokens (exact hex):
- Foreground (dark) layer:
background-color: #2a2b2a;textcolor: #edf2ed; - Background (light) layer:
background-color: #cbd4c2;(pale sage) textcolor: #323332;
Layout / positioning:
.hero:position: relative; width: 100%; height: 100svh; overflow: hidden;.hero-content-background, .hero-content-foreground:position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 2rem; text-align: center; user-select: none;(both fully overlap the hero)..hero-content-foreground:display: flex; justify-content: center; align-items: flex-end;→ the title sits bottom-centered..hero-content-background:display: flex; justify-content: center; align-items: center;→ the message sits dead-center. Apply the mask on this element:
``css mask: url(#smudge-mask); -webkit-mask: url(#smudge-mask); ``
.smudge-revealer(the svg):position: absolute; top: 0; left: 0; pointer-events: none;(JS sets its pixel width/height to match the viewport).
Why it works: an SVG <mask> shows the masked element only where the mask content is white and hides it where black/empty. The mask starts empty, so .hero-content-background is fully hidden and only the dark foreground + title are visible. Each white circle stamped into .smudge-blobs punches a visible hole revealing the light layer at that spot. The goo filter thresholds the blurred circles' alpha so nearby stamps merge into smooth blobs instead of separate discs.
The goo filter (metaball threshold)
#smudge-goo = feGaussianBlur stdDeviation="25" (heavy blur softens each circle) → feColorMatrix whose alpha row is 0 0 0 60 -14 (multiply alpha ×60, subtract 14). This steepens the blurred alpha gradient into a near-binary edge: overlapping blurred circles that individually sit below threshold sum above it in their overlap, fusing into one gooey shape. RGB rows are identity. Keep these exact values.
GSAP effect (exhaustive)
Config (name these constants)
const config = {
smoothing: 0.1, // lerp factor for pointer smoothing per frame
movementThreshold: 0.01, // min speed (px) to stamp a smudge
sizeFromSpeed: 0.2, // stamp radius = pointer speed * this
expandMultiplier: 2, // radius grows to 2× on expand
expandTime: 2, // expand duration (s)
expandEase: "power1.inOut",
dissolveStart: 2, // dissolve begins at t = 2s (absolute position)
dissolveTime: 3, // dissolve duration (s)
dissolveEase: "power3.in",
};
Pointer state & capture
- Two objects:
pointer {x,y}(raw target) andsmoothPointer {x,y}(lerped). FlaghasStarted = false. onPointerMove(x, y): on the first call, seedpointer.x = smoothPointer.x = xand same for y, sethasStarted = true, and return without stamping (prevents a giant initial stamp from the jump-to-first-position). On subsequent calls just setpointer.x = x; pointer.y = y.- Listeners on
.hero: mousemove→onPointerMove(e.pageX, e.pageY).touchstartandtouchmove→e.preventDefault()thenonPointerMove(e.touches[0].pageX, e.touches[0].pageY); both registered with{ passive: false }.- SVG sizing:
matchSVGToViewport()setssmudgeSVG.style.width = innerWidth + "px"and height= innerHeight + "px". Call once on load and onwindowresize.
rAF loop — update() (runs continuously for the page lifetime)
Each frame, if hasStarted:
- Lerp the smoothed pointer toward the raw pointer:
``js smoothPointer.x += (pointer.x - smoothPointer.x) * config.smoothing; // 0.1 smoothPointer.y += (pointer.y - smoothPointer.y) * config.smoothing; ``
- Compute speed as the distance between raw and smoothed pointer:
``js const speed = Math.hypot(pointer.x - smoothPointer.x, pointer.y - smoothPointer.y); `` (Faster cursor movement ⇒ larger raw/smoothed gap ⇒ bigger speed ⇒ bigger stamps. When still, speed decays to ~0 and stamping stops.)
- If
speed > config.movementThreshold(0.01), stamp:
``js stampSmudgeAt(smoothPointer.x, smoothPointer.y, speed * config.sizeFromSpeed); // radius ` Then requestAnimationFrame(update). Kick off with one requestAnimationFrame(update)` at startup.
stampSmudgeAt(x, y, radius) — the per-stamp GSAP timeline
- Create an SVG
<circle>viadocument.createElementNS("http://www.w3.org/2000/svg","circle")withcx=x,cy=y,r=radius,fill="#fff".prependit into.smudge-blobs(prepend, not append). - Hold an animatable proxy:
const animatedRadius = { current: radius }; - Build a
gsap.timelinewith: onUpdate()→ write the current radius back to the SVG:circle.setAttribute("r", Math.max(0, animatedRadius.current));onComplete()→timeline.kill(); circle.remove();(cleanup so the DOM/mask doesn't accumulate dead nodes).- Timeline tweens (two, with an absolute position param on the second):
- Expand:
tl.to(animatedRadius, { current: radius * config.expandMultiplier, duration: config.expandTime, ease: config.expandEase });→ grows radiusr → 2rover 2s,power1.inOut. - Dissolve:
tl.to(animatedRadius, { current: 0, duration: config.dissolveTime, ease: config.dissolveEase }, config.dissolveStart);→ shrinks radius→ 0over 3s,power3.in, placed at absolute time2(thedissolveStartposition param). So dissolve begins exactly when expand ends; total stamp lifetime ≈ 5s (2s grow, then 3s shrink-to-nothing), after which the circle is removed.
Net motion: a fast flick lays down a dense trail of large blobs that swell then melt away over ~5s, so the reveal is transient and "re-covers" itself; a slow drag lays smaller blobs. Because every stamp lives in the same goo-filtered group, concurrent stamps merge into continuous smudges rather than a string of separate dots.
Assets / images
None. This component uses no image files — it is purely typographic (two big headings) plus SVG mask/filter geometry generated at runtime. Do not add images.
Behavior notes
- Trigger: pointer only —
mousemoveon desktop,touchstart/touchmoveon touch. No scroll, click, load, or hover-state animation. Idle (no pointer movement) ⇒ speed decays and existing stamps finish dissolving, so the hero settles back to the fully-covered dark title. - Static / fresh-load appearance: because the reveal only happens *while the pointer is actively moving*, a fresh page load with no pointer motion correctly shows only the dark foreground layer with the giant title fully covering the light message beneath — the mask starts empty. A static screenshot of an untouched load is expected to show zero smudge (that is not a failure); the light layer and its message only peek through along the moving cursor trail.
- Touch:
preventDefaulton touch events (with{ passive: false }) so dragging smudges instead of scrolling. - First-move guard: the very first pointer event only seeds positions and does not stamp.
- Cleanup: every stamp's timeline
kill()s itself and removes its<circle>on complete — essential to keep the mask group from growing unbounded during continuous movement. - Viewport: hero is
100svh; the SVG is resized in pixels toinnerWidth × innerHeighton load and resize so mask coordinates stay aligned withpageX/pageY. - The mask relies on
url(#smudge-mask)referencing the inline SVG in the same document; keep the SVG in the DOM (it ispointer-events: noneand visually empty).