GODRICH

3D Hover Effect CSS: 9 Pointer-Following Scenes

A 3D hover effect turns a scene toward your pointer with CSS 3D rotation. These nine "3d hover effect css" scenes use only perspective, preserve-3d,

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine scenes are ordered by how each one listens to the pointer, not by spectacle. The first three respond with angles — the product mockup turns pointer X and Y into rotateY and rotateX (01), depth coefficients split a card stack into layers (02), and drag spins a six-faced box freely (03). The next three respond with direction — eyes measure the angle to the cursor (04), the same word floats in stacked copies (05), and the cursor becomes the lamp for a highlight (06). The last three survive without a pointer — a layered panorama builds the parallax feel (07), a tilt sensor takes over (08), and automatic rotation with a prefers-reduced-motion stop closes the set (09). Stage colors rotate through three inks, two papers, two yellows, and two oranges. Screens that react to scrolling live in the scroll story template, and changing the cursor itself is the subject of 9 mouse cursor effects.

01Product mockup that turns with the pointer

The base scene maps pointer X to rotateY ±16deg and pointer Y to rotateX ±6deg. Inertia and spring return share one loop: a pull toward the target angle plus a friction term each frame makes the box bounce back to its front view when you let go. The preview replays the same range as a CSS keyframe track — 3.96% changed area, 23/23 frames moving — a fit for hero images on product pages.

perspectiverotateYtransform-style
// Damped tracking: stiffness toward the aim plus friction handles inertia and spring return in one equation
function step() {
  velX = (velX + (aimX - nowX) * 0.18) * 0.76;
  velY = (velY + (aimY - nowY) * 0.18) * 0.76;
  nowX += velX;
  nowY += velY;
  model.style.transform = 'rotateX(' + nowX.toFixed(2) + 'deg) rotateY(' + nowY.toFixed(2) + 'deg)';
  var resting = Math.abs(velX) < 0.01 && Math.abs(velY) < 0.01 && Math.abs(aimX - nowX) < 0.05 && Math.abs(aimY - nowY) < 0.05;
  if (resting && !holding) { running = false; return; }
  requestAnimationFrame(step);
}
/* perspective goes on the scene (parent), preserve-3d on the turning element — stacked on one element, faces flatten */
.pr__scene {
  position: relative;
  perspective: 620px;
  perspective-origin: 50% 42%;
}
.pr__model {
  position: absolute; inset: 0; margin: auto;
  transform-style: preserve-3d;
}

02Card stack that shifts by depth

Three cards each carry a different depth coefficient, and JavaScript passes just one value, pointer X from −1 to 1. The front card slides 26px, the middle 16px, the back 8px, while translateZ handles the perspective shrink so the layers read as separate planes; the same value sweeps the gloss band's gradient angle. The preview measured 11.46% changed area, 19/23 frames moving — a fit for pricing comparison cards.

translate3dlinear-gradientrotateY
/* Depth coefficient: the front card slides the most. translate3d's z also handles perspective shrink */
.cs__card {
  transform: translate3d(calc(-50% + var(--px) * var(--shift)), calc(-50% + var(--y)), var(--z))
             rotateY(calc(var(--px) * var(--tilt)));
}
.cs__card--back  { --shift: 8px;  --z: -56px; --tilt: 3deg; --y: -28px; }
.cs__card--mid   { --shift: 16px; --z: -28px; --tilt: 5deg; --y: -18px; }
.cs__card--front { --shift: 26px; --z: 0px;   --tilt: 7deg; --y: 0px; }
function step() {
  now += (aim - now) * 0.16;
  scene.style.setProperty('--px', now.toFixed(3));
  if (Math.abs(aim - now) < 0.002) { scene.style.setProperty('--px', aim.toFixed(3)); running = false; return; }
  requestAnimationFrame(step);
}

03Six-faced box you drag to spin

Six faces hold content, and you spin the box freehand. On release, it coasts on its remaining speed, then pulls to the nearest 90-degree notch and stops — inertia and snap in one loop. The preview measured 9.35% changed area, 18/23 frames moving — a fit for a six-category picker.

