All components

Moblinks Interactive Logo

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

Open live demo ↗ Raw prompt (.md)

What it does

Depth-tunnel hero around a 3D logo: six stacked layers, five of them with SVG mask cutouts at decreasing sizes (mask-composite: subtract), trail the cursor on mousemove. A gsap.ticker buffers the cursor path and each layer reads a progressively older position (8-frame stagger) smoothed with lerp, creating a cascading parallax tunnel with per-layer drop shadows.

How it's built

Categoryinteractive
Techgsap
Complexitysection
Performance costlight
Mobile-safedesktop-first

mouse parallax depth mask logo hero cursor-trail lerp

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

Interactive Logo Depth Tunnel

Goal

Build a full-viewport hero that frames a centered monogram logo inside a tunnel of concentric, logo-shaped holes receding into depth. Five solid, purple, full-bleed layers each have the same glyph silhouette punched out of them at progressively smaller sizes (via mask-composite: subtract), stacked with a sixth layer that holds the real logo image at the very center. As the pointer moves over the hero, every layer trails the cursor — but each one reads a progressively *older* cursor position from a frame-buffer (an 8-frame stagger per layer) and eases toward it with a slow lerp. The result is a soft, cascading parallax "wormhole": the inner logo leads, the outer rings lag behind like a comet tail, and the whole tunnel swings toward wherever the mouse is. No scroll, no click — pure mousemove + a per-frame ticker.

Tech

Vanilla HTML/CSS/JS with an ES-module entry (<script type="module">). Only dependency is gsap (npm) — no plugins, no ScrollTrigger, no Lenis, no Three.js. The motion uses gsap.ticker.add(...) for the per-frame loop, gsap.utils.toArray(...) to collect the layers, and gsap.set(...) to write transforms. Runs in a fresh Vite project with a single import: import gsap from "gsap".

Layout / HTML

One hero section containing six sibling .depth-layer divs. The first five each wrap a .depth-mask; the sixth wraps a .logo with the <img>:

<section class="hero">
  <div class="depth-layer"><div class="depth-mask"></div></div>
  <div class="depth-layer"><div class="depth-mask"></div></div>
  <div class="depth-layer"><div class="depth-mask"></div></div>
  <div class="depth-layer"><div class="depth-mask"></div></div>
  <div class="depth-layer"><div class="depth-mask"></div></div>
  <div class="depth-layer">
    <div class="logo"><img src="/path/to/logo.png" alt="" /></div>
  </div>
</section>
<script type="module" src="./script.js"></script>

DOM order matters: the JS collects .depth-layer in document order and derives each layer's lag from its index, so the logo layer must be last (index 5). Key classes the CSS/JS depend on: .hero (event target + measurement box), .depth-layer (the translated elements), .depth-mask (the punched shape), .logo/.logo img (centered mark).

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box; }.

