Web design effects

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

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.

cursor-miniature-parallax.html
<!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

Seen in

@优卓UX上岸社 (Douyin) · 「UI交互动效描述」第 4 集 · Measured · 0:18