Cursor-driven miniature scene parallax
A glass terrarium with a tiny scene inside. The cursor shifts the layers by depth — back rocks one way, front moss the other, the glass highlight against all of them — and a flick of the pointer makes the smallest creature hop.
How it works
- Depth is a signed number per layer: back −8px, mid 6px, front 16px, the glass highlights −8px, the word behind the globe −10px, the ground 14px. All of them read the same
--x/--y; the sign flip between what is behind the glass and what is on it is the entire illusion of a sphere. - The layers are 12% oversized inside an
overflow: hiddencircle, so sliding never exposes an edge. Vertical travel is 60% of horizontal — a cursor moving up should not lift the scene as much as one moving sideways shifts it. - The hop is triggered by speed, not position: velocity is measured between consecutive pointer events (
distance / dt) and only a flick above 1.6px/ms wakes the creature, with a 700ms cooldown. Frame-diffing the source: the animal jumps on the fast passes and ignores the slow ones. - The hop itself is a 520ms keyframe with squash-and-stretch:
scale(1.15, .85)before take-off,scale(.92, 1.1)at the apex, a landing squash and an 8px second bounce.transform-origin: 50% 100%so it compresses into the ground, not around its centre. - Pointer values are eased at 10% per frame in
requestAnimationFrame; the loop stops when it converges, and the whole scene eases back to centre onmouseoutwith a nullrelatedTarget. - Glass is three radial gradients on one overlay (a broad top highlight, a small lower one, a rim), plus an inset shadow on the globe. It moves against the scene at −8px, which is the cue that it is a surface between you and the miniature.
Numbers
- Depths
- word −10 · back −8 · glass −8 · mid 6 · ground 14 · front 16 px
- Vertical factor
- 0.6 × horizontal
- Easing
- 10% per frame
- Hop trigger
- > 1.6 px/ms · cooldown 700ms
- Hop
- 520ms · −34px · squash 1.15/.85
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a cursor-driven miniature-scene parallax in vanilla HTML/CSS/JS. Black page, a huge serif word 'MEASURED' behind a 300px glass globe, a mossy ground band at the bottom. The globe is a circle with overflow hidden containing three oversized layers (inset −12%): back (rocks, a fern), mid (a moss mound, a large round creature), front (a small white creature, a little orange van, two moss tufts), then a glass overlay of three radial gradients (broad top highlight, small lower highlight, rim). Each layer declares a depth --d (back −8px, mid 6px, front 16px, glass −8px; the word −10px, the ground 14px) and shares one transform: translate(calc(var(--x) * var(--d)), calc(var(--y) * var(--d) * .6)). JS: on pointermove set targets tx, ty in −1…1, ease x += (tx − x) * .1 per requestAnimationFrame, write --x/--y on the scene root, stop when converged, and ease back to 0 on a document mouseout with relatedTarget null. Also measure pointer speed as distance / dt between consecutive events (ignore gaps over 80ms); if speed > 1.6 px/ms and 700ms have passed since the last hop, restart a 520ms 'hop' keyframe on the small creature: 0% scale(1.15,.85) → 30% translateY(−34px) scale(.92,1.1) → 60% translateY(0) scale(1.1,.9) → 80% translateY(−8px) → 100% none, with transform-origin 50% 100%. Under prefers-reduced-motion disable the hop and the easing loop.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cursor-driven miniature scene parallax</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; background: #050505; color: #fff; overflow: hidden; position: relative; -webkit-user-select: none; user-select: none; }
.nav { position: absolute; left: 4%; right: 4%; top: 12px; display: flex; gap: 18px; justify-content: center; font-size: 7.5px; color: #ffffff8c; }
.nav .cta { position: absolute; right: 0; top: 0; color: #fff; }
.nav .lg { position: absolute; left: 0; top: 0; font-weight: 700; color: #fff; }
/* the word sits BEHIND the globe and moves the opposite way: that is the
cheapest possible depth cue and it does most of the work */
h1 {
position: absolute; left: 0; right: 0; top: 34px; text-align: center;
font: 400 92px/1 "Playfair Display", "Didot", "Bodoni 72", Georgia, serif; letter-spacing: .02em;
transform: translate(calc(var(--x) * -10px), calc(var(--y) * -6px));
}
.ground {
position: absolute; left: -10%; right: -10%; bottom: -40px; height: 46%;
background:
radial-gradient(60% 80% at 50% 100%, #3d5a22, #1a2a10 55%, #0a0f07 80%),
#0a0f07;
filter: blur(2px);
transform: translate(calc(var(--x) * 14px), calc(var(--y) * 6px));
}
.ground::before { content: ""; position: absolute; inset: 0; background: radial-gradient(#5b8a2f 1px, transparent 1.4px) 0 0 / 9px 7px; opacity: .35; mask-image: linear-gradient(#0000, #000 40%); -webkit-mask-image: linear-gradient(#0000, #000 40%); }
/* ---------- the globe ---------- */
.globe {
position: absolute; left: 50%; top: 52%; width: 300px; height: 300px; margin: -150px 0 0 -150px; border-radius: 50%;
transform: rotate(calc(var(--x) * 2deg)) translate(calc(var(--x) * 6px), calc(var(--y) * 4px));
background: radial-gradient(circle at 50% 60%, #0d1a0c, #050805 70%);
box-shadow: inset 0 0 40px #ffffff12, inset 0 -20px 40px #00000080, 0 30px 60px -30px #000;
overflow: hidden;
}
/* every layer is oversized and slides by its own depth, front layers further */
.layer { position: absolute; inset: -12%; transform: translate(calc(var(--x) * var(--d)), calc(var(--y) * var(--d) * .6)); }
.back { --d: -8px; }
.mid { --d: 6px; }
.front { --d: 16px; }
.rock { position: absolute; border-radius: 40% 60% 50% 50% / 60% 50% 50% 40%; background: linear-gradient(160deg, #b9a888, #6e6250 60%, #3a3328); box-shadow: inset -8px -10px 20px #0006; }
.moss { position: absolute; border-radius: 50%; background: radial-gradient(circle at 40% 30%, #6fae3a, #2f6a1b 60%, #173a0f); }
.fern { position: absolute; width: 60px; height: 80px; border-radius: 0 100% 0 100%; background: linear-gradient(135deg, #7fc54a, #2d6a1b); transform: rotate(-30deg); opacity: .9; }
/* creatures: rounded shapes with two dots — the small one is the jumper */
.big { position: absolute; left: 150px; top: 150px; width: 70px; height: 80px; border-radius: 50% 50% 46% 46% / 55% 55% 45% 45%; background: radial-gradient(circle at 40% 30%, #8a8f96, #4a4f57 70%); }
.big::before { content: ""; position: absolute; left: 14px; top: 34px; width: 42px; height: 40px; border-radius: 50%; background: #e9e2cf; }
.big::after { content: ""; position: absolute; left: 22px; top: 22px; width: 6px; height: 6px; border-radius: 50%; background: #111; box-shadow: 20px 0 0 #111; }
.big i { position: absolute; top: -12px; width: 12px; height: 22px; border-radius: 50% 50% 0 0; background: #4a4f57; }
.big i:first-child { left: 16px; transform: rotate(-12deg); } .big i:last-child { left: 42px; transform: rotate(12deg); }
.small {
position: absolute; left: 226px; top: 186px; width: 26px; height: 32px; border-radius: 50% 50% 46% 46% / 55% 55% 45% 45%;
background: radial-gradient(circle at 40% 30%, #fff, #cfcabb 75%);
transform-origin: 50% 100%;
}
.small::after { content: ""; position: absolute; left: 7px; top: 12px; width: 3px; height: 3px; border-radius: 50%; background: #111; box-shadow: 9px 0 0 #111; }
.small.hop { animation: hop 520ms cubic-bezier(.22,.61,.36,1); }
@keyframes hop {
0% { transform: scale(1.15, .85); } 30% { transform: translateY(-34px) scale(.92, 1.1); }
60% { transform: translateY(0) scale(1.1, .9); } 80% { transform: translateY(-8px); } 100% { transform: none; }
}
.van { position: absolute; left: 60px; top: 190px; width: 64px; height: 36px; border-radius: 12px 16px 8px 8px; background: linear-gradient(#ffb457, #e27a1f); }
.van::before { content: ""; position: absolute; left: 8px; top: 7px; width: 44px; height: 12px; border-radius: 4px; background: #fff3d4; }
.van::after { content: ""; position: absolute; left: 8px; bottom: -6px; width: 12px; height: 12px; border-radius: 50%; background: #222; box-shadow: 34px 0 0 #222; }
/* the glass: rim, two highlights, both slide AGAINST the scene */
.glass { position: absolute; inset: 0; border-radius: 50%; pointer-events: none;
background:
radial-gradient(60% 30% at 30% 12%, #ffffff8c, #fff0 70%),
radial-gradient(30% 12% at 72% 82%, #ffffff40, #fff0 70%),
radial-gradient(circle at 50% 50%, #0000 78%, #ffffff10 90%, #ffffff33 100%);
transform: translate(calc(var(--x) * -8px), calc(var(--y) * -5px));
}
.rim { position: absolute; left: 50%; top: 52%; width: 300px; height: 300px; margin: -150px 0 0 -150px; border-radius: 50%; box-shadow: inset 0 0 0 2px #ffffff26, 0 0 0 1px #ffffff14; pointer-events: none;
transform: rotate(calc(var(--x) * 2deg)) translate(calc(var(--x) * 6px), calc(var(--y) * 4px)); }
.neck { position: absolute; left: 50%; top: calc(52% - 158px); width: 120px; height: 18px; margin-left: -60px; border-radius: 50%; border: 2px solid #ffffff3d; background: #0a0a0a; transform: translate(calc(var(--x) * 6px), calc(var(--y) * 4px)); }
.hint { position: absolute; left: 0; right: 0; bottom: 12px; text-align: center; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #ffffff55; }
@media (prefers-reduced-motion: reduce) { .small.hop { animation: none; } .hint { display: none; } }
</style>
<div id="scene" style="--x:0;--y:0">
<div class="nav"><span class="lg">◐</span><span>Device</span><span>Real Stories</span><span>Science</span><span>Plans</span><span>Reach Us</span><span class="cta">● Reserve Yours</span></div>
<h1>MEASURED</h1>
<div class="ground"></div>
<div class="neck"></div>
<div class="globe">
<div class="layer back"><div class="rock" style="left:90px;top:70px;width:140px;height:150px"></div><div class="rock" style="left:210px;top:140px;width:90px;height:70px;opacity:.8"></div><div class="fern" style="left:60px;top:110px"></div></div>
<div class="layer mid"><div class="moss" style="left:40px;top:180px;width:280px;height:150px"></div><div class="big"><i></i><i></i></div><div class="fern" style="left:250px;top:150px;transform:rotate(30deg) scaleX(-1)"></div></div>
<div class="layer front"><div class="van"></div><div class="small" id="small"></div><div class="moss" style="left:-20px;top:240px;width:180px;height:80px;opacity:.9"></div><div class="moss" style="left:180px;top:250px;width:200px;height:90px;opacity:.9"></div></div>
<div class="glass"></div>
</div>
<div class="rim"></div>
<div class="hint">move the cursor · move it fast</div>
</div>
<script>
/* Two numbers, --x and --y in −1…1, eased at 10% per frame; every layer
multiplies them by its own --d in CSS (back −8, mid 6, front 16, glass −8,
word −10). The hop is separate: it is triggered by pointer SPEED, not
position — a slow sweep never wakes the creature, a flick does. */
const scene = document.getElementById("scene"), small = document.getElementById("small");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
let tx = 0, ty = 0, x = 0, y = 0, raf = 0, lastX = 0, lastY = 0, lastT = 0, cool = 0;
function tick() {
x += (tx - x) * 0.1; y += (ty - y) * 0.1;
scene.style.setProperty("--x", x.toFixed(4)); scene.style.setProperty("--y", y.toFixed(4));
raf = (Math.abs(tx - x) > 0.001 || Math.abs(ty - y) > 0.001) ? requestAnimationFrame(tick) : 0;
}
addEventListener("pointermove", (e) => {
tx = (e.clientX / innerWidth) * 2 - 1; ty = (e.clientY / innerHeight) * 2 - 1;
if (!raf && !reduce) raf = requestAnimationFrame(tick);
// speed in px/ms over the last event; 1.6 px/ms is a flick, not a drift
const now = performance.now(), dt = now - lastT;
if (lastT && dt < 80) {
const v = Math.hypot(e.clientX - lastX, e.clientY - lastY) / dt;
if (v > 1.6 && now > cool) { cool = now + 700; small.classList.remove("hop"); void small.offsetWidth; small.classList.add("hop"); }
}
lastX = e.clientX; lastY = e.clientY; lastT = now;
}, { passive: true });
document.addEventListener("mouseout", (e) => { if (!e.relatedTarget) { tx = 0; ty = 0; if (!raf && !reduce) raf = requestAnimationFrame(tick); } });
</script>Where it breaks
- Six layers with
filter: bluror largebox-shadowwould each repaint on every frame of the ease. Only the ground has a blur here and it is 2px; keep blur off the moving layers. - Speed from two consecutive events is noisy on a trackpad (events arrive in bursts). The 80ms gap cut and the cooldown are what make the hop feel intentional rather than twitchy; without them a slow drag fires it.
- A flick on touch is a scroll gesture. The hop is pointer-only by construction; on phones it never fires, and the parallax collapses to a still — acceptable, but do not promise the hop in copy.
Seen in
@优卓UX上岸社 (Douyin) · 「UI交互动效描述」第 4 集 · Measured · 0:18