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.
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



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
divwithcursor: pointerthat does nothing on click: the promised hand has no action behind it. If the card leads somewhere, make it anaor abuttonand give it a:focus-visiblestyle — 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#1e1e3aand 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 (nocursor: none). - Screen readers:
.tilt-shineis an emptydivwith no role — harmless, but addaria-hidden="true"so nothing lists it. Transforms don't alter the accessibility tree: the icon (an emoji, read by its Unicode name — give itaria-hiddenor anaria-label) and the label are read as is. - Integration: the perspective already heads the
transformstring (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 hoverz-index;will-change: transformreserves 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.
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):
<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>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
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
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.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)).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.