hover tilt
3D perspective tilt card — cursor position maps to X/Y rotation in real time.
- react
- interaction
- motion
preview
hover me
source · HoverTilt.tsx
import { useRef, useState, useCallback } from 'react';
/** Maximum tilt angle in degrees applied to each axis. */
const MAX_TILT = 8;
/**
* Hover-tilt snippet — a card that rotates in 3D perspective as the cursor moves.
*
* Mouse coordinates are normalised to [-1, 1] relative to the card centre,
* then multiplied by MAX_TILT to produce `rotateX` / `rotateY` values.
*
* Transition strategy:
* - While hovering: `80ms linear` — fast response, follows the cursor.
* - On mouse-leave: `500ms cubic-bezier(0.23,1,0.32,1)` — spring-like return.
*
* Controlled mode (Remotion): when `rx`, `ry`, or `isOn` props are provided they
* take precedence over internal mouse state, and CSS transitions are disabled
* (irrelevant inside the Remotion renderer). All styles are inline so the
* component works without a Tailwind setup.
*/
type Props = {
/** External X rotation in degrees — overrides internal mouse tracking when set. */
rx?: number;
/** External Y rotation in degrees — overrides internal mouse tracking when set. */
ry?: number;
/** External hover/active state — overrides internal mouse enter/leave when set. */
isOn?: boolean;
};
export default function HoverTilt({ rx: rxProp, ry: ryProp, isOn: isOnProp }: Props) {
const isControlled = rxProp !== undefined || ryProp !== undefined || isOnProp !== undefined;
const ref = useRef<HTMLDivElement>(null);
const [rxInternal, setRxInternal] = useState(0);
const [ryInternal, setRyInternal] = useState(0);
const [onInternal, setOnInternal] = useState(false);
const rx = rxProp ?? rxInternal;
const ry = ryProp ?? ryInternal;
const on = isOnProp ?? onInternal;
const track = useCallback((e: React.MouseEvent) => {
if (isControlled || !ref.current) return;
const r = ref.current.getBoundingClientRect();
const nx = (e.clientX - r.left - r.width / 2) / (r.width / 2);
const ny = (e.clientY - r.top - r.height / 2) / (r.height / 2);
setRxInternal(-ny * MAX_TILT);
setRyInternal(nx * MAX_TILT);
}, [isControlled]);
const reset = useCallback(() => {
if (isControlled) return;
setOnInternal(false);
setRxInternal(0);
setRyInternal(0);
}, [isControlled]);
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
userSelect: 'none',
perspective: '600px',
touchAction: 'manipulation',
}}
onMouseEnter={isControlled ? undefined : () => setOnInternal(true)}
onMouseLeave={isControlled ? undefined : reset}
onMouseMove={isControlled ? undefined : track}
>
<div
ref={ref}
style={{
width: 148,
height: 96,
borderRadius: 12,
// Radial gradient shifts toward top when hovered — creates subtle
// top-lighting that reinforces the 3D tilt illusion.
background: on
? 'radial-gradient(120% 90% at 50% 0%, oklch(0.32 0 0) 0%, oklch(0.18 0 0) 100%)'
: 'oklch(0.21 0 0)',
boxShadow: on
? '0 24px 48px -12px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.06)'
: '0 1px 4px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.03)',
outline: `1px solid ${on ? 'oklch(0.38 0 0)' : 'oklch(0.27 0 0)'}`,
transform: `perspective(600px) rotateX(${rx}deg) rotateY(${ry}deg) scale(${on ? 1.03 : 1})`,
// In controlled (Remotion) mode transitions are irrelevant — skip them
// to avoid fighting frame-by-frame prop changes.
transition: isControlled
? 'none'
: on
? 'transform 80ms linear, box-shadow 150ms ease, background 150ms ease'
: 'transform 500ms cubic-bezier(0.23,1,0.32,1), box-shadow 300ms ease, background 300ms ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
cursor: isControlled ? 'default' : undefined,
fontFamily: '"Geist Mono", "JetBrains Mono", "SF Mono", "Fira Code", monospace',
fontSize: 10,
}}
>
<span
style={{
color: on ? 'rgb(212, 212, 216)' : 'rgb(82, 82, 91)',
transition: isControlled ? 'none' : 'color 200ms',
}}
>
{on ? '✦ tilting' : 'hover me'}
</span>
</div>
</div>
);
}