All components

Cursor Smudge Revealer

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Full-screen hero where moving the cursor (or finger) smudges away the still-wet ink foreground: circles sized by pointer speed are stamped at a lerped pointer position inside a gooey SVG mask, each expanding 2x over 2s then dissolving over 3s, revealing the second-colour layer beneath the title.

How it's built

Categoryinteractive
Techgsap
Complexitysection
Performance costlight
Mobile-safeyes

cursor mouse svg mask goo reveal hero touch

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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-foreground is the dark layer on top with the big <h1>.
  • .hero-content-background is 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 carries filter="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 to sans-serif), and do not set font-weight (so it stays the default normal / 400). If you prefer to be explicit, write font-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; text color: #edf2ed;
  • Background (light) layer: background-color: #cbd4c2; (pale sage) text color: #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) and smoothPointer {x,y} (lerped). Flag hasStarted = false.
  • onPointerMove(x, y): on the first call, seed pointer.x = smoothPointer.x = x and same for y, set hasStarted = true, and return without stamping (prevents a giant initial stamp from the jump-to-first-position). On subsequent calls just set pointer.x = x; pointer.y = y.
  • Listeners on .hero:
  • mousemoveonPointerMove(e.pageX, e.pageY).
  • touchstart and touchmovee.preventDefault() then onPointerMove(e.touches[0].pageX, e.touches[0].pageY); both registered with { passive: false }.
  • SVG sizing: matchSVGToViewport() sets smudgeSVG.style.width = innerWidth + "px" and height = innerHeight + "px". Call once on load and on window resize.
rAF loop — update() (runs continuously for the page lifetime)

Each frame, if hasStarted:

  1. 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; ``

  1. 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.)

  1. 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
  1. Create an SVG <circle> via document.createElementNS("http://www.w3.org/2000/svg","circle") with cx=x, cy=y, r=radius, fill="#fff". prepend it into .smudge-blobs (prepend, not append).
  2. Hold an animatable proxy: const animatedRadius = { current: radius };
  3. Build a gsap.timeline with:
  4. onUpdate() → write the current radius back to the SVG: circle.setAttribute("r", Math.max(0, animatedRadius.current));
  5. onComplete()timeline.kill(); circle.remove(); (cleanup so the DOM/mask doesn't accumulate dead nodes).
  6. Timeline tweens (two, with an absolute position param on the second):
  7. Expand: tl.to(animatedRadius, { current: radius * config.expandMultiplier, duration: config.expandTime, ease: config.expandEase }); → grows radius r → 2r over 2s, power1.inOut.
  8. Dissolve: tl.to(animatedRadius, { current: 0, duration: config.dissolveTime, ease: config.dissolveEase }, config.dissolveStart); → shrinks radius → 0 over 3s, power3.in, placed at absolute time 2 (the dissolveStart position 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 — mousemove on desktop, touchstart/touchmove on 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: preventDefault on 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 to innerWidth × innerHeight on load and resize so mask coordinates stay aligned with pageX/pageY.
  • The mask relies on url(#smudge-mask) referencing the inline SVG in the same document; keep the SVG in the DOM (it is pointer-events: none and visually empty).