← Back to gallery
MIXED

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.

preserve-3dpointermovecss-varsparallaxtranslateZtransform-stylereduced-motion

3D / pointer vars / preserve-3d

Pointer Preserve-3D Parallax Stack

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

Depth Stack

Pointer CSS variables drive a single 3D card stack without flattening child layers.

  • preserve-3d
  • pointer vars
  • translateZ

Blend layer · same scene

Glare Plane

Glare stays inside the transformed element, so highlight and content share perspective.

  • mix-blend-mode
  • glare
  • one scene

Background · foreground split

Scene Split

A small scene proves depth with separate Z planes and one perspective root.

  • depth planes
  • perspective
  • no flatten

Parallax stack inspector

Depth Stack

Depth Stack
  • preserve-3d
  • pointer vars
  • translateZ

The stack tilts as one preserve-3d scene, while foreground badges use translateZ so the depth stays coherent.

Helped you ship something? 🐟 Send my cat a churu

/* Depth Stack: Pointer CSS variables drive a single 3D card stack without flattening child layers. */
.motion-parallax-depth-stack {
  --motion-duration: 4.2s;
  --motion-intensity: 1.00;
  width: min(100%, 18rem);
  min-height: 9rem;
  display: grid;
  place-items: center;
  perspective: 620px;
}

.motion-parallax-depth-stack__stack {
  position: relative;
  width: min(62%, 168px);
  height: 68px;
  transform-style: preserve-3d;
  animation: parallax-depth-stack-tilt var(--motion-duration) ease-in-out infinite;
  will-change: transform;
}

.motion-parallax-depth-stack__layer {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border-radius: 14px;
  backface-visibility: hidden;
  background:
    linear-gradient(135deg, #67e8f933, transparent 58%),
    rgba(15, 23, 42, .94);
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, #67e8f9 42%, transparent),
    0 16px 32px rgba(2, 6, 23, .28);
  color: color-mix(in srgb, #67e8f9 68%, white);
  font-size: .72rem;
  font-weight: 900;
  letter-spacing: .06em;
  text-transform: uppercase;
  transform-style: preserve-3d;
}

.motion-parallax-depth-stack__layer--back {
  transform: translate3d(-22px, 12px, -48px);
  opacity: .58;
}

.motion-parallax-depth-stack__layer--mid {
  transform: translate3d(-7px, 5px, -14px);
  opacity: .76;
}

.motion-parallax-depth-stack__layer--front {
  transform: translate3d(14px, -9px, 42px);
}

@keyframes parallax-depth-stack-tilt {
  0%, 100% {
    transform: rotateX(calc(7deg * var(--motion-intensity))) rotateY(calc(-12deg * var(--motion-intensity)));
  }
  50% {
    transform: rotateX(calc(-7deg * var(--motion-intensity))) rotateY(calc(12deg * var(--motion-intensity)));
  }
}

@media (prefers-reduced-motion: reduce) {
  .motion-parallax-depth-stack__stack {
    animation: none;
    transform: rotateX(6deg) rotateY(-10deg);
  }
}

How to make this

A pointer preserve-3d parallax stack maps bounded pointer coordinates to CSS variables, then rotates a fixed 3D layer stack without moving layout.

html
<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;32  perspective: 700px;}.parallax-card__stack {  position: relative;  width: 70%;  height: 58%;38  transform-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;48  transform: 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>

Annotated snippet

  1. Line 11Pointer input only updates CSS variables. Layout dimensions and hit targets stay fixed while the camera changes.
    PitfallHow far should pointer parallax rotate?

    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.

  2. Line 32Perspective belongs on the scene wrapper, not on each layer. That keeps the 3D projection consistent.

    A flat rotate reads like skew. preserve-3d plus translateZ gives each layer an actual depth plane.

    PitfallWhere should perspective be applied?

    Apply perspective to the outer scene and preserve-3d to the inner stack. Putting perspective on each layer creates inconsistent projection.

  3. Line 38Without preserve-3d, children flatten into the parent plane and translateZ stops communicating depth.
    PitfallWhy does translateZ appear to do nothing?

    The parent is probably flattening children. Add transform-style: preserve-3d to the stack that contains the z-translated layers.

  4. Line 48Each layer owns a fixed z-depth. Pointer motion changes the camera, not the layer geometry.
    PitfallShould layers change z-depth on every pointer move?

    No. Keep each layer depth fixed and change only the camera rotation variables so the visual hierarchy remains stable.

  5. Line 52Reduced motion flattens live tracking while leaving the stack readable as layered content.
    PitfallWhat should reduced motion do for pointer parallax?

    Disable live tilt and transitions, but keep the layered composition visible so the card does not become a blank fallback.

Other pitfalls

What should I verify before shipping this pattern?
Check that the preview card and showcase communicate the same start and end state, every inspector control visibly changes the animation, compare demos stay fixed-height and centered, and reduced motion preserves the information without running a substitute loop.
Advanced

Layer a tilt-driven specular highlight that lives on the same --rx/--ry stream

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.

View explanation and full code37 lines

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.

css
/* 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 gradient   angle leans with --ry (left/right tilt) so the highlight band rolls   across the card; the opacity scales with tilt magnitude through   abs() so the card sits dark when flat and brightens as it tilts.   Requires @property registration on --rx/--ry so calc/abs can read   them 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; }}

Notes

Overview

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.

When to use it

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.

How it works

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.

Production gotchas

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.

Accessibility

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.

References

Implementation depth

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.