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>
    );
}