rotateXpointermovesetPointerCapture
// After release it coasts (inertia), then pulls to the nearest 90-degree notch and stops
function step() {
  if (dragging) { running = false; return; }
  var snap = Math.round(angle / 90) * 90;
  spin = spin * 0.92 + (snap - angle) * 0.08;
  angle += spin;
  paint();
  if (Math.abs(spin) < 0.05 && Math.abs(snap - angle) < 0.3) { angle = snap; paint(); running = false; return; }
  requestAnimationFrame(step);
}
/* The six faces are the same square pushed outward by half its side (--s / 2) */
.cb__face--f { transform: translateZ(calc(var(--s) / 2)); }
.cb__face--r { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.cb__face--u { transform: rotateX(90deg) translateZ(calc(var(--s) / 2)); }

04Character whose eyes chase the cursor

Each eye measures the angle from its own center to the cursor with Math.atan2 and sends the pupil out by 22% of the eye's size in that direction. Blinking is a separate timer that drops and lifts the eyelid as a steps(1, end) cut, and because the cat and the robot stand at different spots, the same cursor hits them at different angles. The preview measured 2.57% changed area, 20/23 frames moving — a mascot for 404 pages.

Math.atan2scaleYsteps
// Each eye measures the angle from its center to the cursor, and lets the pupil out only that far
function look(clientX, clientY) {
  for (var i = 0; i < eyes.length; i++) {
    var eye = eyes[i];
    var pupil = eye.firstElementChild;
    var r = eye.getBoundingClientRect();
    var angle = Math.atan2(clientY - (r.top + r.height / 2), clientX - (r.left + r.width / 2));
    var reach = Math.min(r.width, r.height) * 0.22;
    pupil.style.transform = 'translate(' + (Math.cos(angle) * reach).toFixed(1) + 'px, ' + (Math.sin(angle) * reach).toFixed(1) + 'px)';
  }
}

05Letters floating in layers

The same word is stacked five copies deep with translateZ from −32px up to 0px, which reads as thickness. When the pointer tilts the stack, front and back copies shift by different amounts so the word feels carved from a solid block; the back copies fade to translucent ink so the front copy stays readable. The preview measured 3.03% changed area, 23/23 frames moving — big headlines on event pages.

translateZperspectiverotateX
.tx__layer {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%) translateZ(var(--z));
  color: rgba($subject-ink, .55);
  font-weight: 800; font-size: clamp(26px, 8vw, 40px); line-height: 1; letter-spacing: .04em;
}
.tx__layer:nth-child(1) { --z: -32px; }
.tx__layer:nth-child(2) { --z: -24px; }
.tx__layer:nth-child(3) { --z: -16px; }
.tx__layer:nth-child(4) { --z: -8px; }
.tx__layer--top {
  --z: 0px;
  position: relative; left: auto; top: auto;
  transform: translateZ(0);
}

06Highlight that treats the cursor as a lamp

A round light appears at the cursor, and its position is passed to each surface as a percentage of that surface's own box. The faceted surface uses a conic-gradient to fake folded planes, the rippled one a repeating radial gradient for its ripples, and the light blends through mix-blend-mode: screen, which keeps the pattern underneath visible. The preview measured 13.95% changed area, 22/23 frames moving — emphasis panels in dark dashboards.

