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%):
.herobackground: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 light75%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
.heromousemove: readrect = 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
.heromouseleave: resetmouse.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:
const rect = hero.getBoundingClientRect().- 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.)
- Cap the buffer:
if (cursorTrail.length > BUFFER_SIZE) cursorTrail.shift()(drop the oldest). - For each layer:
const trailIndex = Math.max(0, cursorTrail.length - 1 - layer.delay)— sample a positiondelayframes in the past (clamped to the oldest entry while the buffer is still filling).const targetPos = cursorTrail[trailIndex].- Ease toward it:
layer.current.x += (targetPos.x - layer.current.x) * LERP(and.ylikewise). - 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 whoseviewBoxmatches 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 emitmousemovesimply 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: transformon every layer; transforms are written withgsap.set(no layout thrash). The oversized 250%/500% layers plusoverflow:hiddenon.heroare what let layers translate freely without exposing edges.