Cursor-driven luminous gateway
A glowing doorway on a dusk horizon. The whole scene creeps toward you over 16 seconds, mist rises off the ground, and the cursor tilts the layers apart by depth.
How it works
- Three motions, three owners. The dolly is one CSS animation on the scene wrapper:
scale(1) → scale(1.11)over 16s with a strong ease-out, origin at the horizon (50% 62%) so the push feels aimed at the door. The mist is CSS keyframes on three sheets. The parallax is the only thing JavaScript touches. - JS writes two numbers,
--pxand--pyin −1…1, eased toward the pointer at 8% per frame. Every layer multiplies them by its own--din CSS (translate(calc(var(--px) * var(--d)), …)): sky 4px, far ridge 10px, gate 14px, near ridge 18px, water 22px, mist 30px. Adding a layer is one custom property, not another line of script. - Every layer is 12% larger than the frame (
inset: -6%) so neither the parallax nor the dolly ever exposes an edge. - The door is a white rectangle with four stacked
box-shadows (14px white, 60px peach, 180px orange, 320px deep orange) — that is the bloom — plus a blurred ellipse below it for light on the ground and a masked radial column on the water for the reflection, with 1px ripples inside the same mask. It also tiltsrotateY(±6°)with--px, which sells it as an object standing in the scene. - Mist sheets fade up from 0 to .55 over 7s (delays 0.8 / 1.9 / 3.2s) — the source's 'ground gradually reveals itself' — then drift ±6% on 26 / 19 / 33-second alternating loops so the pattern never repeats.
- Headline lines rise out of an
overflow: hiddenwindow (900ms, 140ms stagger) with the second line in a ghost weight, as in the source; the paragraph and button fade in at 700ms. - The pointer leaving the document fires no further
pointermove, so a documentmouseoutwithrelatedTarget === nulleases the scene back to centre. Parallax is gated behind(pointer: fine)and off under reduced motion, where the dolly and mist drift are also removed.
Numbers
- Dolly
- scale 1 → 1.11 · 16s cubic-bezier(.16,.6,.3,1) · origin 50% 62%
- Parallax depths
- 4 / 10 / 14 / 18 / 22 / 30 px
- Pointer easing
- 8% per frame
- Door bloom
- 14 · 60 · 180 · 320px shadows
- Mist
- rise 7s to .55 · drift ±6% on 26/19/33s
- Headline
- 900ms cubic-bezier(.22,.61,.36,1) · 140ms stagger
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 luminous gateway hero in vanilla HTML/CSS/JS. A .scene wrapper with inset: -6% and transform-origin: 50% 62% animates scale(1) → scale(1.11) over 16s cubic-bezier(.16,.6,.3,1) forwards. Inside it, absolutely positioned layers each declare a depth --d and share one transform: translate(calc(var(--px, 0) * var(--d)), calc(var(--py, 0) * var(--d) * .6)). Layers, back to front: sky (--d 4px; linear gradient #150f3a → #34205a 36% → #8a3f5a 54% → #f28a55 64% → #ffb07a 68%, plus a peach radial glow at 50% 66% and a faint star field on the top half), a far ridge (--d 10px; jagged SVG polygon with a vertical gradient fill), a near ridge (--d 18px; darker polygon), the gate (--d 14px), water (--d 22px; from 66% down, dark gradient, a masked radial reflection column with 1px repeating ripples), and mist (--d 30px). The door is a 4.6%-wide, 29%-tall white rectangle at top 38%, with box-shadow: 0 0 14px 4px #ffffffe6, 0 0 60px 18px #ffb27acc, 0 0 180px 60px #ff8a4a66, 0 0 320px 120px #ff6a2a33, a rotateY(calc(var(--px) * 6deg)) tilt, and a blurred ::after ellipse below it for light on the ground. Mist: three 140%-wide blurred (14px) radial-gradient sheets low in the frame, animated from opacity 0 / translateY(18px) to opacity .55 over 7s with delays .8s, 1.9s, 3.2s, then drifting margin-left −6% → 6% on 26s, 19s and 33s alternate loops. Headline: two lines each in an overflow: hidden window, rising from translateY(110%) over 900ms cubic-bezier(.22,.61,.36,1) with a 140ms stagger, the second line at 52% white; paragraph and button fade in after 700ms. JS: on pointermove set targets tx = clientX/innerWidth*2−1 and ty likewise, ease x += (tx − x) * .08 per requestAnimationFrame, write --px/--py on the scene, and stop the loop once within .001. On a document mouseout with relatedTarget null, ease back to 0,0. Enable only under (pointer: fine) and not under prefers-reduced-motion, where the dolly and mist drift are also disabled.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 luminous gateway</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: #1a1240; color: #fff; overflow: hidden; position: relative;
}
/* Every layer is 12% larger than the frame so the parallax never shows an
edge, and the whole scene is on one slow dolly-in. */
.scene {
position: absolute; inset: -6%;
transform-origin: 50% 62%;
animation: dolly 16s cubic-bezier(.16,.6,.3,1) forwards;
}
@keyframes dolly { from { transform: scale(1); } to { transform: scale(1.11); } }
.layer {
position: absolute; inset: 0;
/* --px/--py: the cursor, −1…1. --d: this layer's depth (px of travel). */
transform: translate(calc(var(--px, 0) * var(--d)), calc(var(--py, 0) * var(--d) * .6));
will-change: transform;
}
.sky {
--d: 4px;
background:
radial-gradient(60% 45% at 50% 66%, #ffb27a 0%, #ff7a4a55 35%, #ff7a4a00 70%),
linear-gradient(#150f3a 0%, #34205a 36%, #8a3f5a 54%, #f28a55 64%, #ffb07a 68%);
}
.sky::after { /* a handful of stars, high only */
content: ""; position: absolute; inset: 0 0 50%;
background-image:
radial-gradient(#ffffffb0 .6px, transparent 1px),
radial-gradient(#ffffff70 .5px, transparent 1px);
background-size: 140px 90px, 90px 70px;
background-position: 20px 10px, 50px 30px;
opacity: .7;
}
.ridge { --d: 10px; }
.ridge svg { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
.ridge.near { --d: 18px; }
/* Water: the bottom third mirrors the sky, dimmed, with the gate's light
laid down it as a soft column + hairline ripples. */
.water {
--d: 22px;
top: 66%;
background: linear-gradient(#b0644d 0%, #4a2a4a 20%, #221638 60%, #110b24 100%);
}
.water::before {
content: ""; position: absolute; left: 50%; top: -6%; width: 30%; height: 110%;
transform: translateX(-50%);
background:
repeating-linear-gradient(#ffffff00 0 3px, #ffffff2e 3px 4px, #ffffff00 4px 7px),
radial-gradient(50% 50% at 50% 12%, #fff5e8e6, #ffb27a99 28%, #ff9a5a33 55%, transparent 72%);
/* the ripples only exist where the light is: mask the stripes with the same ellipse */
mask-image: radial-gradient(50% 50% at 50% 12%, #000 30%, transparent 72%);
-webkit-mask-image: radial-gradient(50% 50% at 50% 12%, #000 30%, transparent 72%);
filter: blur(1.5px);
}
.water::after { /* the shore's black rocks */
content: ""; position: absolute; inset: 0 0 auto; height: 12%;
background: radial-gradient(100% 100% at 50% 100%, #00000000 55%, #12091e 56%);
opacity: .8;
}
.gate { --d: 14px; }
.door {
position: absolute; left: 50%; top: 38%; width: 4.6%; height: 29%;
transform: translateX(-50%) rotateY(calc(var(--px, 0) * 6deg));
border-radius: 2px;
background: linear-gradient(#ffffff, #fff4e6 60%, #ffd9b0);
box-shadow:
0 0 14px 4px #ffffffe6,
0 0 60px 18px #ffb27acc,
0 0 180px 60px #ff8a4a66,
0 0 320px 120px #ff6a2a33;
}
.door::after { /* light spilling onto the ground */
content: ""; position: absolute; left: 50%; top: 100%; width: 420%; height: 34%;
transform: translate(-50%, -30%);
background: radial-gradient(50% 50% at 50% 50%, #ffffffcc, #ffb27a66 40%, transparent 70%);
filter: blur(6px);
}
/* Fog: three long blurred sheets low in the frame. They fade UP over the
first seconds (the "ground reveals itself") and then drift sideways on
loops of different lengths so the pattern never repeats. */
.fog { --d: 30px; top: 58%; }
.fog i {
position: absolute; left: -20%; width: 140%; height: 38%;
border-radius: 50%;
background: radial-gradient(50% 50% at 50% 50%, #ffdcc8, #ffb89a88 45%, transparent 72%);
filter: blur(14px);
opacity: 0;
animation: rise 7s ease-out forwards, drift var(--t) ease-in-out infinite alternate;
animation-delay: var(--w), 0s;
}
.fog i:nth-child(1) { top: 22%; left: -30%; --t: 26s; --w: .8s; }
.fog i:nth-child(2) { top: 46%; left: 0; width: 110%; --t: 19s; --w: 1.9s; background: radial-gradient(50% 50% at 40% 50%, #ffc7a8e0, #e0a08a66 45%, transparent 72%); }
.fog i:nth-child(3) { top: 66%; left: -40%; --t: 33s; --w: 3.2s; background: radial-gradient(50% 50% at 60% 50%, #ffe0cc, #ffb89a77 40%, transparent 70%); }
@keyframes rise { from { opacity: 0; transform: translateY(18px); } to { opacity: .55; transform: translateY(0); } }
@keyframes drift { from { margin-left: -6%; } to { margin-left: 6%; } }
.copy { position: absolute; left: 0; right: 0; top: 24px; text-align: center; pointer-events: none; }
.line { overflow: hidden; height: 1.15em; }
.line span { display: block; transform: translateY(110%); animation: up 900ms cubic-bezier(.22,.61,.36,1) forwards; }
.copy h1 { font-size: 22px; font-weight: 500; letter-spacing: -.01em; }
.copy h1 .line:nth-child(2) span { animation-delay: 140ms; color: #ffffff85; font-weight: 400; }
.copy p { margin-top: 6px; font-size: 7px; line-height: 1.5; color: #ffffffb0; letter-spacing: .04em; }
.copy .btn { display: inline-block; margin-top: 8px; padding: 4px 9px; background: #fff; color: #1a1240; font-size: 6.5px; letter-spacing: .06em; }
.copy p, .copy .btn { opacity: 0; animation: fade 700ms ease-out forwards; animation-delay: 700ms; }
@keyframes up { to { transform: translateY(0); } }
@keyframes fade { to { opacity: 1; } }
.hint { position: absolute; left: 0; right: 0; bottom: 12px; text-align: center; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #ffffff66; }
@media (prefers-reduced-motion: reduce) {
.scene, .fog i, .line span, .copy p, .copy .btn { animation: none; }
.fog i { opacity: .55; }
.line span { transform: none; }
.copy p, .copy .btn { opacity: 1; }
.hint { display: none; }
}
</style>
<div class="scene" id="scene">
<div class="layer sky"></div>
<div class="layer ridge far">
<svg viewBox="0 0 1000 400" preserveAspectRatio="none" aria-hidden="true">
<defs><linearGradient id="hz" x1="0" y1="0" x2="0" y2="1"><stop offset=".5" stop-color="#4a2d5c"/><stop offset=".68" stop-color="#7a3f58"/></linearGradient></defs>
<path fill="url(#hz)" d="M0 268 L28 252 L52 258 L84 236 L106 244 L132 222 L150 230 L176 214 L204 228 L226 220 L252 236 L286 210 L310 224 L336 218 L362 232 L392 226 L420 240 L448 236 L476 246 L504 242 L536 234 L560 240 L588 226 L612 236 L640 214 L662 224 L690 208 L714 220 L742 212 L770 230 L800 218 L828 232 L856 216 L880 228 L910 214 L936 226 L962 220 L1000 236 L1000 400 L0 400Z"/>
</svg>
</div>
<div class="layer ridge near">
<svg viewBox="0 0 1000 400" preserveAspectRatio="none" aria-hidden="true">
<path fill="#1b1030" d="M0 282 L22 270 L48 276 L70 258 L96 266 L124 250 L146 262 L170 254 L196 266 L222 256 L250 264 L276 246 L300 258 L322 252 L348 260 L376 256 L410 262 L440 258 L470 264 L500 260 L530 264 L560 258 L590 266 L620 252 L646 262 L672 244 L698 256 L724 250 L752 262 L780 254 L812 262 L840 248 L868 258 L896 250 L926 262 L954 256 L1000 266 L1000 400 L0 400Z"/>
</svg>
</div>
<div class="layer gate"><div class="door"></div></div>
<div class="layer water"></div>
<div class="layer fog"><i></i><i></i><i></i></div>
</div>
<div class="copy">
<h1><div class="line"><span>Your Personal Gateway To</span></div><div class="line"><span>Global Connection</span></div></h1>
<p>Step through and host real-time, multilingual conversations — no extra<br>apps or plugins needed.</p>
<span class="btn">contact sales</span>
</div>
<div class="hint">move the cursor</div>
<script>
/* The cursor only ever writes two numbers, --px and --py in −1…1. Each
layer multiplies them by its own --d in CSS, so adding a layer is one
custom property, not another line of JavaScript. The numbers are eased
towards the target at 8% per frame: the scene follows the hand with a
little weight instead of snapping to it. */
const scene = document.getElementById("scene");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
let tx = 0, ty = 0, x = 0, y = 0, raf = 0;
function tick() {
x += (tx - x) * 0.08; y += (ty - y) * 0.08;
scene.style.setProperty("--px", x.toFixed(4));
scene.style.setProperty("--py", y.toFixed(4));
raf = (Math.abs(tx - x) > 0.001 || Math.abs(ty - y) > 0.001) ? requestAnimationFrame(tick) : 0;
}
if (!reduce && matchMedia("(pointer: fine)").matches) {
addEventListener("pointermove", (e) => {
tx = (e.clientX / innerWidth) * 2 - 1;
ty = (e.clientY / innerHeight) * 2 - 1;
if (!raf) raf = requestAnimationFrame(tick);
}, { passive: true });
// the pointer leaving the document fires no further pointermove
document.addEventListener("mouseout", (e) => { if (!e.relatedTarget) { tx = 0; ty = 0; if (!raf) raf = requestAnimationFrame(tick); } });
}
</script>Where it breaks
- Four stacked large-radius box-shadows on a moving element repaint every frame the parallax runs. On a full-viewport hero at DPR 2 this shows on integrated GPUs — promote the gate layer with
will-change: transform(done) and keep the biggest shadow under ~350px. - The dolly runs once from page load and never resets; on a route change inside an SPA the animation will not replay unless the element is re-mounted.
- Mist over the water dims the reflection. The sheets here are lowered and capped at .55 opacity for that reason; push them up and the door's reflection disappears, which was the first version of this sample.
- The sky's saturated orange band does not meet contrast for the small paragraph if the copy is moved down; keep text on the dark upper third.
Seen in
@优卓UX上岸社 (Douyin) · 「动效描述」第 3 集 · Gateway · 0:26