radial-gradientconic-gradientmix-blend-mode
@property --mx { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --my { syntax: "<percentage>"; inherits: true; initial-value: 50%; }

/* The light is a round brightness spreading from the cursor. mix-blend-mode keeps the surface pattern alive */
.lt__glow {
  position: absolute; inset: 0;
  background: radial-gradient(circle 76px at var(--mx) var(--my),
    rgba($stage-paper, .92), rgba($stage-paper, .28) 42%, rgba($stage-paper, 0) 72%);
  mix-blend-mode: screen;
}
function lightAt(clientX, clientY) {
  var faces = panel.querySelectorAll('.lt__surface');
  for (var i = 0; i < faces.length; i++) {
    var r = faces[i].getBoundingClientRect();
    faces[i].style.setProperty('--mx', ((clientX - r.left) / r.width * 100).toFixed(1) + '%');
    faces[i].style.setProperty('--my', ((clientY - r.top) / r.height * 100).toFixed(1) + '%');
  }
}

07Panorama that splits front to back

Sky 8px, hills 22px, field 44px — each layer's travel coefficient grows toward the front, so the scene gains depth like a view through a window. JavaScript passes the same single value from −1 to 1 as in 02, and CSS keeps the coefficient per layer as --depth; the hills are two clip-path triangles. The preview measured 14.1% changed area, 21/23 frames moving — a natural fit for travel banners.

translateXfilterclip-path
.tp__layer {
  position: absolute; left: -12%; right: -12%;
  transform: translateX(calc(var(--px) * var(--depth)));
}
.tp__layer--sky {
  --depth: 8px;
  top: 0; height: 62%;
}
.tp__layer--hill {
  --depth: 22px;
  bottom: 26%; height: 48%;
}
.tp__layer--field {
  --depth: 44px;
  bottom: 0; height: 30%;
}

/* Hills as two clip-path triangles — a gradient draws a half wedge that never reads as a ridge */
.tp__peak { position: absolute; bottom: 0; height: 100%; clip-path: polygon(50% 0, 100% 100%, 0 100%); }

08Fallback that hands over to the tilt sensor

On devices without a mouse, deviceorientation's beta and gamma drive the same scene. The label showing who is in control is the whole point of this scene: since iOS 13, DeviceOrientationEvent.requestPermission grants the sensor only after a user tap — until then, the pointer stays in charge. The preview measured 1.71% changed area, 23/23 frames moving — product previews viewed on phones.

deviceorientationDeviceOrientationEventpointermove
// iOS 13+ grants the sensor only after DeviceOrientationEvent.requestPermission is user-approved — pointer stays until then
var hasSensor = typeof window.DeviceOrientationEvent !== 'undefined'
             && typeof window.DeviceOrientationEvent.requestPermission !== 'function';

if (hasSensor) {
  window.addEventListener('deviceorientation', function (e) {
    var beta = e.beta === null ? 0 : e.beta;     // front-back tilt
    var gamma = e.gamma === null ? 0 : e.gamma;  // left-right tilt
    tilt(Math.max(-12, Math.min(12, (beta - 40) * 0.4)), Math.max(-18, Math.min(18, gamma * 0.6)), TILT_LABEL);
  });
}

09Automatic turn when there is no pointer

A (hover: hover) and (pointer: fine) media query asks first whether the device has a pointer; if so, a person drives; otherwise, a CSS animation turns the scene by itself once every 8 seconds. When the user requests reduced motion, rotation stops and one face stays up. The preview measured 7.72% changed area, intensity 145.8, 23/23 frames moving — idle screens on kiosks.

prefers-reduced-motionmatchMediaanimation-duration
// Ask first whether the device has a pointer at all — a person drives if so, otherwise it turns by itself
var fine = window.matchMedia('(hover: hover) and (pointer: fine)');

function backToAuto() {
  prism.style.transform = '';
  prism.classList.add('ar__prism--auto');
  state.textContent = AUTO_LABEL;
}
/* Auto rotation is one class, added and removed — while a person drives, the class is absent */
.ar__prism--auto {
  animation-name: arTurn;
  animation-duration: 8s;
  animation-timing-function: $easing;
}

Where it breaks — the trap

The first trap was the preview's poster always landing on the flat front view. A rotated face's visible width is the cosine of the angle, so the area's rate of change is sine times angular speed — sweep ±16 degrees at constant speed and frames near 0 degrees differ the most, so the poster kept landing on the flattest frame. Re-cutting the keyframes to crawl through front and whip at the ends made the turned pose register the largest change under the same measurement. Second, the hills in 07 drawn with a conic-gradient came out as half wedges — a fan with its apex at the bottom center survives only when opened wider than 180 degrees, and a ridge needed clip-path triangles from the start. Third, cream text on the cube's default token blue (#2f6df6) measured 4.25:1, below the AA threshold of 4.5:1; stepping the face down to #0a4fe8 measured 5.98:1 (evidence: run/290/_대비실측.json). That the pointer actually drives each scene was verified on all nine with Playwright's mouse.move; the log is run/290/_probe/실측.json.

Trap Symptom Fix
Constant-speed sweep Poster always the flat front Crawl through front, whip at the ends
conic-gradient hills Drawn as half wedges Two clip-path triangles
Cream on blue 4.25:1 Below AA contrast Step face to #0a4fe8 for 5.98:1

The archive password for all nine scenes is 27ds8fgs; inside, each item ships as a vanilla and a React pair.

Accessibility (reduced-motion)

3D effects are decoration, so all nine scenes carry role="img" with a summary aria-label, and every pointer-dependent feature has a keyboard route — arrows rotate (01, 05), step a face (03), aim the eyes (04), move the light (06, 08), and 09 returns to auto rotation with Escape. When 08 announces its label, the sentence states who is actually driving — sensor or pointer. Under prefers-reduced-motion, rotation, parallax, and blinking stop, and each scene stands at its most readable fixed angle — 06 alone keeps the light's position, because where the light sits is the information, and only the motion is removed.

FAQ

What happens on a phone with no mouse?

The matchMedia query (hover: hover) and (pointer: fine) in 09 detects touch-only devices, where the scene turns slowly by itself. Add 08, and a tilt sensor takes over, so the hand tilting the device stands in for the mouse.

Why does the page slow down with these 3D transforms?

Usually it's the number of faces being rotated. An element wrapped in transform-style: preserve-3d turns its faces into stacked compositing layers, so the cost grows with each face. For layered parallax like 07, put will-change: transform only on the moving layers and avoid recomputing shadows or filter every frame.

Can I drop inertia and follow the pointer immediately?

In the step() of 01 and 05, remove the velocity terms and keep the single line now += (aim - now) * 0.14 for instant tracking. To go the other way, raise the friction coefficient (0.76) toward 1 — past 0.9 it wobbles for a long time after release, so stay between 0.76 and 0.85. The angle math comes from the Math.atan2 reference and pointer events from Pointer Events on MDN.

Enter the archive password

The password is inside this article. You will find it as you read.