Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Depth parallax card" for my site using the Enrichr Library element at https://enrichr.dev/elements/depth-parallax-card.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/depth-parallax-card.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- label: Depth
- strength: 40
- smoothing: 0.08
- colorA: #2f7d5b
- colorB: #c6f26b
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/depth-parallax-card.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-depth" role="img" aria-label="Layered shapes that move with the pointer">
<div class="ef-depth__l" data-d="0.25"></div>
<div class="ef-depth__l" data-d="0.6"></div>
<div class="ef-depth__l" data-d="1"></div>
<div class="ef-depth__label"></div>
</div>CSS
.ef-depth { position: relative; overflow: hidden; min-height: 18rem; border-radius: 1rem; background: radial-gradient(circle at 50% 120%, var(--ef-a, #2f7d5b), #0b1512 70%); touch-action: pan-y; }
.ef-depth__l { position: absolute; inset: 0; will-change: transform; }
.ef-depth__l::before { content: ""; position: absolute; border-radius: 50%; background: var(--ef-a, #2f7d5b); }
.ef-depth__l:nth-child(1)::before { width: 70%; aspect-ratio: 1; left: -10%; top: 25%; opacity: 0.35; filter: blur(2px); }
.ef-depth__l:nth-child(2)::before { width: 38%; aspect-ratio: 1; right: 8%; top: 10%; opacity: 0.6; }
.ef-depth__l:nth-child(3)::before { width: 16%; aspect-ratio: 1; left: 18%; bottom: 14%; background: var(--ef-b, #c6f26b); }
.ef-depth__label { position: absolute; left: 1.2rem; bottom: 1rem; font-size: 1.1rem; font-weight: 700; color: #fff; letter-spacing: -0.01em; }
@media (prefers-reduced-motion: reduce) { .ef-depth__l { transform: none !important; } }JavaScript
// Depth parallax card: layers shift by different amounts as the pointer moves, which reads as depth.
// On touch screens with no pointer, the layers drift slowly instead.
// mount(root, { strength (px), smoothing (0.02-0.3), colorA, colorB, label })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var card = root.querySelector(".ef-depth");
var layers = Array.from(card.querySelectorAll(".ef-depth__l"));
var label = card.querySelector(".ef-depth__label");
var opts = {}, raf = 0, tx = 0, ty = 0, cx = 0, cy = 0, hovering = false, visible = true, io = null, t0 = performance.now();
function frame(now) {
raf = requestAnimationFrame(frame);
if (!visible) return;
if (!hovering) {
var s = (now - t0) / 1000; // idle drift
tx = Math.sin(s * 0.6) * 0.45;
ty = Math.cos(s * 0.45) * 0.35;
}
cx += (tx - cx) * opts.smoothing;
cy += (ty - cy) * opts.smoothing;
layers.forEach(function (l) {
var d = parseFloat(l.getAttribute("data-d"));
l.style.transform = "translate3d(" + (cx * opts.strength * d).toFixed(2) + "px," + (cy * opts.strength * d).toFixed(2) + "px,0)";
});
}
function move(e) {
var r = card.getBoundingClientRect();
tx = ((e.clientX - r.left) / r.width - 0.5) * 2;
ty = ((e.clientY - r.top) / r.height - 0.5) * 2;
hovering = true;
}
function leave() { hovering = false; }
function update(next) {
opts = Object.assign({ strength: 40, smoothing: 0.08, colorA: "#2f7d5b", colorB: "#c6f26b", label: "Depth" }, opts, next || {});
card.style.setProperty("--ef-a", opts.colorA);
card.style.setProperty("--ef-b", opts.colorB);
label.textContent = opts.label;
}
update(o);
if (!reduce) {
card.addEventListener("pointermove", move);
card.addEventListener("pointerleave", leave);
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(card); }
raf = requestAnimationFrame(frame);
}
return {
update: update,
destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); card.removeEventListener("pointermove", move); card.removeEventListener("pointerleave", leave); }
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"label":"Depth","strength":40,"smoothing":0.08,"colorA":"#2f7d5b","colorB":"#c6f26b"});How it works
The pointer position inside the card becomes a value from -1 to 1 on each axis. Each layer moves by that value times the strength times its own depth factor (0.25, 0.6, 1), and the current position eases toward the target every frame. Without a pointer the target follows a slow sine path, so touch devices still see depth.
Accessibility and performance
Decorative: the card is a labelled image. With reduced motion nothing moves. No interaction depends on the movement.
Three translate3d updates per frame, paused when the card is off screen. No images, so nothing to download.
| Section | Hero |
|---|---|
| Techniques | Pointer interaction, Parallax and depth |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Can I use real images as layers?
Yes. Replace the layer backgrounds with images of different depth cutouts, and keep the data-d values as the depth factors.
Does it use device tilt on phones?
Not by default, because it needs a permission prompt on iOS. The idle drift gives touch screens a similar feel without it.
What does smoothing do?
It is the share of the remaining distance covered each frame. Lower values feel heavier and lag behind the pointer more.