Palette — a single monochrome purple family, all hsl(270, 56%, L%):

  • .hero background: hsl(270, 56%, 65%).
  • Mask fill colors (the solid rectangle each hole is cut from), per layer 1→5: 50%, 55%, 60%, 52.5%, 55%.
  • Per-layer drop-shadow color (filter: drop-shadow(0 0 1rem hsl(270,56% L%))), layers 1→5: 25%, 30%, 35%, 27.5%, 75%. (Layer 5's very light 75% shadow reads as a bright rim/glow around the innermost ring.)

.hero: position:relative; width:100%; height:100svh; overflow:hidden (overflow hidden clips the oversized layers as they translate).

.depth-layer: position:absolute; width:250%; height:250%; top:-75%; left:-75%; will-change:transform. Each layer is 2.5× the viewport and offset up-left by 75%, i.e. centered but massively oversized so it can be translated in any direction without ever exposing an edge inside the hero.

.depth-mask: width:100%; height:100%. Its fill is a solid background: hsl(270,56%,L%) (per-layer above). The cutout is done with a dual mask + subtract:

-webkit-mask:
  linear-gradient(#fff, #fff),
  url("/path/to/mask.svg") center / var(--size) no-repeat;
-webkit-mask-composite: subtract;
mask:
  linear-gradient(#fff, #fff),
  url("/path/to/mask.svg") center / var(--size) no-repeat;
mask-composite: subtract;

The first mask layer (linear-gradient(#fff,#fff)) shows the entire rectangle; the second (the SVG glyph silhouette, centered, sized to var(--size)) is subtracted, punching a logo-shaped transparent hole through the solid color. Larger --size = larger hole.

Per-layer --size and z-index (this is what builds the tunnel — bigger holes on top, smaller holes behind, so you look *through* nested cutouts down to the logo):

| layer (nth-child) | z-index | mask fill L | --size (hole) | drop-shadow L | |---|---|---|---|---| | 1 | 7 | 50% | 90% | 25% | | 2 | 6 | 55% | 67.5% | 30% | | 3 | 5 | 60% | 45% | 35% | | 4 | 4 | 52.5% | 27.5% | 27.5%| | 5 | 3 | 55% | 15% | 75% | | 6 (logo) | 2 | — | — | — |

So the front-most layer (z 7) has the biggest hole (90%) and the innermost visible ring (z 3) has the smallest (15%), with the logo image sitting behind everything at z 2, framed dead-center in the shrinking tunnel of holes.

.logo: width:100%; height:100%; display:flex; justify-content:center; align-items:center. .logo img: width:200px; height:auto; object-fit:contain.

Responsive @media (max-width:1000px): .depth-layer grows to width:500%; height:500%; top:-200%; left:-200% — even more oversized on small screens (layers translate a larger fraction of the viewport there, so they need more bleed to never reveal an edge).

The effect (be exhaustive — this is the whole component)

There is no timeline and no tween. Motion is a hand-rolled frame-buffer + lerp running inside gsap.ticker. Reproduce every constant.

Tunable constants (exact)
SENSITIVITY   = 0.3    // max pointer displacement as a fraction of hero size
LERP          = 0.04   // easing factor toward the sampled target, per frame
STAGGER_DELAY = 8      // frames of lag added per layer step

Derived: totalDepthLayers = 6; BUFFER_SIZE = totalDepthLayers * STAGGER_DELAY + 1 = 49.

1. Pointer tracking (normalized, centered on the hero)

Keep const mouse = { x: 0, y: 0 }.

  • On .hero mousemove: read rect = hero.getBoundingClientRect(), then

mouse.x = ((e.clientX - rect.left) / rect.width - 0.5) * 2, mouse.y = ((e.clientY - rect.top) / rect.height - 0.5) * 2. Range −1..1, with 0,0 at the hero center.

  • On .hero mouseleave: reset mouse.x = 0; mouse.y = 0 (the tunnel eases back to rest/center).
2. Per-layer state

const depthLayers = gsap.utils.toArray(".depth-layer") (document order). Map to state objects:

layers[i] = {
  el: node,
  delay: (totalDepthLayers - 1 - i) * STAGGER_DELAY,  // i0→40, i1→32, i2→24, i3→16, i4→8, i5→0
  current: { x: 0, y: 0 },
}

Note the delay is inverted vs. DOM index: the logo layer (index 5) has delay 0 (leads, most responsive), and the front-most big-hole layer (index 0) has delay 40 (lags most). Also keep const cursorTrail = [] — a rolling buffer of recent target positions.

3. The ticker loop (gsap.ticker.add(() => { ... })) — the star

Every frame:

  1. const rect = hero.getBoundingClientRect().
  2. Push the current target onto the trail:

cursorTrail.push({ x: mouse.x * rect.width * SENSITIVITY, y: mouse.y * rect.height * SENSITIVITY }). (At the extreme corner the target is ±30% of the hero's width/height.)

  1. Cap the buffer: if (cursorTrail.length > BUFFER_SIZE) cursorTrail.shift() (drop the oldest).
  2. For each layer:
  3. const trailIndex = Math.max(0, cursorTrail.length - 1 - layer.delay) — sample a position delay frames in the past (clamped to the oldest entry while the buffer is still filling).
  4. const targetPos = cursorTrail[trailIndex].
  5. Ease toward it: layer.current.x += (targetPos.x - layer.current.x) * LERP (and .y likewise).
  6. Write it: gsap.set(layer.el, { x: layer.current.x, y: layer.current.y }).
Why it looks the way it does
  • Two-stage lag. Each layer first samples an *older* cursor target (the 8-frame-per-step stagger), then *eases* toward that stale target at only 4%/frame. Both effects compound: inner rings snap toward the cursor almost immediately while outer rings drift in behind them, producing a fluid caterpillar/comet-tail cascade rather than a rigid parallax.
  • Direction. Positive mouse.x (cursor right of center) → positive target x → layers translate right, toward the cursor; same for y. The whole tunnel leans toward the pointer, and because the holes are concentric you get a convincing "looking down a shaft" perspective shift.
  • Rest state. With the pointer outside the hero, targets are 0,0; every layer lerps back to center and the tunnel settles perfectly aligned.
  • Softness. Nothing is ever tweened to completion — the lerp means the layers are *always chasing*, so motion feels weighty and floaty, and stops the instant the target stabilizes.

Assets / images

  • 1 logo image (logo.png): a bold, high-contrast monogram / short wordmark (e.g. two uppercase letters) in solid white on a fully transparent background, on a square ~1:1 canvas (~2800×2800). Displayed small (width:200px) dead-center on the innermost layer. Any simple, chunky mark works — no brand logos; use a neutral placeholder monogram.
  • 1 SVG mask (mask.svg): an SVG whose viewBox matches the logo canvas (0 0 2800 2800) containing a single solid-fill (#000) silhouette path of the same glyph. This is the shape subtracted from all five layers to cut the tunnel holes, so it must match the logo's dominant silhouette — if you swap the logo, regenerate this mask to the same shape. A bold, closed letterform path (thick strokes, no thin details, so the cutout reads clearly at every size from 90% down to 15%) works best.

Behavior notes

  • Desktop / pointer only. The effect is driven entirely by mousemove; touch devices that don't emit mousemove simply show the static, centered tunnel — that's acceptable, there's no touch fallback in the original.
  • Infinite, no trigger. The ticker runs continuously from load; there is no start/stop, no ScrollTrigger, and no reduced-motion guard in the original. The tunnel is idle (centered) until the pointer enters the hero.
  • Perf. will-change: transform on every layer; transforms are written with gsap.set (no layout thrash). The oversized 250%/500% layers plus overflow:hidden on .hero are what let layers translate freely without exposing edges.