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

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--ink, --ink-deep, --pink, --paper, --mono. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.

Adapting this to React

Everything above describes a standalone module that already anticipates being rebuilt: mount(config) exists so this catalogue's own visual editor can call it again after a knob changes, and the destroy() it returns already undoes everything mount created — the mousemove/touchstart/touchmove listeners on .hero, the resize listener, the pending requestAnimationFrame handle, every timeline still live in timelines, and the <circle> nodes stamped into .smudge-blobs. That is exactly the contract a useEffect cleanup needs, so most of this adaptation is wiring rather than rewriting — the risk is in what gets substituted for that wiring, not in what the file is missing.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen, on the same DOM nodes. Routed straight through — call mount in the effect and return the destroy it hands back — that double-invoke is already handled: the first destroy() removes the listeners, cancels the pending frame, kills whatever timelines exist (none yet, this early) and empties .smudge-blobs, all before the second mount() call runs. The place this breaks is if the effect is written to match the rest of this catalogue's GSAP components instead — wrapping the call in a gsap.context and returning ctx.revert() as the cleanup. ctx.revert() only undoes GSAP objects the context tracked; it does not remove heroSection's three pointer listeners, the window resize listener, or cancel frame, because none of those are GSAP's. Do that, and the second mount() call attaches a second full set of listeners next to the first's and starts a second requestAnimationFrame loop next to the first's still-running one — two independent pointer/smoothPointer closures both driving stampSmudgeAt off the same mousemove events into the same .smudge-blobs, and neither loop is ever cancelled by this cleanup. Unlike most StrictMode-only symptoms in this catalogue, that is not a development-only blip: the same substitution breaks a real unmount-then-remount — leaving this route and coming back — exactly the same way, because nothing about ctx.revert() ever touches the listeners or the frame handle, in development or in production.

*(1) The entry point* — Delete the whole if (window.MP && window.MP.register) {...} else {...} block; window.MP is this catalogue's editor hook and has no counterpart in a deployed app. What's left is mount(Object.assign({}, DEFAULTS)) itself — call it directly inside a useEffect with an empty dependency array and return the destroy it hands back. The document.readyState/DOMContentLoaded guard wrapped around boot() is redundant once that call is inside useEffect, which never fires before the DOM it reads is committed.

*(2) Element lookups* — mount resolves .hero, .smudge-revealer and .smudge-blobs with three separate document.querySelector calls and bails to a no-op destroy if any come back empty. Put the root ref on .hero itself — it is both the outermost element and the node the pointer/touch listeners bind to, so heroSection becomes the ref's current value directly, no query needed for it. Rewrite the other two as root.querySelector(".smudge-revealer") and root.querySelector(".smudge-blobs") scoped under that ref, and keep the existing early-return guard — it now protects against an effect that fired before the ref attached, rather than against a missing element on the page.

*(3) Cleanup* —

GSAP — ctx.revert() cannot replace destroy(). If a context is introduced at all here, it only earns its keep for the per-stamp timelines, and even there it works against the grain of this file: stampSmudgeAt builds a fresh gsap.timeline() from inside update()'s requestAnimationFrame loop, called on nearly every frame the pointer moves — long after the effect's own setup call has already returned. Wrapping mount(...) in gsap.context(() => mount(Object.assign({}, DEFAULTS)), rootRef) captures nothing, because mount's synchronous body creates no GSAP object itself; every timeline is built later, outside the window a context auto-tracks. The only placement that would work is opening ctx.add(() => { /* build the timeline here */ }) inside stampSmudgeAt, once per stamp, so each one registers at the instant it's created. Given that cost, the timelines Set this file already keeps is doing the identical job for less ceremony — every stamp adds itself on creation, and destroy() already runs timelines.forEach(tl => tl.kill()) before clearing it — so the simplest correct port carries that Set over untouched and skips gsap.context for this component entirely. Whichever way you go, destroy() stays the effect's cleanup; a context's revert() is at most one line folded inside it, never a stand-in for it.

rAF loop — this is requestAnimationFrame, not gsap.ticker. update() re-schedules itself unconditionally into frame on every call, so the ticker-specific caveat elsewhere in this guide — gsap.ticker.add surviving a gsap.context revert — doesn't apply; there is no ticker subscription here to separately remove. What matters is exactly what destroy() already does: cancel frame before removing the listeners, so no already-queued frame can call stampSmudgeAt again after the listeners that would have fed it are gone.