Cursors✨ Premium

Cursor Tilt Effect

The pointer drives rotateX/rotateY up to ±15° and a radial highlight through two CSS variables, with a 500 ms return on leave. The transform string written by the JS starts with perspective(600px): the card really tilts, near edge growing and far edge shrinking, with nothing required from the parent.

JSCSS 3DPerspective

You'll get access to the interactive demo with a free account.

This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Cursors has 26 effects, including 1 free. Explore the category →

3 usage examples

Pricing card — featured plan — Cursor Tilt Effect example 1

① Pricing card — featured plan

WhenPricing grid where the recommended plan sits in a larger card, with price, feature list and button.
WhyThe tilt and the indigo shadow that appears on hover give relief to the plan you want to sell, without touching the text. On a 330 px card, ±15° stays moderate; the highlight following the hand makes the card feel “material”.
Settings330 × 236 px card, perspective(600px) kept in the JS, amplitude lowered to 10 (both coefficients), scale(1.03) to avoid overlapping neighboring cards.
Project tile — portfolio grid — Cursor Tilt Effect example 2

② Project tile — portfolio grid

WhenProject mosaic: full-frame visual, title and category at the bottom of the tile.
WhyOver an image, the 12% highlight acts as a varnish catching the light, and the tilt singles out the hovered tile without a harsh zoom. The 500 ms return with the cubic-bezier(.23, 1, .32, 1) curve lays the card down gently.
Settings360 × 226 px tile, perspective(800px) in the JS for a softer swing, highlight raised to rgba(255,255,255,.18), neutral 0 24px 48px rgba(0,0,0,.45) shadow rather than indigo.
Event badge — personal pass — Cursor Tilt Effect example 3

③ Event badge — personal pass

WhenAttendee card: name, role, QR code, date and venue, presented as an object to handle.
WhyA badge is a physical object: the tilt following the hand and the highlight reproduce the laminated card turned under a lamp. The rotation stays under ±15°, the QR code stays readable at all times.
Settings340 × 210 px card, perspective(500px) in the JS to accentuate the relief, amplitude 15 kept, overflow: hidden kept for the highlight — the content's translateZ will stay inert, as expected.

How it works

