Pointer Preserve-3D Parallax Stack
A pointer-driven 3D stack that keeps child layers in one preserve-3d scene while CSS variables drive tilt, glare, and translateZ offsets without flattening the depth model.
A pointer-driven 3D stack that keeps child layers in one preserve-3d scene while CSS variables drive tilt, glare, and translateZ offsets without flattening the depth model.
3D / pointer vars / preserve-3d
Three pointer-driven 3D stack expressions keep all layers in one preserve-3d scene while CSS variables drive tilt, glare, and depth offsets.
Pointer vars · translateZ
Pointer CSS variables drive a single 3D card stack without flattening child layers.
Blend layer · same scene
Glare stays inside the transformed element, so highlight and content share perspective.
Background · foreground split
A small scene proves depth with separate Z planes and one perspective root.
A pointer preserve-3d parallax stack maps bounded pointer coordinates to CSS variables, then rotates a fixed 3D layer stack without moving layout.
<div class="parallax-card" tabindex="0"><div class="parallax-card__stack"><span class="parallax-card__layer" style="--z: -34px"></span><span class="parallax-card__layer" style="--z: 0px"></span><strong class="parallax-card__layer" style="--z: 42px">Depth</strong></div></div><script>const card = document.querySelector('.parallax-card');11card?.addEventListener('pointermove', (event) => {const rect = card.getBoundingClientRect();const x = (event.clientX - rect.left) / rect.width - 0.5;const y = (event.clientY - rect.top) / rect.height - 0.5;card.style.setProperty('--rx', `${(-y * 10).toFixed(2)}deg`);card.style.setProperty('--ry', `${(x * 14).toFixed(2)}deg`);});card?.addEventListener('pointerleave', () => {card.style.setProperty('--rx', '0deg');card.style.setProperty('--ry', '0deg');});</script><style>.parallax-card {--rx: 0deg;--ry: 0deg;width: 18rem;aspect-ratio: 16 / 9;display: grid;place-items: center;32perspective: 700px;}.parallax-card__stack {position: relative;width: 70%;height: 58%;38transform-style: preserve-3d;transform: rotateX(var(--rx)) rotateY(var(--ry));transition: transform 160ms ease;}.parallax-card__layer {position: absolute;inset: 0;display: grid;place-items: center;border-radius: 1rem;48transform: translateZ(var(--z));background: color-mix(in srgb, #67e8f9 18%, #0f172a);border: 1px solid rgba(103, 232, 249, .38);}52@media (prefers-reduced-motion: reduce) {.parallax-card__stack {transform: none;transition: none;}}</style>
Keep it small, usually below 10 to 14 degrees. Large rotations make text hard to read and can move perceived hit targets away from the pointer.
A flat rotate reads like skew. preserve-3d plus translateZ gives each layer an actual depth plane.
Apply perspective to the outer scene and preserve-3d to the inner stack. Putting perspective on each layer creates inconsistent projection.
The parent is probably flattening children. Add transform-style: preserve-3d to the stack that contains the z-translated layers.
No. Keep each layer depth fixed and change only the camera rotation variables so the visual hierarchy remains stable.
Disable live tilt and transitions, but keep the layered composition visible so the card does not become a blank fallback.
Both cells run the same 4s tilt cycle (the pointer is simulated by a @property-registered --rx/--ry keyframe so the compare can autoplay). BEFORE shows the base recipe — the 3D stack tilts but the surface stays uniformly lit, so it reads as a flat panel rotating. AFTER adds a pseudo-element overlay that consumes the same --rx/--ry: a gradient highlight rolls across the card and its opacity intensifies as the tilt magnitude grows via abs(). The card now reads as a physical surface catching ambient light at the steepest tilt angles.
The base recipe lets the pointer drive the 3D rotation through two custom properties (--rx, --ry) and a fixed layer stack. Premium product cards (Apple, Stripe, Linear) add a specular highlight on top of the stack that intensifies as the card tilts more steeply — the visual reward that makes a 3D surface feel like it is catching light. Add a single pseudo-element overlay that consumes the EXACT same --rx/--ry the pointer handler is already updating: the gradient direction tracks --ry, and the opacity scales with the tilt magnitude via abs(). No new JS, no new event listeners — the existing pointermove handler is what makes the highlight follow the cursor.
Append these rules inside the <style> block from the base snippet above.
/* Advanced: tilt-driven specular highlight — extends the base recipe.Same --rx/--ry custom properties the base pointer handler updates,just consumed by an additional pseudo-element overlay. The gradientangle leans with --ry (left/right tilt) so the highlight band rollsacross the card; the opacity scales with tilt magnitude throughabs() so the card sits dark when flat and brightens as it tilts.Requires @property registration on --rx/--ry so calc/abs can readthem as angles. */@property --rx {syntax: '<angle>';inherits: true;initial-value: 0deg;}@property --ry {syntax: '<angle>';inherits: true;initial-value: 0deg;}.parallax-card__stack::before {content: "";position: absolute;inset: 0;border-radius: 1rem;pointer-events: none;transform: translateZ(50px);background: linear-gradient(calc(90deg + var(--ry) * 4),rgba(255, 255, 255, .65) 0%,rgba(255, 255, 255, 0) 55%);opacity: calc((abs(var(--rx)) + abs(var(--ry))) / 24deg);mix-blend-mode: screen;transition: opacity 160ms ease;}@media (prefers-reduced-motion: reduce) {.parallax-card__stack::before { opacity: 0; }}
Pointer preserve-3d parallax is a depth illusion built from a real 3D stack, not a flat card that happens to rotate. The important distinction is ownership: the scene owns perspective, the stack preserves 3D children, and each layer has a fixed translateZ depth. Pointer input only nudges camera variables inside a small, clamped range, so the object feels responsive without changing layout.
Use it for compact hero objects, feature cards, editor thumbnails, and product surfaces where depth helps the user inspect hierarchy. It works best when the animated thing is one self-contained object with a clear front, middle, and back plane. Skip it for dense reading content, primary form controls, or required workflows where pointer tracking would make the target feel like it is moving away from the user.
The wrapper owns perspective, the stack owns transform-style: preserve-3d, and each child uses translateZ for depth. JavaScript maps pointer coordinates to CSS variables such as --rx and --ry, then CSS applies those variables to the single stack transform.
Do not rotate the whole page, animate layout dimensions, or let pointer values become unbounded. Large angles make text hard to read and can make perceived hit targets drift away from their actual boxes. Inspector controls must affect depth, tilt, duration, or easing; decorative controls that do not affect the animation should be removed.
Depth is decorative, so the content must remain readable without motion. Pair pointer tracking with focus-visible parity and provide an idle pose that still communicates layers for touch and keyboard users. Reduced motion should flatten live tracking while keeping the stacked planes visible, rather than removing the visual hierarchy entirely.
This is the pointer-reactive version of preserve-3d layering, not another static cube. The scene owns perspective, the stack owns transform-style: preserve-3d, and each layer has a stable translateZ depth so pointer movement changes camera feel without changing document layout.
Keep pointer input bounded and optional. Map client coordinates into small CSS variable ranges, clamp the tilt, and provide a focus or idle state that reads as depth without requiring mouse movement. Reduced motion should flatten the live tracking while keeping the layers visible.
The showcase examples should stay method-based: a depth stack, a glare plane, and a foreground/background split are different expressions of the same 3D contract. Changing only the label or color would not be enough evidence that the slug deserves a separate preset.
Production code needs a clear event boundary. One pointermove listener on the root should update variables; child layers should not each track input, and the transformed surface should not become the source of layout, scroll, or hit-target changes.