Each [data-tilt-card] (200 × 140 px, 145deg #1e1e3a → #2a2a4e gradient, border-radius: 16px) listens to mousemove. The script measures the card (getBoundingClientRect), computes the pointer position relative to the center and derives two angles: rotateX = (y − cy) / cy × −15 and rotateY = (x − cx) / cx × 15, i.e. ±15° at the edges (measured: 13.7° at 97% of the width), 0° at the center. It writes transform: perspective(600px) rotateX(…) rotateY(…) scale(1.05) and box-shadow: 0 20px 40px rgba(99,102,241,.3) inline. The signs are chosen so that the edge under the pointer recedes, as if pressed.

The highlight is a .tilt-shine layer at position: absolute; inset: 0, pointer-events: none, whose background is radial-gradient(circle at var(--shine-x, 50%) var(--shine-y, 50%), rgba(255,255,255,.12) 0%, transparent 60%). On every move, the JS writes --shine-x and --shine-y as percentages of the card: the bright spot follows the pointer. On mouseenter, the transition is set to box-shadow .3s ease only — so the rotation tracks the pointer with no smoothing; on mouseleave, transform goes back to perspective(600px) rotateX(0) rotateY(0) scale(1), the shadow to none, and the transition becomes transform .5s cubic-bezier(.23, 1, .32, 1), box-shadow .5s ease: the return takes 500 ms.

The perspective is carried by the transform string itself: perspective(600px) at its head, on every write, return included. That is what provides the vanishing point — without it, a 3D rotation is projected orthographically and the card doesn't tilt, it compresses: measured at 13.7° with no perspective, its box goes from 210 to 204 px wide (cos 13.7°) and keeps 147 px of height, a 3% shrink with no trapezoid and no depth. With a 600 px perspective, the same card measures 154 px tall at the same angle: the near edge grows, the far edge shrinks, the 3D appears. Carrying the perspective in the transform rather than on the parent makes each card self-contained: its vanishing point is centered on it and doesn't depend on the container you drop it in.

One promise stays inert: .tilt-icon and .tilt-label carry transform: translateZ(30px) and translateZ(20px), and the card transform-style: preserve-3d, so the content floats above the face. But the card also has overflow: hidden, which forces transform-style: flat per the specification: the children are flattened into the card's plane. Measured with a 600 px perspective: the icon is 20.2 px wide with overflow: hidden and 21.3 px with overflow: visible (× 1.053 = 600 / 570), proof that the relief only acts once the overflow is removed — yet that is what clips the highlight to the rounded corners. The code implicitly picks the highlight over the relief.

Accessibility

  • prefers-reduced-motion not handled: the tilt, the 5% zoom and the 500 ms return play regardless of the preference. The safest fix is not to attach the listeners when matchMedia('(prefers-reduced-motion: reduce)').matches; otherwise, @media (prefers-reduced-motion: reduce) { .tilt-card { transform: none !important; transition: none !important } } overrides the JS's inline writes.
  • The effect is mouse-only and the card is a div with cursor: pointer that does nothing on click: the promised hand has no action behind it. If the card leads somewhere, make it an a or a button and give it a :focus-visible style — on the keyboard no tilt will signal focus, which is acceptable as long as focus is visible otherwise.
  • Contrast: the rgba(255,255,255,.7) label at 13.6 px measures 8.6:1 on #1e1e3a and 7.5:1 on #2a2a4e, 5.6:1 under the highlight spot (12% white) — AA everywhere, AAA outside the highlight. The native pointer stays visible (no cursor: none).
  • Screen readers: .tilt-shine is an empty div with no role — harmless, but add aria-hidden="true" so nothing lists it. Transforms don't alter the accessibility tree: the icon (an emoji, read by its Unicode name — give it aria-hidden or an aria-label) and the label are read as is.
  • Integration: the perspective already heads the transform string (perspective(600px)), nothing to add on the parent; scale(1.05) makes the card overflow by 5 px on each side onto its neighbors — plan a margin or a hover z-index; will-change: transform reserves a compositing layer per card, remove it beyond a dozen instances. The reset on leave is done by the code (transform, shadow, transition).

Browser compatibility

ES5 code (var, function). The CSS requires 3D transforms (rotateX/rotateY), custom properties (var()) for the highlight and the inset shorthand, which sizes the highlight layer. Zero dependencies.

Chrome 87+✓ Full
Firefox 66+✓ Full
Safari 14.1+✓ Full
Edge 87+✓ Full
Mobile iOS 14.5+✓ Static (no hover)
Android Chrome✓ Static (no hover)

Without JavaScript, the card is static with a centered highlight (the variables fall back to 50%). Without inset (Safari before 14.1, Firefox before 66), the highlight layer has neither size nor position: no bright spot, the tilt and shadow remain. Without var() (2016 browsers), the gradient is invalid and the highlight disappears likewise.

The code

HTML structure to paste into your page (CSS + JS available with a premium account):

index.html — structure
<div class="tilt-card" data-tilt-card>
  <div class="tilt-shine"></div>
  <div class="tilt-icon">⚖</div>
  <div class="tilt-label">Tilt me</div>
</div>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
perspective (JS — perspective(600px) heading the transform) 600 px Vanishing point of the tilt, written in the transform string on mousemove and on return: change the value in both places. The smaller it is, the stronger the 3D: 400px = very pronounced, 1200px = subtle. A perspective also set on the parent would stack with this one.
Amplitude (JS — × −15 and × 15) ±15° at the edges Maximum angle reached when the pointer touches an edge. 8 for a wide card or dense text; 25 for a playful object. The two coefficients may differ (less rotateX on a tall card).
Hover zoom (JS — scale(1.05)) 1.05 Enlargement during hover, 5 px on each side of a 200 px card. 1 removes the overflow onto neighbors; 1.08 emphasizes the highlight.
Hover shadow (JS — 0 20px 40px rgba(99,102,241,.3)) indigo, 40 px blur, offset 20 px Shadow written on mousemove, removed on mouseleave (none). A neutral rgba(0,0,0,.45) shadow suits visuals; the vertical offset suggests the card's height.
Return (JS — transform 0.5s cubic-bezier(0.23,1,0.32,1)) .5s, pronounced ease-out curve Duration and curve of the return to flat on leave. .25s = snappy card; .8s with cubic-bezier(.34, 1.56, .64, 1) = slight bounce at rest.
Highlight (CSS — rgba(255,255,255,.12) 0%, transparent 60%) 12% white, 60% radius Intensity and extent of the bright spot. .2 over dark visuals; transparent 40% for a more focused highlight. Remove the .tilt-shine layer for a dry tilt.
Size and face (CSS — .tilt-card 200 × 140, 145deg gradient) 200 × 140 px, #1e1e3a → #2a2a4e Geometry is free: the angles are computed in proportion to the actual width and height. On a large card, lower the amplitude; the gradient can give way to a background image, the highlight sits on top.

FAQ

At the head of the transform string the script writes: 'perspective(600px) rotateX(' + …, on mousemove as on the mouseleave return — order matters, the perspective must be the first function. Without it, the browser would project the rotation orthographically: near and far edges would keep the same size, the card would merely shrink (measured: 204 px wide instead of 210 at 13.7°, height unchanged); with it, the same rotation yields a 154 px tall trapezoid. Carrying it in the transform rather than as perspective: 600px on the parent makes the card self-contained — the vanishing point is centered on it and doesn't depend on its container — at the cost of a value to change in two places in the JS. If you prefer the property on the parent, remove the function from both strings so they don't stack.
Because the card has overflow: hidden. The specification requires that any overflow other than visible forces transform-style: flat: the declared preserve-3d is ignored and the children are flattened into the card's plane, translateZ included. Measured with a 600 px perspective: the icon is 20.2 px with overflow: hidden and 21.3 px with overflow: visible, i.e. the expected 600 / (600 − 30) magnification. The dilemma: overflow: hidden is what clips the highlight to the rounded corners. To get both, remove the card's overflow and give the .tilt-shine layer its own border-radius: 16px (or a clip-path: inset(0 round 16px)).
The script runs querySelectorAll('[data-tilt-card]'): each card gets its own listeners and its own highlight (card.querySelector('.tilt-shine'), optional). For a clickable card, replace the div with an a set to display: flex — the code doesn't depend on the tag. Two precautions: scale(1.05) makes the hovered card overflow onto its neighbors, give it position: relative; z-index: 1 on hover or a 6 px margin; and will-change: transform creates a compositing layer per card, drop it if the grid has dozens. On touch, no mousemove: the card stays flat and clickable.